diff --git a/pygittools/hook_samples/README.md b/pygittools/hook_samples/README.md
index 001a538..4f61549 100644
--- a/pygittools/hook_samples/README.md
+++ b/pygittools/hook_samples/README.md
@@ -30,6 +30,24 @@ Environment variables:
 - `PYGITWEB_COMMIT_MSG_PATTERN` — message regex (default `^(\S+): (.+)`)
 - `PYGITWEB_REJECT_MERGE_COMMITS` — set to `1`/`true`/`yes` to reject merge commits
 
+## pre-receive.branch-permissions
+
+Enforce per-branch write permissions based on `auth.json` `oauth_permissions`. For each
+updated branch ref (`refs/heads/*`), the hook:
+
+- Builds a `project/branch` subject (e.g. `demo/main`, `demo/dev/alice.feature`).
+- If any `pgw.write_branch.{scope}` keys match that subject, requires a matching grant.
+- Otherwise, falls back to project write (`pgw.write.{project}`) for that project.
+
+When no usable configuration or context is available, the hook is a no-op and allows
+the push to proceed.
+
+Environment variables:
+
+- `PYGITWEB_PRINCIPAL` — principal identity string (email or username) to check grants for.
+- `PYGITWEB_PROJECT` — project path as known to pygitweb (e.g. `demo`, `team/app`).
+- `PYGITWEB_AUTH_CONFIG` — optional auth.json path (default: `~/.pygitweb/auth.json`).
+
 ## post-commit.push-remotes
 
 After each local commit, force-with-lease push the current branch to every configured
diff --git a/pygittools/hook_samples/pre-receive.branch-permissions b/pygittools/hook_samples/pre-receive.branch-permissions
new file mode 100644
index 0000000..070b018
--- /dev/null
+++ b/pygittools/hook_samples/pre-receive.branch-permissions
@@ -0,0 +1,69 @@
+#!/usr/bin/env -S uv run python
+
+from __future__ import annotations
+
+__VERSION__ = "1"
+
+import os
+import sys
+from pathlib import Path
+
+from pygittools.hooks import HookResult, RefUpdate, parse_ref_updates
+from pygitweb.auth_config import DEFAULT_AUTH_CONFIG_PATH, AuthConfig
+from pygitweb.permissions import PermissionsMap, has_branch_write_permission
+
+
+def _normalize_env(name: str) -> str | None:
+	value = os.environ.get(name)
+	if value is None:
+		return None
+	stripped = value.strip()
+	return stripped or None
+
+
+def _load_permissions() -> PermissionsMap | None:
+	config_path_env = _normalize_env("PYGITWEB_AUTH_CONFIG")
+	path = Path(config_path_env).expanduser() if config_path_env is not None else DEFAULT_AUTH_CONFIG_PATH
+	try:
+		text = path.read_text(encoding="utf-8")
+	except OSError:
+		return None
+	try:
+		config = AuthConfig.model_validate_json(text)
+	except Exception:
+		return None
+	return config.oauth_permissions
+
+
+def _check_updates(
+	principal: str,
+	project: str,
+	updates: list[RefUpdate],
+	permissions: PermissionsMap,
+) -> HookResult:
+	for _, _, ref_name in updates:
+		if has_branch_write_permission(permissions, principal, project, ref_name):
+			continue
+		sys.stderr.write(
+			f"Push rejected: {principal!r} lacks branch write permission for project {project!r} ref {ref_name!r}.\n",
+		)
+		return HookResult.FAILURE
+	return HookResult.SUCCESS
+
+
+def main() -> int:
+	principal = _normalize_env("PYGITWEB_PRINCIPAL")
+	project = _normalize_env("PYGITWEB_PROJECT")
+	if principal is None or project is None:
+		return int(HookResult.SUCCESS.value)
+	permissions = _load_permissions()
+	if permissions is None:
+		return int(HookResult.SUCCESS.value)
+	updates = parse_ref_updates(sys.stdin)
+	result = _check_updates(principal, project, updates, permissions)
+	return int(result.value)
+
+
+if __name__ == "__main__":
+	raise SystemExit(main())
+
diff --git a/pygitweb/actions.py b/pygitweb/actions.py
index d4f659c..dc30d99 100644
--- a/pygitweb/actions.py
+++ b/pygitweb/actions.py
@@ -20,6 +20,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Res
 from python_ripgrep import PySortMode, PySortModeKind
 from python_ripgrep import search as rg_search
 
