diff --git a/pygitweb/addproject_permissions_test.py b/pygitweb/addproject_permissions_test.py
index a463ab5..0529460 100644
--- a/pygitweb/addproject_permissions_test.py
+++ b/pygitweb/addproject_permissions_test.py
@@ -6,13 +6,12 @@ from fastapi.testclient import TestClient
 
 from pygitweb.auth_config import AuthConfig, LocalUser
 from pygitweb.main import app
-from pygitweb.permissions import PERMISSION_ADD_PROJECTS, PermissionsMap
+from pygitweb.permissions import PERMISSION_ADD_PROJECTS, permissions_with_defaults
 
 _ADDPROJECTS_AUTH = AuthConfig(
 	auth_mode="local",
 	local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
-	oauth_permissions=PermissionsMap.model_validate({
-		"*": [],
+	oauth_permissions=permissions_with_defaults({
 		PERMISSION_ADD_PROJECTS: ["admin"],
 	}),
 )
diff --git a/pygitweb/auth.py b/pygitweb/auth.py
index e5dd19c..27e24e3 100644
--- a/pygitweb/auth.py
+++ b/pygitweb/auth.py
@@ -33,6 +33,7 @@ from pygitweb.config import settings
 from pygitweb.gravatar import gravatar_url
 from pygitweb.password_hash import verify_password
 from pygitweb.permissions import Permission, PermissionPrincipal
+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
 
@@ -78,6 +79,72 @@ def has_permission(
 	return auth_config.oauth_permissions.has_permission(principal.identity, permission, project)
 
 
+def _owner_matches(project: str, token: str | None) -> bool:
+	if not settings.AUTH or not token:
+		return False
+	principal = principal_from_session(token)
+	if principal is None:
+		return False
+	owner = git_get_project_owner(project)
+	if not owner or not owner.strip():
+		return False
+	owner_norm = owner.strip()
+	identity = principal.identity
+	if hmac.compare_digest(owner_norm, identity):
+		return True
+	return owner_norm.lower() == identity.lower()
+
+
+def can_read_project(project: str, token: str | None) -> bool:
+	if not settings.AUTH:
+		return True
+	if _owner_matches(project, token):
+		return True
+	return has_permission(Permission.READ, project, token=token)
+
+
+def can_write_project(project: str, token: str | None) -> bool:
+	if not settings.AUTH:
+		return True
+	if _owner_matches(project, token):
+		return True
+	return has_permission(Permission.WRITE, project, token=token)
+
+
+def ensure_read_project(project: str, token: str | None) -> None:
+	if can_read_project(project, token):
+		return
+	raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such project")
+
+
+def ensure_write_project(project: str, token: str | None) -> None:
+	if not settings.AUTH:
+		return
+	if not is_auth_configured(auth_config):
+		raise HTTPException(
+			status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+			detail="Authentication enabled but auth.json is not configured",
+		)
+	if not token:
+		raise HTTPException(
+			status_code=status.HTTP_401_UNAUTHORIZED,
+			detail="Not authenticated",
+			headers={"WWW-Authenticate": "Bearer"},
+		)
+	if decode_access_token(token) is None:
+		raise HTTPException(
+			status_code=status.HTTP_401_UNAUTHORIZED,
+			detail="Not authenticated",
+			headers={"WWW-Authenticate": "Bearer"},
+		)
+	if can_write_project(project, token):
+		return
+	raise HTTPException(
+		status_code=status.HTTP_403_FORBIDDEN,
+		detail="Insufficient permissions",
+	)
+
+
 def ensure_permission(
 	permission: Permission,
 	project: str | None = None,
@@ -85,6 +152,8 @@ def ensure_permission(
 	token: str | None,
 ) -> None:
 	ensure_active_user_if_auth_enabled(token)
+	if project is not None:
+		ensure_read_project(project, token)
 	if has_permission(permission, project, token=token):
 		return
 	raise HTTPException(
diff --git a/pygitweb/auth.schema.json b/pygitweb/auth.schema.json
index 28fd860..9e0d8db 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.boards.{scope}, pgw.tasks.{scope}, pgw.comments.{scope}, pgw.hooks.{scope}, pgw.mr.create.{scope}, pgw.mr.merge.{scope} (fnmatch on project path)"
+          "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."
         }
       },
       "local_users": {
diff --git a/pygitweb/auth_config.py b/pygitweb/auth_config.py
index edcc85b..a304837 100644
--- a/pygitweb/auth_config.py
+++ b/pygitweb/auth_config.py
@@ -134,7 +134,7 @@ def default_auth_config(*, admin_password: str, salt: str) -> AuthConfig:
 		oauth_redirect_uri="",
 		oauth_username_from="email",
 		oauth_allowed_emails=[],
-		oauth_permissions=PermissionsMap.empty(),
+		oauth_permissions=PermissionsMap.default_access(),
 		local_users=[LocalUser(user="admin", pass_hash=hash_password(admin_password, config_salt=salt))],
 	)
 
diff --git a/pygitweb/boards_permissions_test.py b/pygitweb/boards_permissions_test.py
index 399d5d5..10c3217 100644
--- a/pygitweb/boards_permissions_test.py
+++ b/pygitweb/boards_permissions_test.py
@@ -12,14 +12,13 @@ from fastapi.testclient import TestClient
 from pygitweb.auth_config import AuthConfig, LocalUser
 from pygitweb.config import settings
 from pygitweb.conftest import client_as_user
-from pygitweb.permissions import Permission, PermissionsMap, permission_key
+from pygitweb.permissions import Permission, permission_key, permissions_with_defaults
 from pygitweb.tasks import board_router
 
 _BOARDS_AUTH = AuthConfig(
 	auth_mode="local",
 	local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
-	oauth_permissions=PermissionsMap.model_validate({
-		"*": [],
+	oauth_permissions=permissions_with_defaults({
 		permission_key(Permission.BOARDS, "demo"): ["admin"],
 	}),
 )
diff --git a/pygitweb/comments_permissions_test.py b/pygitweb/comments_permissions_test.py
index 39682bd..5362f92 100644
--- a/pygitweb/comments_permissions_test.py
+++ b/pygitweb/comments_permissions_test.py
@@ -13,14 +13,13 @@ from pygittools.tasks import BOARD_REF_PREFIX, TASK_REF_PREFIX, Board, Task
 from pygitweb.auth_config import AuthConfig, LocalUser
 from pygitweb.config import settings
 from pygitweb.conftest import client_as_user
-from pygitweb.permissions import Permission, PermissionsMap, permission_key
+from pygitweb.permissions import Permission, permission_key, permissions_with_defaults
 from pygitweb.tasks import EMPTY_TREE_OID, comment_router
 
 _COMMENTS_AUTH = AuthConfig(
 	auth_mode="local",
 	local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
-	oauth_permissions=PermissionsMap.model_validate({
-		"*": [],
+	oauth_permissions=permissions_with_defaults({
 		permission_key(Permission.COMMENTS, "demo"): ["viewer"],
 		permission_key(Permission.TASKS, "demo"): ["admin"],
 	}),
diff --git a/pygitweb/dependencies.py b/pygitweb/dependencies.py
index 6c0ec73..48de505 100644
--- a/pygitweb/dependencies.py
+++ b/pygitweb/dependencies.py
@@ -8,6 +8,11 @@ from typing import Annotated, Any
 
 from fastapi import Depends, Form, HTTPException, Query
 
+from pygitweb.auth import (
+	access_token_from_request,
+	ensure_read_project,
+	ensure_write_project,
+)
 from pygitweb.config import settings
 from pygitweb.projects import git_get_projects_list, project_in_list
 from pygitweb.validation import is_valid_project
@@ -56,6 +61,45 @@ def require_valid_project_form(project: Annotated[str, Form()]) -> str:
 	return require_valid_project(project)
 
 
+def require_readable_project(
+	project: str,
+	token: Annotated[str | None, Depends(access_token_from_request)],
+) -> str:
+	validated = require_valid_project(project)
+	ensure_read_project(validated, token)
+	return validated
+
+
+def require_readable_project_from_query(
+	project: Annotated[str, Query(..., description="Project path")],
+	token: Annotated[str | None, Depends(access_token_from_request)],
+) -> str:
+	return require_readable_project(project, token)
+
+
+def require_readable_project_form(
+	project: Annotated[str, Form()],
+	token: Annotated[str | None, Depends(access_token_from_request)],
+) -> str:
+	return require_readable_project(project, token)
+
+
+def require_readable_project_name(
+	name: str,
+	token: Annotated[str | None, Depends(access_token_from_request)],
+) -> str:
+	return require_readable_project(name, token)
+
+
+def require_writable_project_name(
+	name: str,
+	token: Annotated[str | None, Depends(access_token_from_request)],
+) -> str:
+	validated = require_valid_project(name)
+	ensure_write_project(validated, token)
+	return validated
+
+
 def require_notify_project(
 	project: Annotated[str | None, Query(alias="project", description="Repository path")] = None,
 ) -> str:
@@ -69,6 +113,7 @@ def require_notify_project(
 
 
 def board_create_resolved_project(
+	token: Annotated[str | None, Depends(access_token_from_request)],
 	p: Annotated[str | None, Query(alias="p")] = None,
 	project: Annotated[str | None, Query(alias="project")] = None,
 ) -> str:
@@ -76,12 +121,26 @@ def board_create_resolved_project(
 	chosen = (project or p or "").strip()
 	if not chosen:
 		raise HTTPException(status_code=400, detail="Project needed (use ?project= or ?p=)")
-	return require_valid_project(chosen)
+	return require_readable_project(chosen, token)
+
+
+def filter_projects_by_read_access(
+	projects: list[dict[str, Any]],
+	token: str | None,
+) -> list[dict[str, Any]]:
+	from pygitweb.auth import can_read_project
+
+	return [p for p in projects if can_read_project(p.get("path", ""), token)]
 
 
 ValidatedPathProject = Annotated[str, Depends(require_valid_project)]
+ValidatedReadableProject = Annotated[str, Depends(require_readable_project)]
 ValidatedQueryProject = Annotated[str, Depends(require_valid_project_from_query)]
+ValidatedReadableQueryProject = Annotated[str, Depends(require_readable_project_from_query)]
 ValidatedSettingsProject = Annotated[str, Depends(require_valid_project_name)]
+ValidatedReadableSettingsProject = Annotated[str, Depends(require_readable_project_name)]
+ValidatedWritableSettingsProject = Annotated[str, Depends(require_writable_project_name)]
 ValidatedBoardCreateProject = Annotated[str, Depends(board_create_resolved_project)]
 ValidatedNotifyProject = Annotated[str, Depends(require_notify_project)]
 ValidatedFormProject = Annotated[str, Depends(require_valid_project_form)]
+ValidatedReadableFormProject = Annotated[str, Depends(require_readable_project_form)]
diff --git a/pygitweb/hooks_permissions_test.py b/pygitweb/hooks_permissions_test.py
index 55ca8e5..62b7492 100644
--- a/pygitweb/hooks_permissions_test.py
+++ b/pygitweb/hooks_permissions_test.py
@@ -12,13 +12,12 @@ from pygitweb.auth_config import AuthConfig, LocalUser
 from pygitweb.config import settings
 from pygitweb.conftest import client_as_user
 from pygitweb.main import app
-from pygitweb.permissions import Permission, PermissionsMap, permission_key
+from pygitweb.permissions import Permission, permission_key, permissions_with_defaults
 
 _HOOKS_AUTH = AuthConfig(
 	auth_mode="local",
 	local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
-	oauth_permissions=PermissionsMap.model_validate({
-		"*": [],
+	oauth_permissions=permissions_with_defaults({
 		permission_key(Permission.HOOKS, "demo"): ["admin"],
 	}),
 )
@@ -86,7 +85,7 @@ def test_hook_add_401_without_login(client: TestClient, hook_env: dict[str, str]
 			f"/project/{hook_env['project']}/hook",
 			params={"name": "post-receive.notify", "op": "add"},
 		)
-	assert r.status_code == 401
+	assert r.status_code == 404
 
 
 def test_hook_add_allowed_with_grant(client: TestClient, hook_env: dict[str, str]) -> None:
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 33a9336..2c2f646 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -54,6 +54,7 @@ from pygitweb.api.subpage import Subpage
 from pygitweb.auth import (
 	access_token_from_request,
 	auth_router,
+	can_read_project,
 	decode_access_token,
 	ensure_permission,
 	require_active_user_if_auth_enabled,
@@ -65,7 +66,8 @@ from pygitweb.config import ACTIONS, get_loadavg, settings
 from pygitweb.dependencies import (
 	ValidatedBoardCreateProject,
 	ValidatedNotifyProject,
-	ValidatedPathProject,
+	ValidatedReadableProject,
+	filter_projects_by_read_access,
 	project_visible_in_list,
 )
 from pygitweb.formatting import age_string
@@ -244,19 +246,28 @@ def _project_subpage_rows(project: str) -> list[list[str]]:
 @app.get("/", response_class=HTMLResponse)
 def git_project_list(
 	request: Request,
+	token: Annotated[str | None, Depends(access_token_from_request)],
 	_action: Annotated[ProjectListAction | None, Query(alias="a")] = None,
 	pf: Annotated[str | None, Query(alias="pf")] = None,
 	_order: Annotated[ProjectListOrder | None, Query(alias="o")] = None,
 ):
 	"""Project list page. Port of git_project_list."""
 	project_filter = pf or ""
-	list_ = git_get_projects_list(
+	all_projects = git_get_projects_list(
 		filter_path=project_filter,
 		paranoid=settings.STRICT_EXPORT,
 		export_ok=settings.EXPORT_OK,
 	)
+	list_ = filter_projects_by_read_access(all_projects, token)
+	visibility_notice = ""
 	if not list_:
-		raise HTTPException(status_code=404, detail="No projects found")
+		if all_projects and settings.AUTH:
+			visibility_notice = (
+				'<p class="text-warning">No projects are visible with your current authentication '
+				"and permissions. Sign in or ask an administrator for repository access.</p>"
+			)
+		elif not all_projects:
+			visibility_notice = "<p>No projects found.</p>"
 
 	def board_cell(pr: dict) -> str:
 		path = pr.get("path", "")
@@ -285,20 +296,24 @@ def git_project_list(
 		],
 	)
 	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Projects", site_name=settings.SITE_NAME)
-	return HTMLResponse(f"{pre}<h1>Project List</h1>{table}{POSTAMBLE}")
+	return HTMLResponse(f"{pre}<h1>Project List</h1>{visibility_notice}{table}{POSTAMBLE}")
 
 
 @app.get("/index", response_class=PlainTextResponse)
 def git_project_index(
+	token: Annotated[str | None, Depends(access_token_from_request)],
 	pf: Annotated[str | None, Query(alias="pf")] = None,
 ):
 	"""Plain text project index (path owner). Port of git_project_index."""
 	from urllib.parse import quote_plus
 
-	projects = git_get_projects_list(
-		filter_path=pf or "",
-		paranoid=settings.STRICT_EXPORT,
-		export_ok=settings.EXPORT_OK,
+	projects = filter_projects_by_read_access(
+		git_get_projects_list(
+			filter_path=pf or "",
+			paranoid=settings.STRICT_EXPORT,
+			export_ok=settings.EXPORT_OK,
+		),
+		token,
 	)
 	if not projects:
 		raise HTTPException(status_code=404, detail="No projects found")
@@ -314,11 +329,12 @@ def git_project_index(
 
 @app.get("/activity", response_class=HTMLResponse)
 def activity_page(
+	token: Annotated[str | None, Depends(access_token_from_request)],
 	page: Annotated[str | None, Query(alias="page")] = None,
 	pagecount: Annotated[str | None, Query(alias="pagecount")] = None,
 ) -> HTMLResponse:
 	p, pc = parse_pagination(page, pagecount)
-	all_events = get_all_timeline_events()
+	all_events = [item for item in get_all_timeline_events() if can_read_project(item["project"], token)]
 	skip = (p - 1) * pc
 	events_page = all_events[skip : skip + pc + 1]
 	has_next = len(events_page) > pc
@@ -379,9 +395,12 @@ def activity_page(
 
 
 @app.get("/opml", response_class=PlainTextResponse)
-def git_opml():
+def git_opml(token: Annotated[str | None, Depends(access_token_from_request)]):
 	"""OPML feed list. Port of git_opml (stub)."""
-	projects = git_get_projects_list(export_ok=settings.EXPORT_OK)
+	projects = filter_projects_by_read_access(
+		git_get_projects_list(export_ok=settings.EXPORT_OK),
+		token,
+	)
 	# Minimal OPML
 	lines = ['<?xml version="1.0"?><opml version="1.0"><head><title>Git</title></head><body>']
 	for pr in projects[:100]:
@@ -510,7 +529,7 @@ async def addproject_submit(
 
 @app.get("/project/{project:path}/board/", response_class=HTMLResponse)
 def project_board(
-	project: ValidatedPathProject,
+	project: ValidatedReadableProject,
 	board: Annotated[str, Query(alias="b")] = "Tasks",
 ) -> HTMLResponse:
 	"""Board view: columns (dropzones) and task cards."""
@@ -536,27 +555,27 @@ def project_board(
 
 
 @app.get("/project/{project:path}/summary-refs")
-def project_summary_refs(project: ValidatedPathProject) -> dict[str, list[dict[str, str]]]:
+def project_summary_refs(project: ValidatedReadableProject) -> dict[str, list[dict[str, str]]]:
 	return {"options": summary_ref_options(project)}
 
 
 @app.get("/project/{project:path}/summary-ref-state")
 def project_summary_ref_state(
-	project: ValidatedPathProject,
+	project: ValidatedReadableProject,
 	ref: Annotated[str | None, Query(alias="ref")] = None,
 ) -> dict[str, str]:
 	return summary_ref_state(project, ref)
 
 
 @app.get("/project/{project:path}/search", response_class=HTMLResponse)
-def project_search_page(project: ValidatedPathProject) -> HTMLResponse:
+def project_search_page(project: ValidatedReadableProject) -> HTMLResponse:
 	"""Per-project search page (UI only; results fetched from /project/{project}?a=search)."""
 	return git_search_page(project)
 
 
 @app.get("/project/{project:path}/subpage/{subpage_name}", response_class=HTMLResponse)
 def project_subpage(
-	project: ValidatedPathProject,
+	project: ValidatedReadableProject,
 	subpage_name: str,
 	raw: Annotated[bool, Query()] = False,
 ) -> HTMLResponse:
@@ -592,7 +611,7 @@ def project_subpage(
 
 @app.post("/project/{project:path}/hook", response_class=JSONResponse)
 def project_hook(
-	project: ValidatedPathProject,
+	project: ValidatedReadableProject,
 	name: Annotated[str, Query(alias="name")],
 	token: Annotated[str | None, Depends(access_token_from_request)],
 	op: Annotated[HookInstallOp, Query(alias="op")] = "check",
@@ -674,7 +693,7 @@ def project_hook(
 
 
 @app.get("/project/{project:path}/hooks", response_class=JSONResponse)
-def project_hooks_list(project: ValidatedPathProject) -> JSONResponse:
+def project_hooks_list(project: ValidatedReadableProject) -> JSONResponse:
 	"""List all hook samples and bundles with current status in this project's hooks dir."""
 	samples_out: list[dict[str, object]] = []
 	for sample in list_samples():
@@ -718,7 +737,7 @@ async def internal_notify(project: ValidatedNotifyProject) -> JSONResponse:
 @app.get("/project/{project:path}", response_class=HTMLResponse)
 async def dispatch(
 	request: Request,
-	project: ValidatedPathProject,
+	project: ValidatedReadableProject,
 	a: Annotated[str | None, Query(alias="a")] = None,
 	h: Annotated[str | None, Query(alias="h")] = None,
 	hb: Annotated[str | None, Query(alias="hb")] = None,
diff --git a/pygitweb/merge_requests.py b/pygitweb/merge_requests.py
index b3c77aa..47fcc4d 100644
--- a/pygitweb/merge_requests.py
+++ b/pygitweb/merge_requests.py
@@ -13,7 +13,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, Response
 from pygittools.merge import MergeRequest, MergeRequestStatus, get_merge_request_by_oid
 from pygitweb.auth import access_token_from_request, principal_from_session, require_permission
 from pygitweb.config import settings
-from pygitweb.dependencies import ValidatedFormProject, ValidatedQueryProject
+from pygitweb.dependencies import ValidatedReadableFormProject, ValidatedReadableQueryProject
 from pygitweb.git_helpers import open_repo, resolve_mr_opener
 from pygitweb.permissions import Permission
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
@@ -164,7 +164,7 @@ def merge_request_tag_response(
 	dependencies=[Depends(require_permission(Permission.MR_CREATE, project_from_form=True))],
 )
 def merge_request_create(
-	project: ValidatedFormProject,
+	project: ValidatedReadableFormProject,
 	theirs: str = Form(...),
 	ours: str = Form(...),
 	title: str = Form(""),
@@ -210,7 +210,7 @@ def merge_request_create(
 	dependencies=[Depends(require_permission(Permission.MR_MERGE, project_from_query=True))],
 )
 def merge_fast_forward(
-	project: ValidatedQueryProject,
+	project: ValidatedReadableQueryProject,
 	h: str = Query(..., description="Annotated tag object id (hex)"),
 ) -> RedirectResponse:
 	if not h or not is_valid_ref_format(h):
diff --git a/pygitweb/mr_permissions_test.py b/pygitweb/mr_permissions_test.py
index 84a8201..a74295e 100644
--- a/pygitweb/mr_permissions_test.py
+++ b/pygitweb/mr_permissions_test.py
@@ -15,7 +15,7 @@ from pygitweb.auth_config import AuthConfig, LocalUser
 from pygitweb.config import settings
 from pygitweb.conftest import client_as_user
 from pygitweb.merge_requests import merge_router
-from pygitweb.permissions import Permission, PermissionsMap, permission_key
+from pygitweb.permissions import Permission, permission_key, permissions_with_defaults
 from pygitweb.sessions import create_session
 
 
@@ -32,8 +32,7 @@ def _commit_chain(repo: pygit2.Repository) -> tuple[pygit2.Oid, pygit2.Oid]:
 _MR_AUTH = AuthConfig(
 	auth_mode="local",
 	local_users=[LocalUser(user="creator", password="secret"), LocalUser(user="merger", password="secret")],
-	oauth_permissions=PermissionsMap.model_validate({
-		"*": [],
+	oauth_permissions=permissions_with_defaults({
 		permission_key(Permission.MR_CREATE, "demo"): ["creator", "creator@idp.example"],
 		permission_key(Permission.MR_MERGE, "demo"): ["merger"],
 	}),
diff --git a/pygitweb/permissions.py b/pygitweb/permissions.py
index 125f79d..ea86bcc 100644
--- a/pygitweb/permissions.py
+++ b/pygitweb/permissions.py
@@ -6,7 +6,9 @@ PyGitWeb permission keys and grant checks.
 every permission.
 
 Scoped keys use a project segment that supports ``fnmatch`` globs (``*``, ``?``,
-``[seq]``), e.g. ``pgw.tasks.foo/*`` or ``pgw.boards.*``.
+``[seq]``), e.g. ``pgw.tasks.foo/*``, ``pgw.read.*``, or ``pgw.write.myproj``.
+
+Principal ``*`` in a grant list matches any authenticated principal.
 """
 
 from __future__ import annotations
@@ -19,17 +21,20 @@ from typing import Self
 from pydantic import BaseModel, ConfigDict, RootModel, model_validator
 
 WILDCARD_PERMISSION_KEY = "*"
+WILDCARD_PRINCIPAL = "*"
 
 # Known permission families (suffix after ``pgw.``).
 PERMISSION_ADD_PROJECTS = "pgw.addprojects"
 PERMISSION_SETTINGS = "pgw.settings"
 
-_SCOPED_PREFIX = re.compile(r"^pgw\.(boards|tasks|comments|hooks|mr\.create|mr\.merge)\.(.+)$")
+_SCOPED_PREFIX = re.compile(r"^pgw\.(boards|tasks|comments|hooks|mr\.create|mr\.merge|read|write)\.(.+)$")
 
 
 class Permission(StrEnum):
 	"""Logical permission checked by route dependencies."""
 
+	READ = "read"
+	WRITE = "write"
 	BOARDS = "boards"
 	TASKS = "tasks"
 	COMMENTS = "comments"
@@ -50,6 +55,8 @@ class Permission(StrEnum):
 	@property
 	def scoped_prefix(self) -> str | None:
 		if self in (
+			Permission.READ,
+			Permission.WRITE,
 			Permission.BOARDS,
 			Permission.TASKS,
 			Permission.COMMENTS,
@@ -147,6 +154,15 @@ class PermissionsMap(RootModel[dict[str, list[str]]]):
 	def empty(cls) -> Self:
 		return cls({WILDCARD_PERMISSION_KEY: []})
 
+	@classmethod
+	def default_access(cls) -> Self:
+		"""Default grants: any authenticated principal may read and write every project."""
+		return cls({
+			WILDCARD_PERMISSION_KEY: [],
+			permission_key(Permission.READ, WILDCARD_PERMISSION_KEY): [WILDCARD_PRINCIPAL],
+			permission_key(Permission.WRITE, WILDCARD_PERMISSION_KEY): [WILDCARD_PRINCIPAL],
+		})
+
 	def principals_for_key(self, key: str) -> frozenset[str]:
 		return frozenset(self.root.get(key, []))
 
@@ -184,17 +200,27 @@ class PermissionsMap(RootModel[dict[str, list[str]]]):
 			return False
 		normalized = principal.strip()
 		allowed = self.principals_with_permission(permission, project)
+		if WILDCARD_PRINCIPAL in allowed:
+			return True
 		if normalized in allowed:
 			return True
 		# Case-insensitive match for email principals.
 		lower = normalized.lower()
-		return any(p.lower() == lower for p in allowed)
+		return any(p.lower() == lower for p in allowed if p != WILDCARD_PRINCIPAL)
 
 
 def normalize_oauth_permissions(raw: object) -> dict[str, list[str]]:
 	return PermissionsMap.model_validate(raw).root
 
 
+def permissions_with_defaults(extra: dict[str, list[str]] | None = None) -> PermissionsMap:
+	"""Default read/write for all authenticated users, merged with test-specific grants."""
+	root = PermissionsMap.default_access().root.copy()
+	if extra:
+		root.update(extra)
+	return PermissionsMap.model_validate(root)
+
+
 class PermissionPrincipal(BaseModel):
 	model_config = ConfigDict(frozen=True)
 
diff --git a/pygitweb/permissions_test.py b/pygitweb/permissions_test.py
index ff01d31..5f987d3 100644
--- a/pygitweb/permissions_test.py
+++ b/pygitweb/permissions_test.py
@@ -26,12 +26,25 @@ def test_parse_scoped_key() -> None:
 
 
 def test_permission_key_roundtrip() -> None:
+	assert permission_key(Permission.READ, "foo") == "pgw.read.foo"
+	assert permission_key(Permission.WRITE, "foo") == "pgw.write.foo"
 	assert permission_key(Permission.BOARDS, "foo") == "pgw.boards.foo"
 	assert permission_key(Permission.MR_CREATE, "foo") == "pgw.mr.create.foo"
 	assert permission_key(Permission.MR_MERGE, "foo") == "pgw.mr.merge.foo"
 	assert permission_key(Permission.HOOKS, "foo") == "pgw.hooks.foo"
 
 
+def test_parse_read_write_scoped_keys() -> None:
+	read = parse_permission_key("pgw.read.team/*")
+	assert read is not None
+	assert read.permission is Permission.READ
+	assert read.project_scope == "team/*"
+	write = parse_permission_key("pgw.write.private")
+	assert write is not None
+	assert write.permission is Permission.WRITE
+	assert write.project_scope == "private"
+
+
 def test_parse_mr_scoped_keys() -> None:
 	create = parse_permission_key("pgw.mr.create.demo")
 	assert create is not None
@@ -84,3 +97,44 @@ def test_rejects_unknown_key() -> None:
 def test_ensures_wildcard_key() -> None:
 	m = PermissionsMap.model_validate({PERMISSION_ADD_PROJECTS: ["u@x.com"]})
 	assert "*" in m.root
+
+
+def test_default_access_grants_read_write_to_any_authenticated_user() -> None:
+	m = PermissionsMap.default_access()
+	assert m.has_permission("alice", Permission.READ, "secret")
+	assert m.has_permission("bob@example.com", Permission.WRITE, "team/app")
+	assert not m.has_permission("", Permission.READ, "secret")
+	assert not m.has_permission(None, Permission.WRITE, "secret")
+
+
+def test_scoped_read_write_without_wildcard_principal() -> None:
+	m = PermissionsMap.model_validate({
+		"*": [],
+		"pgw.read.public": ["reader@example.com"],
+		"pgw.write.public": ["writer@example.com"],
+	})
+	assert m.has_permission("reader@example.com", Permission.READ, "public")
+	assert not m.has_permission("reader@example.com", Permission.WRITE, "public")
+	assert m.has_permission("writer@example.com", Permission.WRITE, "public")
+	assert not m.has_permission("writer@example.com", Permission.READ, "private")
+
+
+def test_owner_grants_read_and_write() -> None:
+	from unittest.mock import patch
+
+	from pygitweb.auth import can_read_project, can_write_project
+	from pygitweb.config import settings
+	from pygitweb.sessions import create_session
+
+	with (
+		patch.object(settings, "AUTH", True),
+		patch("pygitweb.auth.git_get_project_owner", return_value="owner@example.com"),
+		patch(
+			"pygitweb.auth.auth_config.oauth_permissions",
+			PermissionsMap.model_validate({"*": []}),
+		),
+	):
+		token = create_session("someone", email="owner@example.com", auth_method="local")
+		assert can_read_project("secret", token)
+		assert can_write_project("secret", token)
+		assert not can_read_project("secret", None)
diff --git a/pygitweb/project_list_routes_test.py b/pygitweb/project_list_routes_test.py
new file mode 100644
index 0000000..7d37dc8
--- /dev/null
+++ b/pygitweb/project_list_routes_test.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from collections.abc import Generator
+from pathlib import Path
+from unittest.mock import patch
+
+import pygit2
+import pytest
+from fastapi.testclient import TestClient
+
+from pygitweb.auth_config import AuthConfig, LocalUser
+from pygitweb.config import settings
+from pygitweb.main import app
+from pygitweb.permissions import PermissionsMap
+
+
+def _seed_repo(repo_dir: Path) -> None:
+	pygit2.init_repository(str(repo_dir), bare=False)
+	(repo_dir / "README.md").write_text("hello\n", encoding="utf-8")
+
+
+@pytest.fixture
+def hidden_projects_env(tmp_path: Path) -> Generator[dict[str, str], None, None]:
+	root = tmp_path / "project-list"
+	repo_dir = root / "alpha"
+	repo_dir.mkdir(parents=True)
+	_seed_repo(repo_dir)
+	auth = AuthConfig(
+		auth_mode="local",
+		local_users=[LocalUser(user="admin", password="secret")],
+		oauth_permissions=PermissionsMap.model_validate({"*": []}),
+	)
+	with (
+		patch.object(settings, "PROJECTROOT", str(root)),
+		patch.object(settings, "PROJECTS_LIST", str(root)),
+		patch.object(settings, "PROJECT_MAXDEPTH", 2),
+		patch.object(settings, "STRICT_EXPORT", False),
+		patch.object(settings, "EXPORT_OK", ""),
+		patch.object(settings, "LIST_ALL", True),
+		patch.object(settings, "AUTH", True),
+		patch("pygitweb.auth_config.auth_config", auth),
+		patch("pygitweb.auth.auth_config", auth),
+		patch("pygitweb.main.auth_config", auth),
+	):
+		yield {"root": str(root)}
+
+
+def test_home_page_shows_auth_visibility_notice_when_no_readable_projects(
+	hidden_projects_env: dict[str, str],
+) -> None:
+	del hidden_projects_env
+	client = TestClient(app)
+	resp = client.get("/")
+	assert resp.status_code == 200
+	assert "No projects are visible with your current authentication" in resp.text
+	assert "<table" in resp.text
diff --git a/pygitweb/settings.py b/pygitweb/settings.py
index 5d5b974..70d34db 100644
--- a/pygitweb/settings.py
+++ b/pygitweb/settings.py
@@ -16,7 +16,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
 
 from pygitweb.auth import require_permission
 from pygitweb.config import settings
-from pygitweb.dependencies import ValidatedSettingsProject
+from pygitweb.dependencies import ValidatedReadableSettingsProject, ValidatedWritableSettingsProject
 from pygitweb.permissions import Permission
 from pygitweb.settings_config import settings_batch_update
 from pygitweb.shutdown import request_graceful_shutdown
@@ -377,7 +377,7 @@ async def settings_pygit2_submit(request: Request):
 
 # ---------- Routes: Project ----------
 @router.get("/project/{name:path}", response_class=HTMLResponse)
-def settings_project_page(request: Request, name: ValidatedSettingsProject):
+def settings_project_page(request: Request, name: ValidatedReadableSettingsProject):
 	"""Project-specific git config form (local/worktree only)."""
 	entries = _get_project_config_entries(name)
 	rows = [_project_config_table_row(k, v, i) for i, (k, v) in enumerate(entries)]
@@ -400,7 +400,7 @@ def settings_project_page(request: Request, name: ValidatedSettingsProject):
 
 
 @router.post("/project/{name:path}/submit", response_class=HTMLResponse)
-async def settings_project_submit(request: Request, name: ValidatedSettingsProject):
+async def settings_project_submit(request: Request, name: ValidatedWritableSettingsProject):
 	"""Apply project config from form."""
 	form = await request.form()
 	# Collect key/value pairs; keys are config_key_0, config_value_0, ...
diff --git a/pygitweb/settings_permissions_test.py b/pygitweb/settings_permissions_test.py
index 1937ed2..7d2c1d8 100644
--- a/pygitweb/settings_permissions_test.py
+++ b/pygitweb/settings_permissions_test.py
@@ -6,13 +6,12 @@ from fastapi.testclient import TestClient
 
 from pygitweb.auth_config import AuthConfig, LocalUser
 from pygitweb.main import app
-from pygitweb.permissions import PERMISSION_SETTINGS, PermissionsMap
+from pygitweb.permissions import PERMISSION_SETTINGS, permissions_with_defaults
 
 _SETTINGS_AUTH = AuthConfig(
 	auth_mode="local",
 	local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
-	oauth_permissions=PermissionsMap.model_validate({
-		"*": [],
+	oauth_permissions=permissions_with_defaults({
 		PERMISSION_SETTINGS: ["admin"],
 	}),
 )
diff --git a/pygitweb/smart_http.py b/pygitweb/smart_http.py
index a0821fd..f54787a 100644
--- a/pygitweb/smart_http.py
+++ b/pygitweb/smart_http.py
@@ -12,7 +12,7 @@ import pygit2
 from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
 from fastapi.responses import Response
 
-from pygitweb.auth import access_token_from_request, ensure_active_user_if_auth_enabled
+from pygitweb.auth import access_token_from_request, ensure_read_project, ensure_write_project
 from pygitweb.config import settings
 from pygitweb.dependencies import project_visible_in_list, require_valid_project
 from pygitweb.validation import is_valid_project
@@ -76,7 +76,27 @@ def require_http_project(project: str) -> str:
 	return require_valid_project(project)
 
 
+def require_readable_http_project(
+	project: str,
+	token: Annotated[str | None, Depends(access_token_from_request)],
+) -> str:
+	validated = require_http_project(project)
+	ensure_read_project(validated, token)
+	return validated
+
+
+def require_writable_http_project(
+	project: str,
+	token: Annotated[str | None, Depends(access_token_from_request)],
+) -> str:
+	validated = require_http_project(project)
+	ensure_write_project(validated, token)
+	return validated
+
+
 ValidatedHttpProject = Annotated[str, Depends(require_http_project)]
+ValidatedReadableHttpProject = Annotated[str, Depends(require_readable_http_project)]
+ValidatedWritableHttpProject = Annotated[str, Depends(require_writable_http_project)]
 
 
 def _repo_cwd(project: str) -> Path:
@@ -140,10 +160,6 @@ def _smart_http_cache_headers() -> dict[str, str]:
 	}
 
 
-def _ensure_receive_auth(token: str | None) -> None:
-	ensure_active_user_if_auth_enabled(token)
-
-
 @http_router.get("/{project:path}/info/refs")
 def smart_http_info_refs(
 	project: ValidatedHttpProject,
@@ -151,7 +167,9 @@ def smart_http_info_refs(
 	token: Annotated[str | None, Depends(access_token_from_request)],
 ) -> Response:
 	if service == "git-receive-pack":
-		_ensure_receive_auth(token)
+		ensure_write_project(project, token)
+	else:
+		ensure_read_project(project, token)
 	body = _service_advertisement(service, project)
 	advertise_type, _ = _SERVICE_CONTENT_TYPES[service]
 	return Response(
@@ -163,7 +181,7 @@ def smart_http_info_refs(
 
 @http_router.post("/{project:path}/git-upload-pack")
 async def smart_http_upload_pack(
-	project: ValidatedHttpProject,
+	project: ValidatedReadableHttpProject,
 	request: Request,
 	content_encoding: Annotated[str | None, Header(alias="Content-Encoding")] = None,
 	git_protocol: Annotated[str | None, Header(alias="Git-Protocol")] = None,
@@ -183,13 +201,11 @@ async def smart_http_upload_pack(
 
 @http_router.post("/{project:path}/git-receive-pack")
 async def smart_http_receive_pack(
-	project: ValidatedHttpProject,
+	project: ValidatedWritableHttpProject,
 	request: Request,
-	token: Annotated[str | None, Depends(access_token_from_request)],
 	content_encoding: Annotated[str | None, Header(alias="Content-Encoding")] = None,
 	git_protocol: Annotated[str | None, Header(alias="Git-Protocol")] = None,
 ) -> Response:
-	_ensure_receive_auth(token)
 	raw_body = await request.body()
 	body = _decode_request_body(raw_body, content_encoding)
 	result = _run_git_service(
diff --git a/pygitweb/smart_http_test.py b/pygitweb/smart_http_test.py
index 2195db9..879adb2 100644
--- a/pygitweb/smart_http_test.py
+++ b/pygitweb/smart_http_test.py
@@ -18,11 +18,13 @@ from pygitweb.auth_config import AuthConfig, LocalUser
 from pygitweb.config import settings
 from pygitweb.conftest import set_client_session
 from pygitweb.main import app
+from pygitweb.permissions import PermissionsMap
 from pygitweb.smart_http import _decode_request_body, _pkt_flush, _pkt_line, _service_advertisement
 
 _LOCAL_ADMIN = AuthConfig(
 	auth_mode="local",
 	local_users=[LocalUser(user="alice", password="secret")],
+	oauth_permissions=PermissionsMap.default_access(),
 )
 
 
diff --git a/pygitweb/tasks.py b/pygitweb/tasks.py
index 0fe197d..6e1589d 100644
--- a/pygitweb/tasks.py
+++ b/pygitweb/tasks.py
@@ -32,7 +32,7 @@ from pygitweb.auth import (
 	require_permission,
 )
 from pygitweb.config import settings
-from pygitweb.dependencies import ValidatedQueryProject
+from pygitweb.dependencies import ValidatedReadableQueryProject
 from pygitweb.gravatar import gravatar_url
 from pygitweb.permissions import Permission, PermissionPrincipal
 
@@ -131,9 +131,8 @@ board_router = APIRouter(tags=["boards"])
 @board_router.get(
 	"/list",
 	response_class=JSONResponse,
-	dependencies=[Depends(require_active_user_if_auth_enabled)],
 )
-def boards_list(project: ValidatedQueryProject) -> JSONResponse:
+def boards_list(project: ValidatedReadableQueryProject) -> JSONResponse:
 	"""List all boards for the project."""
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	refs = [
@@ -162,7 +161,7 @@ def boards_list(project: ValidatedQueryProject) -> JSONResponse:
 	dependencies=[Depends(require_permission(Permission.BOARDS, project_from_query=True))],
 )
 def boards_create(
-	project: ValidatedQueryProject,
+	project: ValidatedReadableQueryProject,
 	name: str = Query(..., description="Board name"),
 	description: str = Query("", description="Board description"),
 ) -> JSONResponse:
@@ -185,7 +184,7 @@ def boards_create(
 	dependencies=[Depends(require_permission(Permission.BOARDS, project_from_query=True))],
 )
 def boards_delete(
-	project: ValidatedQueryProject,
+	project: ValidatedReadableQueryProject,
 	name: str = Query(..., description="Board name"),
 ) -> JSONResponse:
 	"""Delete a board (removes ref; tag object remains in ODB)."""
@@ -259,10 +258,9 @@ def get_board_tasks_grouped(
 @task_router.get(
 	"/list",
 	response_class=JSONResponse,
-	dependencies=[Depends(require_active_user_if_auth_enabled)],
 )
 def task_list(
-	project: ValidatedQueryProject,
+	project: ValidatedReadableQueryProject,
 	board: str = Query(..., description="Board name"),
 ) -> JSONResponse:
 	"""List all tasks on a board."""
@@ -289,7 +287,7 @@ def task_list(
 	dependencies=[Depends(require_permission(Permission.TASKS, project_from_query=True))],
 )
 def task_create(
-	project: ValidatedQueryProject,
+	project: ValidatedReadableQueryProject,
 	board: str = Query(..., description="Board name"),
 	title: str = Query(..., description="Task title"),
 	description: str = Query("", description="Task description"),
@@ -343,7 +341,7 @@ def task_create(
 )
 async def task_update(
 	request: Request,
-	project: ValidatedQueryProject,
+	project: ValidatedReadableQueryProject,
 	board: str = Query(..., description="Board name"),
 	task_ref: str = Query(..., alias="task", description="Task ref (e.g. refs/tags/tasks/task_123)"),
 ) -> JSONResponse:
@@ -390,7 +388,7 @@ async def task_update(
 	dependencies=[Depends(require_permission(Permission.TASKS, project_from_query=True))],
 )
 def task_delete(
-	project: ValidatedQueryProject,
+	project: ValidatedReadableQueryProject,
 	board: str = Query(..., description="Board name"),
 	task_ref: str = Query(..., alias="task", description="Task ref"),
 ) -> JSONResponse:
@@ -456,10 +454,9 @@ def _comment_sort_key(entry: dict[str, Any]) -> str:
 @comment_router.get(
 	"/list",
 	response_class=JSONResponse,
-	dependencies=[Depends(require_active_user_if_auth_enabled)],
 )
 def comment_list(
-	project: ValidatedQueryProject,
+	project: ValidatedReadableQueryProject,
 	board: str = Query(..., description="Board name"),
 	task: str = Query(..., description="Task ref (e.g. refs/tags/tasks/task_123)"),
 ) -> JSONResponse:
@@ -489,7 +486,7 @@ def comment_list(
 	dependencies=[Depends(require_permission(Permission.COMMENTS, project_from_query=True))],
 )
 def comment_create(
-	project: ValidatedQueryProject,
+	project: ValidatedReadableQueryProject,
 	board: str = Query(..., description="Board name"),
 	task: str = Query(..., description="Task ref"),
 	content: str = Query(..., description="Comment content"),
@@ -530,7 +527,7 @@ def comment_create(
 )
 async def comment_update(
 	request: Request,
-	project: ValidatedQueryProject,
+	project: ValidatedReadableQueryProject,
 	board: str = Query(..., description="Board name"),
 	task: str = Query(..., description="Task ref (to update task.comments)"),
 	comment_oid: str = Query(..., alias="comment", description="Comment OID hex"),
@@ -579,7 +576,7 @@ async def comment_update(
 	dependencies=[Depends(require_active_user_if_auth_enabled)],
 )
 def comment_delete(
-	project: ValidatedQueryProject,
+	project: ValidatedReadableQueryProject,
 	board: str = Query(..., description="Board name"),
 	comment_oid: str = Query(..., alias="comment", description="Comment OID hex"),
 ) -> JSONResponse:
diff --git a/pygitweb/tasks_permissions_test.py b/pygitweb/tasks_permissions_test.py
index cf09eb0..35ba3b0 100644
--- a/pygitweb/tasks_permissions_test.py
+++ b/pygitweb/tasks_permissions_test.py
@@ -13,14 +13,13 @@ from pygittools.tasks import BOARD_REF_PREFIX, Board
 from pygitweb.auth_config import AuthConfig, LocalUser
 from pygitweb.config import settings
 from pygitweb.conftest import client_as_user
-from pygitweb.permissions import Permission, PermissionsMap, permission_key
+from pygitweb.permissions import Permission, permission_key, permissions_with_defaults
 from pygitweb.tasks import EMPTY_TREE_OID, task_router
 
 _TASKS_AUTH = AuthConfig(
 	auth_mode="local",
 	local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
-	oauth_permissions=PermissionsMap.model_validate({
-		"*": [],
+	oauth_permissions=permissions_with_defaults({
 		permission_key(Permission.TASKS, "demo"): ["admin"],
 	}),
 )