+from pygitweb.auth import can_read_branch
 from pygitweb.config import BLOB_LANG, settings
 from pygitweb.formatting import age_string, sanitize, to_utf8
 from pygitweb.git_helpers import (
@@ -972,13 +973,17 @@ def git_history(
 	)
 
 
-def git_heads(project: str) -> HTMLResponse:
+def git_heads(project: str, token: str | None) -> HTMLResponse:
 	"""Heads (branches) list page. Port of git_heads."""
 	heads_list = git_get_heads_list(project)
-	if not heads_list:
+	if settings.AUTH:
+		visible_heads = [(name, ref, oid) for name, ref, oid in heads_list if can_read_branch(project, ref, token)]
+	else:
+		visible_heads = heads_list
+	if not visible_heads:
 		raise HTTPException(status_code=404, detail="No heads found")
 	rows = []
-	for name, _ref, oid in heads_list:
+	for name, _ref, oid in visible_heads:
 		oid_short = oid[:7] if oid else ""
 		commit_link = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
 		tree_link = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
diff --git a/pygitweb/auth.py b/pygitweb/auth.py
index 27e24e3..6da259e 100644
--- a/pygitweb/auth.py
+++ b/pygitweb/auth.py
@@ -23,16 +23,15 @@ from fastapi.security import APIKeyCookie, OAuth2PasswordBearer, OAuth2PasswordR
 from pydantic import BaseModel
 
 from pygitweb.auth_config import (
-	auth_config,
 	is_auth_configured,
 	is_local_auth_available,
 	is_oauth_auth_available,
 )
 from pygitweb.auth_oauth import OAUTH_STATE_COOKIE_NAME, oauth_router
-from pygitweb.config import settings
+from pygitweb.config import auth_config, settings
 from pygitweb.gravatar import gravatar_url
 from pygitweb.password_hash import verify_password
-from pygitweb.permissions import Permission, PermissionPrincipal
+from pygitweb.permissions import Permission, PermissionPrincipal, has_branch_read_permission
 from pygitweb.projects import git_get_project_owner
 from pygitweb.sessions import create_session, get_session, revoke_session
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
@@ -65,6 +64,23 @@ def principal_from_session(token: str | None) -> PermissionPrincipal | None:
 	return PermissionPrincipal(username=rec.username, email=rec.email)
 
 
+def _anonymous_principal_for_permissions() -> PermissionPrincipal | None:
+	for entry in auth_config.local_users:
+		if getattr(entry, "assume_anonymous", False):
+			return PermissionPrincipal(username=entry.user, email=entry.email)
+	return None
+
+
+def _principal_identity_for_permissions(token: str | None) -> str | None:
+	principal = principal_from_session(token)
+	if principal is not None:
+		return principal.identity
+	anonymous = _anonymous_principal_for_permissions()
+	if anonymous is not None:
+		return anonymous.identity
+	return None
+
+
 def has_permission(
 	permission: Permission,
 	project: str | None = None,
@@ -73,10 +89,10 @@ def has_permission(
 ) -> bool:
 	if not settings.AUTH:
 		return True
-	principal = principal_from_session(token)
-	if principal is None:
+	identity = _principal_identity_for_permissions(token)
+	if identity is None:
 		return False
-	return auth_config.oauth_permissions.has_permission(principal.identity, permission, project)
+	return auth_config.oauth_permissions.has_permission(identity, permission, project)
 
 
 def _owner_matches(project: str, token: str | None) -> bool:
@@ -111,6 +127,17 @@ def can_write_project(project: str, token: str | None) -> bool:
 	return has_permission(Permission.WRITE, project, token=token)
 
 
+def can_read_branch(project: str, ref_name: str, token: str | None) -> bool:
+	if not settings.AUTH:
+		return True
+	if _owner_matches(project, token):
+		return True
+	identity = _principal_identity_for_permissions(token)
+	if identity is None:
+		return False
+	return has_branch_read_permission(auth_config.oauth_permissions, identity, project, ref_name)
+
+
 def ensure_read_project(project: str, token: str | None) -> None:
 	if can_read_project(project, token):
 		return
diff --git a/pygitweb/auth.schema.json b/pygitweb/auth.schema.json
index 9e0d8db..500e981 100644
--- a/pygitweb/auth.schema.json
+++ b/pygitweb/auth.schema.json
@@ -60,7 +60,7 @@
         "additionalProperties": {
           "type": "array",
           "items": { "type": "string" },
-          "description": "Scoped keys: pgw.read.{scope}, pgw.write.{scope}, pgw.boards.{scope}, pgw.tasks.{scope}, pgw.comments.{scope}, pgw.hooks.{scope}, pgw.mr.create.{scope}, pgw.mr.merge.{scope} (fnmatch on project path). Principal * matches any authenticated user."
+          "description": "Scoped keys: pgw.read.{scope}, pgw.write.{scope}, pgw.read_branch.{scope}, pgw.write_branch.{scope}, pgw.boards.{scope}, pgw.tasks.{scope}, pgw.comments.{scope}, pgw.hooks.{scope}, pgw.mr.create.{scope}, pgw.mr.merge.{scope} (fnmatch on project or project/branch path). Principal * matches any authenticated user."
         }
       },
       "local_users": {
@@ -94,6 +94,10 @@
               "type": "string",
               "format": "email",
               "description": "Optional e-mail for Gravatar and permission grants by principal e-mail."
+            },
+            "assume_anonymous": {
+              "type": "boolean",
+              "description": "If true, unauthenticated requests assume this user for read permissions (e.g. public viewer role)."
             }
           }
         }
diff --git a/pygitweb/auth_config.py b/pygitweb/auth_config.py
index a304837..e550037 100644
--- a/pygitweb/auth_config.py
+++ b/pygitweb/auth_config.py
@@ -12,14 +12,16 @@ import secrets
 import stat
 import sys
 from pathlib import Path
-from typing import Literal
+from typing import TYPE_CHECKING, Literal
 
 from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
 
-from pygitweb.config import Settings
 from pygitweb.password_hash import hash_password
 from pygitweb.permissions import PermissionsMap
 
+if TYPE_CHECKING:
+	from pygitweb.config import Settings
+
 DEFAULT_AUTH_CONFIG_PATH = Path.home() / ".pygitweb" / "auth.json"
 _AUTH_CONFIG_MODE = 0o600
 
@@ -70,6 +72,7 @@ class LocalUser(BaseModel):
 	password: str | None = Field(default=None, alias="pass", serialization_alias="pass")
 	pass_hash: str = ""
 	email: str | None = None
+	assume_anonymous: bool = False
 
 	@field_validator("email", mode="before")
 	@classmethod
diff --git a/pygitweb/auth_oauth.py b/pygitweb/auth_oauth.py
index 5c283f9..64191f9 100644
--- a/pygitweb/auth_oauth.py
+++ b/pygitweb/auth_oauth.py
@@ -23,8 +23,8 @@ import httpx
 from fastapi import APIRouter, HTTPException, Query, Request, status
 from fastapi.responses import RedirectResponse
 
-from pygitweb.auth_config import AuthConfig, OAuthUsernameFrom, auth_config, is_oauth_auth_available
-from pygitweb.config import settings
+from pygitweb.auth_config import AuthConfig, OAuthUsernameFrom, is_oauth_auth_available
+from pygitweb.config import auth_config, settings
 from pygitweb.sessions import create_session
 
 OAUTH_STATE_COOKIE_NAME = "pygitweb_oauth_state"
diff --git a/pygitweb/auth_test.py b/pygitweb/auth_test.py
index cdf9aa8..ab91ef4 100644
--- a/pygitweb/auth_test.py
+++ b/pygitweb/auth_test.py
@@ -294,6 +294,32 @@ def test_oauth_allowed_emails_exact_match() -> None:
 	assert not is_oauth_email_allowed(cfg, "other@example.com")
 
 
+def test_anonymous_user_assumes_local_public_role_for_read_permissions() -> None:
+	from pygitweb.auth import can_read_project
+
+	cfg = AuthConfig(
+		auth_mode="local",
+		local_users=[
+			LocalUser(
+				user="public",
+				password="secret",
+				email="public@example.com",
+				assume_anonymous=True,
+			),
+		],
+		oauth_permissions=PermissionsMap.model_validate({
+			"*": [],
+			"pgw.read.secret": ["public@example.com"],
+		}),
+	)
+	with (
+		patch.object(settings, "AUTH", True),
+		patch("pygitweb.auth.auth_config", cfg),
+	):
+		assert can_read_project("secret", None)
+		assert not can_read_project("other", None)
+
+
 _OAUTH_CFG = AuthConfig(
 	auth_mode="oauth",
 	oauth_provider="google",
diff --git a/pygitweb/hooks_install.py b/pygitweb/hooks_install.py
index 75a8d1d..757d720 100644
--- a/pygitweb/hooks_install.py
+++ b/pygitweb/hooks_install.py
@@ -34,6 +34,7 @@ _SAMPLE_LABELS: dict[str, str] = {
 	"pre-commit.ruff": "Ruff Pre-commit",
 	"commit-msg.pattern": "Commit Message Pattern",
 	"pre-receive.protected-pattern": "Protected Branch Commit Messages",
+	"pre-receive.branch-permissions": "Branch Write Permissions",
 	"post-commit.push-remotes": "Post-commit Push Remotes",
 }
 
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 94f7907..cf8acdb 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -60,10 +60,10 @@ from pygitweb.auth import (
 	ensure_permission,
 	require_permission,
 )
-from pygitweb.auth_config import auth_config, is_auth_configured
+from pygitweb.auth_config import is_auth_configured
 from pygitweb.board_refs import BoardRefManifest, board_ref_manifest
 from pygitweb.change_queue import CHANGE_QUEUE
-from pygitweb.config import ACTIONS, get_loadavg, settings
+from pygitweb.config import ACTIONS, auth_config, get_loadavg, settings
 from pygitweb.dependencies import (
 	ValidatedBoardCreateProject,
 	ValidatedNotifyProject,
@@ -751,6 +751,7 @@ async def internal_notify(
 async def dispatch(
 	request: Request,
 	project: ValidatedReadableProject,
+	token: Annotated[str | None, Depends(access_token_from_request)],
 	a: Annotated[str | None, Query(alias="a")] = None,
 	h: Annotated[str | None, Query(alias="h")] = None,
 	hb: Annotated[str | None, Query(alias="hb")] = None,
@@ -845,7 +846,7 @@ async def dispatch(
 		p, pc = parse_pagination(page, pagecount)
 		return git_history(project, hash_param, file_name, request, p, pc)
 	if action == "heads":
-		return git_heads(project)
+		return git_heads(project, token)
 	if action == "tags":
 		p, pc = parse_pagination(page, pagecount)
 		return git_tags(project, request, p, pc)
diff --git a/pygitweb/permissions.py b/pygitweb/permissions.py
index ea86bcc..ea082f5 100644
--- a/pygitweb/permissions.py
+++ b/pygitweb/permissions.py
@@ -27,7 +27,11 @@ WILDCARD_PRINCIPAL = "*"
 PERMISSION_ADD_PROJECTS = "pgw.addprojects"
 PERMISSION_SETTINGS = "pgw.settings"
 
-_SCOPED_PREFIX = re.compile(r"^pgw\.(boards|tasks|comments|hooks|mr\.create|mr\.merge|read|write)\.(.+)$")
+_SCOPED_PREFIX = re.compile(
+	r"^pgw\.(boards|tasks|comments|hooks|mr\.create|mr\.merge|read|write|read_branch|write_branch)\.(.+)$",
+)
+
+_BRANCH_REF_PREFIX = "refs/heads/"
 
 
 class Permission(StrEnum):
@@ -35,6 +39,8 @@ class Permission(StrEnum):
 
 	READ = "read"
 	WRITE = "write"
+	READ_BRANCH = "read_branch"
+	WRITE_BRANCH = "write_branch"
 	BOARDS = "boards"
 	TASKS = "tasks"
 	COMMENTS = "comments"
@@ -57,6 +63,8 @@ class Permission(StrEnum):
 		if self in (
 			Permission.READ,
 			Permission.WRITE,
+			Permission.READ_BRANCH,
+			Permission.WRITE_BRANCH,
 			Permission.BOARDS,
 			Permission.TASKS,
 			Permission.COMMENTS,
@@ -116,6 +124,49 @@ def project_matches_scope(project: str, scope: str) -> bool:
 	return fnmatch.fnmatchcase(project, scope)
 
 
+def short_branch_name_from_ref(ref_name: str) -> str | None:
+	if not ref_name.startswith(_BRANCH_REF_PREFIX):
+		return None
+	return ref_name.removeprefix(_BRANCH_REF_PREFIX)
+
+
+def branch_permission_subject(project: str, ref_name: str) -> str | None:
+	short_name = short_branch_name_from_ref(ref_name)
+	if short_name is None:
+		return None
+	return f"{project}/{short_name}"
+
+
+def has_branch_read_permission(
+	permissions: PermissionsMap,
+	principal: str | None,
+	project: str,
+	ref_name: str,
+) -> bool:
+	subject = branch_permission_subject(project, ref_name)
+	if subject is None:
+		return True
+	branch_specific = permissions.matching_scoped_principals(Permission.READ_BRANCH, subject)
+	if branch_specific:
+		return permissions.has_permission(principal, Permission.READ_BRANCH, subject)
+	return permissions.has_permission(principal, Permission.READ, project)
+
+
+def has_branch_write_permission(
+	permissions: PermissionsMap,
+	principal: str | None,
+	project: str,
+	ref_name: str,
+) -> bool:
+	subject = branch_permission_subject(project, ref_name)
+	if subject is None:
+		return True
+	branch_specific = permissions.matching_scoped_principals(Permission.WRITE_BRANCH, subject)
+	if branch_specific:
+		return permissions.has_permission(principal, Permission.WRITE_BRANCH, subject)
+	return permissions.has_permission(principal, Permission.WRITE, project)
+
+
 class PermissionsMap(RootModel[dict[str, list[str]]]):
 	"""Validated ``oauth_permissions`` object from auth.json."""
 
diff --git a/pygitweb/permissions_test.py b/pygitweb/permissions_test.py
index 5f987d3..192f80b 100644
--- a/pygitweb/permissions_test.py
+++ b/pygitweb/permissions_test.py
@@ -5,9 +5,13 @@ from pygitweb.permissions import (
 	PERMISSION_SETTINGS,
 	Permission,
 	PermissionsMap,
+	branch_permission_subject,
+	has_branch_read_permission,
+	has_branch_write_permission,
 	parse_permission_key,
 	permission_key,
 	project_matches_scope,
+	short_branch_name_from_ref,
 )
 
 
@@ -54,12 +58,31 @@ def test_parse_mr_scoped_keys() -> None:
 	assert merge.permission is Permission.MR_MERGE
 
 
+def test_parse_branch_scoped_keys() -> None:
+	read = parse_permission_key("pgw.read_branch.team/app-main")
+	assert read is not None
+	assert read.permission is Permission.READ_BRANCH
+	assert read.project_scope == "team/app-main"
+	write = parse_permission_key("pgw.write_branch.dev/alice.*")
+	assert write is not None
+	assert write.permission is Permission.WRITE_BRANCH
+	assert write.project_scope == "dev/alice.*"
+
+
 def test_project_glob() -> None:
 	assert project_matches_scope("team/app", "team/*")
 	assert not project_matches_scope("other/app", "team/*")
 	assert project_matches_scope("anything", "*")
 
 
+def test_branch_permission_subject() -> None:
+	assert short_branch_name_from_ref("refs/heads/main") == "main"
+	assert short_branch_name_from_ref("refs/heads/dev/alice.feature") == "dev/alice.feature"
+	assert short_branch_name_from_ref("refs/tags/v1.0.0") is None
+	assert branch_permission_subject("demo", "refs/heads/main") == "demo/main"
+	assert branch_permission_subject("demo", "refs/tags/v1.0.0") is None
+
+
 def test_superuser_grants_all() -> None:
 	m = PermissionsMap.model_validate({
 		"*": ["admin@example.com"],
@@ -138,3 +161,41 @@ def test_owner_grants_read_and_write() -> None:
 		assert can_read_project("secret", token)
 		assert can_write_project("secret", token)
 		assert not can_read_project("secret", None)
+
+
+def test_has_branch_write_permission_falls_back_to_project_scope() -> None:
+	m = PermissionsMap.model_validate({
+		"*": [],
+		"pgw.write.demo": ["writer@example.com"],
+	})
+	assert has_branch_write_permission(m, "writer@example.com", "demo", "refs/heads/main")
+	assert not has_branch_write_permission(m, "other@example.com", "demo", "refs/heads/main")
+
+
+def test_has_branch_write_permission_enforces_branch_specific_rules() -> None:
+	m = PermissionsMap.model_validate({
+		"*": [],
+		"pgw.write.demo": ["writer@example.com"],
+		"pgw.write_branch.demo/main": ["branch-writer@example.com"],
+	})
+	assert has_branch_write_permission(m, "branch-writer@example.com", "demo", "refs/heads/main")
+	assert not has_branch_write_permission(m, "writer@example.com", "demo", "refs/heads/main")
+
+
+def test_has_branch_read_permission_falls_back_to_project_scope() -> None:
+	m = PermissionsMap.model_validate({
+		"*": [],
+		"pgw.read.demo": ["reader@example.com"],
+	})
+	assert has_branch_read_permission(m, "reader@example.com", "demo", "refs/heads/main")
+	assert not has_branch_read_permission(m, "other@example.com", "demo", "refs/heads/main")
+
+
+def test_has_branch_read_permission_enforces_branch_specific_rules() -> None:
+	m = PermissionsMap.model_validate({
+		"*": [],
+		"pgw.read.demo": ["reader@example.com"],
+		"pgw.read_branch.demo/main": ["branch-reader@example.com"],
+	})
+	assert has_branch_read_permission(m, "branch-reader@example.com", "demo", "refs/heads/main")
+	assert not has_branch_read_permission(m, "reader@example.com", "demo", "refs/heads/main")
