diff --git a/pygittools/__init__.py b/pygittools/__init__.py
index 72f8f6d..3061a9d 100644
--- a/pygittools/__init__.py
+++ b/pygittools/__init__.py
@@ -1,7 +1,7 @@
 import pygittools.__meta__
 
 """
-Python hooks, auth, and metadata storage in Git
+Python hooks and metadata storage in Git
 """
 __version__ = pygittools.__meta__.__version__
 __author__ = pygittools.__meta__.__author__
diff --git a/pygittools/__meta__.py b/pygittools/__meta__.py
index 48cc8d5..ca456d4 100644
--- a/pygittools/__meta__.py
+++ b/pygittools/__meta__.py
@@ -5,5 +5,5 @@ Metadata for PyGitTools - this is the canonical source of all information below.
 __version__ = "0.1.0"
 __author__ = "Will Bowers"
 __license__ = "Apache 2.0"  # This may change before being distributed.
-__description__ = "Python hooks, auth, and metadata storage in Git"
+__description__ = "Python hooks and metadata storage in Git"
 __url__ = "https://pygitweb.com"
diff --git a/pygittools/auth.py b/pygittools/auth.py
deleted file mode 100644
index 0cf786a..0000000
--- a/pygittools/auth.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""
-Root auth provider: base settings and interface common to all auth methods.
-"""
-
-from __future__ import annotations
-
-import hmac
-import secrets
-import time
-from datetime import timedelta
-
-
-class RootAuthProvider:
-	"""
-	Base auth provider with admin credentials, session timeout, and session create/validate.
-	Subclass or use as-is for simple admin user/password auth.
-	"""
-
-	def __init__(
-		self,
-		*,
-		admin_user: bytes | None = None,
-		admin_password: bytes | None = None,
-		session_timeout: timedelta | float | None = None,  # duration; None = no expiry
-	) -> None:
-		"""
-		admin_user: optional admin username (bytes). If None, no admin login is accepted.
-		admin_password: optional admin password (bytes). If None, no admin login is accepted.
-		session_timeout: optional duration in seconds (float) or timedelta. If None, sessions do not expire.
-		"""
-		self.admin_user = admin_user
-		self.admin_password = admin_password
-		if session_timeout is not None and hasattr(session_timeout, "total_seconds"):
-			self._timeout_seconds = session_timeout.total_seconds()
-		else:
-			self._timeout_seconds = session_timeout  # float or None
-		self._sessions: dict[str, float] = {}  # token -> created_at (monotonic or epoch)
-
-	def create_session(
-		self,
-		*,
-		user: bytes | None = None,
-		password: bytes | None = None,
-	) -> str | None:
-		"""
-		Authenticate with user/password and create a session if valid.
-		Returns a session token or None if credentials are missing or invalid.
-		"""
-		if self.admin_user is None or self.admin_password is None:
-			return None
-		if user is None or password is None:
-			return None
-		if not hmac.compare_digest(user, self.admin_user) or not hmac.compare_digest(password, self.admin_password):
-			return None
-		token = secrets.token_urlsafe(32)
-		self._sessions[token] = time.monotonic()
-		return token
-
-	def validate_session(self, session_token: str) -> bool:
-		"""
-		Return True if the session token exists and (when session_timeout is set) is not expired.
-		"""
-		if not session_token:
-			return False
-		created = self._sessions.get(session_token)
-		if created is None:
-			return False
-		if self._timeout_seconds is not None and time.monotonic() - created > self._timeout_seconds:
-			del self._sessions[session_token]
-			return False
-		return True
diff --git a/pygittools/auth_test.py b/pygittools/auth_test.py
deleted file mode 100644
index 57a5803..0000000
--- a/pygittools/auth_test.py
+++ /dev/null
@@ -1,35 +0,0 @@
-import time
-from datetime import timedelta
-
-from pygittools.auth import RootAuthProvider
-
-
-def test_rootauthprovider_session() -> None:
-	provider = RootAuthProvider(admin_user=b"root", admin_password=b"secret")
-
-	session = provider.create_session(user=b"root", password=b"secret")
-
-	assert session is not None
-	assert provider.validate_session(session) is True
-
-
-def test_rootauthprovider_badpass() -> None:
-	provider = RootAuthProvider(admin_user=b"root", admin_password=b"secret")
-
-	session = provider.create_session(user=b"root", password=b"wrong")
-
-	assert session is None
-
-
-def test_rootauthprovider_timeout() -> None:
-	provider = RootAuthProvider(
-		admin_user=b"root",
-		admin_password=b"secret",
-		session_timeout=timedelta(milliseconds=1),
-	)
-
-	session = provider.create_session(user=b"root", password=b"secret")
-
-	assert session is not None
-	time.sleep(0.005)
-	assert provider.validate_session(session) is False
diff --git a/pygittools/pyproject.toml b/pygittools/pyproject.toml
index 4e597c2..1aaaa13 100644
--- a/pygittools/pyproject.toml
+++ b/pygittools/pyproject.toml
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
 [project]
 name = "pygittools"
 version = "0.1.0"
-description = "Hooks, auth, and task storage in Git"
+description = "Hooks and task storage in Git"
 readme = "README.md"
 requires-python = ">=3.11"
 dependencies = [
diff --git a/pygitweb/README.md b/pygitweb/README.md
index 1d859a8..db45ec4 100644
--- a/pygitweb/README.md
+++ b/pygitweb/README.md
@@ -38,7 +38,10 @@ Settings load from `PYGITWEB_*` environment variables (or a `.env` file in the w
 - `PYGITWEB_EXPORT_OK` — filename that must exist to allow export (e.g. `git-daemon-export-ok`); empty = no check
 - `PYGITWEB_SITE_NAME` — site name in titles (default: `PyGitWeb`)
 - `PYGITWEB_GIT` — path to the git executable (default: `git`)
-- `PYGITWEB_AUTH` — fully-qualified auth provider class (e.g. `module.path.ClassName`); unset/`None` to disable
+- `PYGITWEB_AUTH` — `0` / `false` / `off` disables auth; `1` / `true` / `on` enables it. If **unset or empty**, auth defaults to **on**, a random password is generated at startup, and a **warning is printed to stderr** (set `PYGITWEB_AUTH` explicitly to `0` or `1` and configure credentials as below).
+- `PYGITWEB_ADMIN_USER` — login username when auth is on (default: `admin`)
+- `PYGITWEB_ADMIN_PASSWORD` — login password when auth is on (optional at first run: a random value is generated and printed if missing while auth is enabled)
+- Browser login: `GET /login` (optional `?next=/path`), `POST /login` with form fields `username`, `password`, `next`; sets an HttpOnly cookie also accepted by protected routes. `GET /logout?next=/` clears it.
 
 ## Routes
 
@@ -136,6 +139,5 @@ The same operations are available programmatically via
 `POST /project/{name}/hook?name=<sample-or-bundle>&op=<...>`. Bundle status is
 `INSTALLED` only when every member is installed, `DIFFERENT` if any member's path holds
 a custom hook (the bundle then refuses to install or remove anything to preserve the
-custom hook), otherwise `NOT_INSTALLED`. When `PYGITWEB_AUTH` is set, `add` and `remove`
-require a valid session (`X-Session-Token` header, `?session=` param, or `session`
-cookie); `check` is always allowed.
+custom hook), otherwise `NOT_INSTALLED`. When `PYGITWEB_AUTH` is enabled, `add` and `remove`
+require a valid access token (`Authorization: Bearer …` from `POST /token`, or the cookie from `POST /login`); `check` is always allowed.
diff --git a/pygitweb/auth.py b/pygitweb/auth.py
new file mode 100644
index 0000000..a2024c1
--- /dev/null
+++ b/pygitweb/auth.py
@@ -0,0 +1,230 @@
+"""
+OAuth2 password flow (tutorial-style) with credentials from PYGITWEB_ADMIN_USER / PYGITWEB_ADMIN_PASSWORD.
+
+Browser login: GET/POST /login sets an HttpOnly cookie; APIs still accept Authorization: Bearer.
+"""
+
+from __future__ import annotations
+
+import hmac
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, Form, HTTPException, Query, status
+from fastapi.responses import HTMLResponse, RedirectResponse
+from fastapi.security import APIKeyCookie, OAuth2PasswordBearer, OAuth2PasswordRequestForm
+from pydantic import BaseModel
+
+from pygitweb.config import settings
+from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
+
+ACCESS_TOKEN_COOKIE_NAME = "pygitweb_access_token"
+LOGIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
+_access_token_cookie = APIKeyCookie(name=ACCESS_TOKEN_COOKIE_NAME, auto_error=False)
+
+auth_router = APIRouter(tags=["auth"])
+
+
+class User(BaseModel):
+	username: str
+
+
+class UserInDB(User):
+	password: str
+
+
+def get_auth_credentials() -> UserInDB | None:
+	if not settings.AUTH:
+		return None
+	pwd = settings.ADMIN_PASSWORD
+	if not pwd:
+		return None
+	return UserInDB(username=settings.ADMIN_USER, password=pwd)
+
+
+def get_user(username: str) -> UserInDB | None:
+	stored = get_auth_credentials()
+	if stored is None:
+		return None
+	if not hmac.compare_digest(stored.username, username):
+		return None
+	return stored
+
+
+def authenticate_user(username: str, password: str) -> User | None:
+	user = get_user(username)
+	if user is None:
+		return None
+	if not hmac.compare_digest(user.password, password):
+		return None
+	return User(username=user.username)
+
+
+def decode_access_token(token: str) -> User | None:
+	stored = get_auth_credentials()
+	if stored is None:
+		return None
+	if not hmac.compare_digest(stored.username, token):
+		return None
+	return User(username=stored.username)
+
+
+def safe_next_url(next_raw: str | None) -> str:
+	n = (next_raw or "/").strip()
+	if not n.startswith("/") or n.startswith("//"):
+		return "/"
+	return n
+
+
+def access_token_from_request(
+	bearer: Annotated[str | None, Depends(oauth2_scheme)],
+	cookie_token: Annotated[str | None, Depends(_access_token_cookie)],
+) -> str | None:
+	b = bearer.strip() if bearer else None
+	c = cookie_token.strip() if cookie_token else None
+	return b or c
+
+
+def ensure_active_user_if_auth_enabled(token: str | None) -> None:
+	if not settings.AUTH:
+		return
+	if get_auth_credentials() is None:
+		raise HTTPException(
+			status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+			detail="Authentication enabled but ADMIN_USER / ADMIN_PASSWORD are 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"},
+		)
+
+
+def require_active_user_if_auth_enabled(
+	token: Annotated[str | None, Depends(access_token_from_request)],
+) -> None:
+	ensure_active_user_if_auth_enabled(token)
+
+
+@auth_router.get("/login", response_class=HTMLResponse)
+async def login_page(
+	next: Annotated[str, Query()] = "/",
+) -> HTMLResponse:
+	if not settings.AUTH:
+		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
+	next_safe = safe_next_url(next)
+	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Sign in", site_name=settings.SITE_NAME)
+	body = env.get_template("login.html").render(
+		site_name=settings.SITE_NAME,
+		next=next_safe,
+		error=None,
+	)
+	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+
+
+@auth_router.post("/login", response_model=None)
+async def login_form_submit(
+	username: Annotated[str, Form()],
+	password: Annotated[str, Form()],
+	next: Annotated[str, Form()] = "/",
+) -> HTMLResponse | RedirectResponse:
+	if not settings.AUTH:
+		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
+	next_safe = safe_next_url(next)
+	user = authenticate_user(username.strip(), password)
+	if user is None:
+		pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Sign in", site_name=settings.SITE_NAME)
+		body = env.get_template("login.html").render(
+			site_name=settings.SITE_NAME,
+			next=next_safe,
+			error="Incorrect username or password.",
+		)
+		return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+	resp = RedirectResponse(url=next_safe, status_code=303)
+	resp.set_cookie(
+		key=ACCESS_TOKEN_COOKIE_NAME,
+		value=user.username,
+		httponly=True,
+		samesite="lax",
+		max_age=LOGIN_COOKIE_MAX_AGE,
+		path="/",
+	)
+	return resp
+
+
+@auth_router.get("/logout")
+async def logout_page(next: Annotated[str, Query()] = "/") -> RedirectResponse:
+	resp = RedirectResponse(url=safe_next_url(next), status_code=303)
+	resp.delete_cookie(key=ACCESS_TOKEN_COOKIE_NAME, path="/")
+	return resp
+
+
+@auth_router.post("/token")
+async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]) -> dict[str, str]:
+	if not settings.AUTH:
+		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
+	user = authenticate_user(form_data.username, form_data.password)
+	if user is None:
+		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Incorrect username or password")
+	return {"access_token": user.username, "token_type": "bearer"}
+
+
+@auth_router.get("/auth/status")
+async def auth_status(
+	token: Annotated[str | None, Depends(access_token_from_request)],
+) -> dict[str, bool | str | None]:
+	"""Lightweight JSON for the navbar script (no 401 when anonymous)."""
+	if not settings.AUTH:
+		return {"auth_enabled": False, "username": None}
+	if not token:
+		return {"auth_enabled": True, "username": None}
+	u = decode_access_token(token)
+	if u is None:
+		return {"auth_enabled": True, "username": None}
+	return {"auth_enabled": True, "username": u.username}
+
+
+@auth_router.get("/user", response_class=HTMLResponse, response_model=None)
+async def user_account_page(
+	token: Annotated[str | None, Depends(access_token_from_request)],
+) -> HTMLResponse | RedirectResponse:
+	if not settings.AUTH:
+		pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Account", site_name=settings.SITE_NAME)
+		body = '<p class="text-muted">Authentication is not enabled on this server.</p>'
+		return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+	if get_auth_credentials() is None:
+		raise HTTPException(
+			status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+			detail="Authentication is misconfigured",
+		)
+	u = decode_access_token(token or "") if token else None
+	if u is None:
+		return RedirectResponse(url="/login?next=/user", status_code=303)
+	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Account", site_name=settings.SITE_NAME)
+	body = env.get_template("user_profile.html").render(username=u.username, site_name=settings.SITE_NAME)
+	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+
+
+@auth_router.get("/users/me", response_model=User)
+async def read_users_me(
+	token: Annotated[str | None, Depends(access_token_from_request)],
+) -> User:
+	if not settings.AUTH:
+		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
+	ensure_active_user_if_auth_enabled(token)
+	u = decode_access_token(token or "")
+	if u is None:
+		raise HTTPException(
+			status_code=status.HTTP_401_UNAUTHORIZED,
+			detail="Not authenticated",
+			headers={"WWW-Authenticate": "Bearer"},
+		)
+	return u
diff --git a/pygitweb/auth_test.py b/pygitweb/auth_test.py
new file mode 100644
index 0000000..eb182bc
--- /dev/null
+++ b/pygitweb/auth_test.py
@@ -0,0 +1,71 @@
+from collections.abc import Generator
+from unittest.mock import patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+from pygitweb.config import settings
+from pygitweb.main import app
+
+
+@pytest.fixture
+def client() -> Generator[TestClient, None, None]:
+	with TestClient(app) as c:
+		yield c
+
+
+def test_token_disabled(client: TestClient) -> None:
+	with patch.object(settings, "AUTH", False):
+		r = client.post("/token", data={"username": "a", "password": "b"})
+		assert r.status_code == 400
+		assert r.json()["detail"] == "Authentication is disabled"
+
+
+def test_token_success(client: TestClient) -> None:
+	with (
+		patch.object(settings, "AUTH", True),
+		patch.object(settings, "ADMIN_USER", "admin"),
+		patch.object(settings, "ADMIN_PASSWORD", "secret"),
+	):
+		r = client.post("/token", data={"username": "admin", "password": "secret"})
+	assert r.status_code == 200
+	assert r.json() == {"access_token": "admin", "token_type": "bearer"}
+
+
+def test_token_wrong_password(client: TestClient) -> None:
+	with (
+		patch.object(settings, "AUTH", True),
+		patch.object(settings, "ADMIN_USER", "admin"),
+		patch.object(settings, "ADMIN_PASSWORD", "secret"),
+	):
+		r = client.post("/token", data={"username": "admin", "password": "wrong"})
+	assert r.status_code == 400
+
+
+def test_users_me_with_bearer(client: TestClient) -> None:
+	with (
+		patch.object(settings, "AUTH", True),
+		patch.object(settings, "ADMIN_USER", "admin"),
+		patch.object(settings, "ADMIN_PASSWORD", "secret"),
+	):
+		tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
+		r = client.get("/users/me", headers={"Authorization": f"Bearer {tok}"})
+		assert r.status_code == 200
+		assert r.json()["username"] == "admin"
+
+
+def test_login_form_sets_cookie_and_users_me(client: TestClient) -> None:
+	with (
+		patch.object(settings, "AUTH", True),
+		patch.object(settings, "ADMIN_USER", "admin"),
+		patch.object(settings, "ADMIN_PASSWORD", "secret"),
+	):
+		r = client.post(
+			"/login",
+			data={"username": "admin", "password": "secret", "next": "/"},
+			follow_redirects=False,
+		)
+		assert r.status_code == 303
+		r2 = client.get("/users/me")
+		assert r2.status_code == 200
+		assert r2.json()["username"] == "admin"
diff --git a/pygitweb/change_queue_routes_test.py b/pygitweb/change_queue_routes_test.py
index 5284c08..1659e6b 100644
--- a/pygitweb/change_queue_routes_test.py
+++ b/pygitweb/change_queue_routes_test.py
@@ -41,7 +41,7 @@ def updates_env(tmp_path_factory: pytest.TempPathFactory) -> Generator[dict[str,
 		patch.object(settings, "STRICT_EXPORT", False),
 		patch.object(settings, "EXPORT_OK", ""),
 		patch.object(settings, "LIST_ALL", True),
-		patch.object(settings, "AUTH", None),
+		patch.object(settings, "AUTH", False),
 		patch.object(settings, "MAXLOAD", None),
 	):
 		yield {"root": str(root), "alpha": "alpha", "beta": "group/beta"}
diff --git a/pygitweb/config.py b/pygitweb/config.py
index 3dabf32..c88c333 100644
--- a/pygitweb/config.py
+++ b/pygitweb/config.py
@@ -5,9 +5,11 @@ PyGitWeb configuration: env-driven settings via pydantic-settings, constants, an
 from __future__ import annotations
 
 import os
+import secrets
+import sys
 from pathlib import Path
 
-from pydantic import Field, model_validator
+from pydantic import Field, field_validator, model_validator
 from pydantic_settings import BaseSettings, SettingsConfigDict
 
 # Actions (most identical to gitweb.perl actions)
@@ -87,9 +89,12 @@ class Settings(BaseSettings):
 	"""
 	Runtime settings sourced from PYGITWEB_* env vars (and optionally a .env file).
 
-	The auth provider:
-	"None" or unset disables authentication. Otherwise "module.path.ClassName" is imported
-	lazily in main.py. Planned providers: Root, SSH, OAuth2, OIDC, Matrix, LDAP.
+	When AUTH is true, PyGitWeb uses OAuth2 password flow (POST /token), Bearer tokens,
+	and browser login. Use ADMIN_USER / ADMIN_PASSWORD for credentials.
+
+	If PYGITWEB_AUTH is omitted entirely (or empty), authentication defaults to enabled
+	and a random password is generated once at startup (see stderr).
+	Set PYGITWEB_AUTH=0 or PYGITWEB_AUTH=1 explicitly together with ADMIN_* as needed.
 	"""
 
 	model_config = SettingsConfigDict(
@@ -111,21 +116,66 @@ class Settings(BaseSettings):
 	GIT: str = "git"
 	MAXLOAD: float | None = None
 
-	AUTH: str | None = None
-	ADMIN_USER: str | None = None
+	AUTH: bool | None = None
+	ADMIN_USER: str = "admin"
 	ADMIN_PASSWORD: str | None = None
-	SESSION_TIMEOUT: int = 3600 * 24 * 7
+
+	@field_validator("AUTH", mode="before")
+	@classmethod
+	def _coerce_auth(cls, v: object) -> bool | None:
+		if v is None:
+			return None
+		if isinstance(v, str) and v.strip() == "":
+			return None
+		if isinstance(v, bool):
+			return v
+		if isinstance(v, int):
+			return v != 0
+		if isinstance(v, str):
+			s = v.strip().lower()
+			if s in ("none", "false", "0", "no", "off"):
+				return False
+			if s in ("true", "1", "yes", "on"):
+				return True
+			return None
+		return bool(v)
 
 	@model_validator(mode="after")
 	def _defaults(self) -> Settings:
 		if not self.PROJECTS_LIST:
 			self.PROJECTS_LIST = self.PROJECTROOT
-		if self.AUTH == "None":
-			self.AUTH = None
 		return self
 
 
+def _finalize_auth_settings(s: Settings) -> None:
+	implicit_auth = s.AUTH is None
+	if implicit_auth:
+		s.AUTH = True
+	if not s.AUTH:
+		return
+	generated_password = False
+	if not s.ADMIN_PASSWORD:
+		s.ADMIN_PASSWORD = secrets.token_urlsafe(16)
+		generated_password = True
+	if implicit_auth and generated_password:
+		print(
+			"WARNING: PyGitWeb: PYGITWEB_AUTH was not set; authentication defaults to ON with a random password.\n"
+			"         Set PYGITWEB_AUTH=0 to disable, or PYGITWEB_AUTH=1 and set PYGITWEB_ADMIN_USER / "
+			"PYGITWEB_ADMIN_PASSWORD for a fixed login.\n",
+			file=sys.stderr,
+		)
+		print(f"  Username: {s.ADMIN_USER}\n  Password: {s.ADMIN_PASSWORD}\n", file=sys.stderr)
+	elif generated_password:
+		print(
+			"PyGitWeb: PYGITWEB_ADMIN_PASSWORD was not set; generated random password. "
+			"Set PYGITWEB_ADMIN_PASSWORD for a stable deployment.\n",
+			file=sys.stderr,
+		)
+		print(f"  Password: {s.ADMIN_PASSWORD}\n", file=sys.stderr)
+
+
 settings = Settings()
+_finalize_auth_settings(settings)
 
 
 def get_loadavg() -> float:
diff --git a/pygitweb/conftest.py b/pygitweb/conftest.py
new file mode 100644
index 0000000..1d5d928
--- /dev/null
+++ b/pygitweb/conftest.py
@@ -0,0 +1,23 @@
+"""Ensure tests default auth off unless a test patches settings (before config import is too late for that)."""
+
+from __future__ import annotations
+
+import asyncio
+import os
+
+import pytest
+
+os.environ.setdefault("PYGITWEB_AUTH", "0")
+
+
+@pytest.fixture(autouse=True)
+def _reset_pygitweb_app_lifecycle_state() -> None:
+	"""Starlette TestClient lifespan sets shutting_down on exit; httpx ASGITransport never runs lifespan.
+
+	Stale state made long-poll tests see ``shutting_down`` and skip ``wait_for_changes``, so notify woke 0 waiters.
+	"""
+	from pygitweb.main import app
+
+	app.state.shutting_down = False
+	app.state.shutdown_event = asyncio.Event()
+	yield
diff --git a/pygitweb/hooks_install_test.py b/pygitweb/hooks_install_test.py
index 8f3051e..d078347 100644
--- a/pygitweb/hooks_install_test.py
+++ b/pygitweb/hooks_install_test.py
@@ -51,7 +51,7 @@ def project_env(tmp_path: Path) -> Generator[dict[str, str], None, None]:
 		patch.object(settings, "STRICT_EXPORT", False),
 		patch.object(settings, "EXPORT_OK", ""),
 		patch.object(settings, "LIST_ALL", True),
-		patch.object(settings, "AUTH", None),
+		patch.object(settings, "AUTH", False),
 		patch.object(settings, "MAXLOAD", None),
 	):
 		yield {"root": str(root), "project": "demo", "hooks_dir": str(repo_dir / ".git" / "hooks")}
diff --git a/pygitweb/main.py b/pygitweb/main.py
index c78e97b..32f7488 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -9,6 +9,7 @@ import asyncio
 import os
 import signal
 import sys
+import threading
 from collections.abc import AsyncGenerator, Callable
 from contextlib import asynccontextmanager
 from datetime import UTC, datetime
@@ -17,7 +18,7 @@ from typing import Annotated
 from urllib.parse import quote
 
 import pygit2
-from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
+from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile
 from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse, Response
 from fastapi.staticfiles import StaticFiles
 
@@ -49,6 +50,12 @@ from pygitweb.actions import (
 from pygitweb.api.plugins.project_zip import ProjectZip
 from pygitweb.api.project import Project
 from pygitweb.api.subpage import Subpage
+from pygitweb.auth import (
+	access_token_from_request,
+	auth_router,
+	ensure_active_user_if_auth_enabled,
+	require_active_user_if_auth_enabled,
+)
 from pygitweb.change_queue import CHANGE_QUEUE
 from pygitweb.config import ACTIONS, get_loadavg, settings
 from pygitweb.formatting import age_string
@@ -127,7 +134,11 @@ def _install_graceful_shutdown_wakeup(app: FastAPI) -> None:
 	so toggling ``shutdown_event`` only from lifespan would deadlock with long polls.
 	We chain OS signals (same ones uvicorn uses) and notify waiters before uvicorn's
 	handler runs.
+
+	Skipped when not running on the main thread (e.g. Starlette ``TestClient`` lifespan).
 	"""
+	if threading.current_thread() is not threading.main_thread():
+		return
 	signals: tuple[int, ...] = (signal.SIGINT, signal.SIGTERM)
 	if sys.platform == "win32":
 		signals = signals + (signal.SIGBREAK,)
@@ -144,8 +155,11 @@ def _install_graceful_shutdown_wakeup(app: FastAPI) -> None:
 
 			return handler
 
-		prev = signal.getsignal(sig)
-		signal.signal(sig, make_chain(prev))
+		try:
+			prev = signal.getsignal(sig)
+			signal.signal(sig, make_chain(prev))
+		except ValueError:
+			continue
 
 
 @asynccontextmanager
@@ -166,7 +180,7 @@ with open("pygitweb/README.md", encoding="utf-8") as _readme_file:
 	_app_description = _readme_file.read()
 
 app = FastAPI(
-	debug=(settings.AUTH is None),
+	debug=(not settings.AUTH),
 	title="PyGitWeb",
 	summary="FastAPI + Pygit2 Repo Browser",
 	description=_app_description,
@@ -193,47 +207,7 @@ def _project_in_list(project: str) -> bool:
 	return any(p.get("path") == project for p in lst)
 
 
-_auth_provider = None
-
-
-def _get_auth_provider():
-	"""Return auth provider instance if settings.AUTH is set; None if auth disabled."""
-	global _auth_provider
-	if not settings.AUTH:
-		return None
-	if _auth_provider is not None:
-		return _auth_provider
-	try:
-		mod_name, _, cls_name = settings.AUTH.rpartition(".")
-		mod = __import__(mod_name, fromlist=[cls_name])
-		cls = getattr(mod, cls_name)
-		admin_user = settings.ADMIN_USER.encode("utf-8") if settings.ADMIN_USER else None
-		admin_password = settings.ADMIN_PASSWORD.encode("utf-8") if settings.ADMIN_PASSWORD else None
-		_auth_provider = cls(
-			admin_user=admin_user,
-			admin_password=admin_password,
-			session_timeout=settings.SESSION_TIMEOUT,
-		)
-	except Exception:
-		_auth_provider = None
-	return _auth_provider
-
-
-def _request_can_add_project(request: Request) -> bool:
-	"""True if auth is disabled or request has a valid session (header X-Session-Token or param/cookie session)."""
-	if not settings.AUTH:
-		return True
-	provider = _get_auth_provider()
-	if provider is None:
-		return False
-	token = (
-		request.headers.get("X-Session-Token") or request.query_params.get("session") or request.cookies.get("session")
-	)
-	if not token:
-		return False
-	return getattr(provider, "validate_session", lambda _: False)(token)
-
-
+app.include_router(auth_router)
 app.include_router(settings_router)
 app.include_router(board_router, prefix="/board")
 app.include_router(task_router, prefix="/tasks")
@@ -446,12 +420,11 @@ def git_opml():
 
 @app.get("/board/create", response_class=RedirectResponse)
 def board_create_page(
+	_: Annotated[None, Depends(require_active_user_if_auth_enabled)],
 	request: Request,
 	project: Annotated[str | None, Query(alias="p")] = None,
 ) -> RedirectResponse:
 	"""Create a board named 'Tasks' (refs/tags/boards/Tasks) and redirect to the project summary."""
-	if settings.AUTH:
-		raise HTTPException(status_code=401, detail="Authentication required")
 	p = project or request.query_params.get("project")
 	if not p:
 		raise HTTPException(status_code=400, detail="Project needed (use ?project= or ?p=)")
@@ -503,6 +476,7 @@ def addproject_page(request: Request):
 
 @app.post("/addproject", response_class=HTMLResponse)
 async def addproject_submit(
+	_: Annotated[None, Depends(require_active_user_if_auth_enabled)],
 	request: Request,
 	project_name: Annotated[str, Form()] = "",
 	pull_from_remote: Annotated[str, Form()] = "",
@@ -512,11 +486,8 @@ async def addproject_submit(
 ):
 	"""
 	Create a new project. Allowed only if project name does not exist,
-	and valid session in headers / session param (or auth is disabled).
+	and a valid Bearer token when authentication is enabled.
 	"""
-	if not _request_can_add_project(request):
-		raise HTTPException(status_code=401, detail="Authentication required to add projects")
-
 	project_name = (project_name or "").strip()
 	if not project_name:
 		raise HTTPException(status_code=400, detail="Project name is required")
@@ -653,9 +624,9 @@ def project_subpage(
 
 @app.post("/project/{project:path}/hook", response_class=JSONResponse)
 def project_hook(
-	request: Request,
 	project: str,
 	name: Annotated[str, Query(alias="name")],
+	token: Annotated[str | None, Depends(access_token_from_request)],
 	op: Annotated[str, Query(alias="op")] = "check",
 ) -> JSONResponse:
 	"""Add, remove, or check a pygittools hook sample (or bundle of samples) for a project.
@@ -665,8 +636,8 @@ def project_hook(
 	`op` is `add`, `remove`, or `check`.
 	"""
 	_validate_project(project)
-	if op != "check" and not _request_can_add_project(request):
-		raise HTTPException(status_code=401, detail="Authentication required to manage hooks")
+	if op != "check":
+		ensure_active_user_if_auth_enabled(token)
 	if op not in ("add", "remove", "check"):
 		raise HTTPException(status_code=400, detail="op must be one of: add, remove, check")
 	bundle = get_bundle(name)
diff --git a/pygitweb/merge_requests.py b/pygitweb/merge_requests.py
index 28568ef..07770ab 100644
--- a/pygitweb/merge_requests.py
+++ b/pygitweb/merge_requests.py
@@ -7,13 +7,14 @@ from typing import Any
 from urllib.parse import quote
 
 import pygit2
-from fastapi import APIRouter, Form, HTTPException, Query
+from fastapi import APIRouter, Depends, Form, HTTPException, Query
 from fastapi.responses import HTMLResponse, RedirectResponse, Response
 
 from pygittools.merge import MergeRequest, MergeRequestStatus, get_merge_request_by_oid
+from pygitweb.auth import require_active_user_if_auth_enabled
 from pygitweb.config import settings
 from pygitweb.git_helpers import open_repo
-from pygitweb.tasks import _require_auth_disabled, _validate_project
+from pygitweb.tasks import _validate_project
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
 from pygitweb.validation import is_valid_ref_format
 
@@ -22,7 +23,7 @@ def _branch_short_name(ref: str) -> str:
 	return ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else ref
 
 
-merge_router = APIRouter(tags=["merge_requests"])
+merge_router = APIRouter(tags=["merge_requests"], dependencies=[Depends(require_active_user_if_auth_enabled)])
 
 _STATUS_LABELS: dict[MergeRequestStatus, str] = {
 	MergeRequestStatus.CAN_FAST_FORWARD: "Can fast-forward",
@@ -162,7 +163,6 @@ def merge_request_create(
 	ours: str = Form(...),
 	title: str = Form(""),
 ) -> RedirectResponse:
-	_require_auth_disabled()
 	_validate_project(project)
 	theirs_ref = theirs.strip()
 	ours_ref = ours.strip()
@@ -201,7 +201,6 @@ def merge_fast_forward(
 	project: str = Query(..., description="Project path"),
 	h: str = Query(..., description="Annotated tag object id (hex)"),
 ) -> RedirectResponse:
-	_require_auth_disabled()
 	_validate_project(project)
 	if not h or not is_valid_ref_format(h):
 		raise HTTPException(status_code=400, detail="Invalid tag hash (h)")
diff --git a/pygitweb/merge_requests_test.py b/pygitweb/merge_requests_test.py
index 2a4c312..455bba6 100644
--- a/pygitweb/merge_requests_test.py
+++ b/pygitweb/merge_requests_test.py
@@ -48,7 +48,7 @@ class TestMergeRequestTagView:
 			patch.object(settings, "PROJECTS_LIST", str(projects_list)),
 			patch.object(settings, "STRICT_EXPORT", False),
 			patch.object(settings, "EXPORT_OK", ""),
-			patch.object(settings, "AUTH", None),
+			patch.object(settings, "AUTH", False),
 		):
 			client = TestClient(app)
 			yield {
diff --git a/pygitweb/static/navbar-auth.js b/pygitweb/static/navbar-auth.js
new file mode 100644
index 0000000..856947a
--- /dev/null
+++ b/pygitweb/static/navbar-auth.js
@@ -0,0 +1,62 @@
+(function () {
+	function adminAvatarSvg() {
+		return '<svg xmlns="http://www.w3.org/2000/svg" class="icon text-white" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 3a12 12 0 0 0 8.5 3a12 12 0 0 1 -8.5 15a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3" /></svg>';
+	}
+	function userAvatarSvg() {
+		return '<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /></svg>';
+	}
+	var nav = document.getElementById("navbar-user");
+	if (!nav) return;
+	var link = document.getElementById("navbar-user-link");
+	var avatar = document.getElementById("navbar-user-avatar");
+	var nameEl = nav.querySelector(".navbar-user-name");
+	var metaEl = nav.querySelector(".navbar-user-meta");
+	if (!link || !avatar || !nameEl || !metaEl) return;
+	function resetGuestAvatar() {
+		avatar.classList.remove("bg-primary", "bg-teal", "text-white");
+		avatar.classList.add("bg-secondary");
+		avatar.innerHTML = userAvatarSvg();
+	}
+	fetch("/auth/status", { credentials: "same-origin" })
+		.then(function (r) {
+			return r.json();
+		})
+		.then(function (s) {
+			if (!s.auth_enabled) {
+				link.removeAttribute("href");
+				link.setAttribute("aria-disabled", "true");
+				link.classList.add("pe-none", "opacity-75");
+				nameEl.textContent = "Guest";
+				metaEl.textContent = "Sign-in not used";
+				return;
+			}
+			link.classList.remove("pe-none", "opacity-75");
+			link.removeAttribute("aria-disabled");
+			if (s.username) {
+				nav.setAttribute("data-logged-in", "true");
+				link.href = "/user";
+				nameEl.textContent = s.username;
+				metaEl.textContent = "Signed in";
+				avatar.classList.remove("bg-secondary");
+				if (String(s.username).toLowerCase() === "admin") {
+					avatar.classList.remove("bg-teal");
+					avatar.classList.add("bg-primary", "text-white");
+					avatar.innerHTML = adminAvatarSvg();
+				} else {
+					avatar.classList.remove("bg-primary", "text-white");
+					avatar.classList.add("bg-teal");
+					avatar.innerHTML = userAvatarSvg();
+				}
+				return;
+			}
+			nav.setAttribute("data-logged-in", "false");
+			link.href = "/login";
+			nameEl.textContent = "Guest";
+			metaEl.textContent = "Not signed in";
+			resetGuestAvatar();
+		})
+		.catch(function () {
+			link.href = "/login";
+			resetGuestAvatar();
+		});
+})();
diff --git a/pygitweb/tasks.py b/pygitweb/tasks.py
index 0f15cbb..0b9dd0e 100644
--- a/pygitweb/tasks.py
+++ b/pygitweb/tasks.py
@@ -10,7 +10,7 @@ import time
 from typing import Any
 
 import pygit2
-from fastapi import APIRouter, HTTPException, Query, Request
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
 from fastapi.responses import JSONResponse
 
 from pygittools.tasks import (
@@ -24,6 +24,7 @@ from pygittools.tasks import (
 	get_task,
 	get_task_by_oid,
 )
+from pygitweb.auth import require_active_user_if_auth_enabled
 from pygitweb.config import settings
 from pygitweb.projects import git_get_projects_list
 from pygitweb.validation import is_valid_project
@@ -32,12 +33,6 @@ from pygitweb.validation import is_valid_project
 EMPTY_TREE_OID = pygit2.Oid(hex="4b825dc642cb6eb9a060e54bf8d69288fbee4904")
 
 
-def _require_auth_disabled() -> None:
-	"""Raise 401 if authentication is enabled (we will add real auth later)."""
-	if settings.AUTH:
-		raise HTTPException(status_code=401, detail="Authentication required")
-
-
 def _project_in_list(project: str) -> bool:
 	lst = git_get_projects_list(
 		filter_path="",
@@ -93,7 +88,7 @@ def create_board_for_project(
 
 # ---------- Board Routes ----------
 
-board_router = APIRouter(tags=["boards"])
+board_router = APIRouter(tags=["boards"], dependencies=[Depends(require_active_user_if_auth_enabled)])
 
 
 @board_router.get("/list", response_class=JSONResponse)
@@ -102,7 +97,6 @@ def boards_list(
 	project: str = Query(..., description="Project path"),
 ) -> JSONResponse:
 	"""List all boards for the project."""
-	_require_auth_disabled()
 	_validate_project(project)
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	refs = [
@@ -133,7 +127,6 @@ def boards_create(
 	description: str = Query("", description="Board description"),
 ) -> JSONResponse:
 	"""Create a new board."""
-	_require_auth_disabled()
 	_validate_project(project)
 	if not name or "/" in name or ".." in name:
 		raise HTTPException(status_code=400, detail="Invalid board name")
@@ -154,7 +147,6 @@ def boards_delete(
 	name: str = Query(..., description="Board name"),
 ) -> JSONResponse:
 	"""Delete a board (removes ref; tag object remains in ODB)."""
-	_require_auth_disabled()
 	_validate_project(project)
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	ref = _board_ref(name)
@@ -166,7 +158,7 @@ def boards_delete(
 
 # ---------- Task Routes ----------
 
-task_router = APIRouter(tags=["tasks"])
+task_router = APIRouter(tags=["tasks"], dependencies=[Depends(require_active_user_if_auth_enabled)])
 
 
 def _task_to_json(t: Task) -> dict[str, Any]:
@@ -231,7 +223,6 @@ def task_list(
 	board: str = Query(..., description="Board name"),
 ) -> JSONResponse:
 	"""List all tasks on a board."""
-	_require_auth_disabled()
 	_validate_project(project)
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	ref = _board_ref(board)
@@ -264,7 +255,6 @@ def task_create(
 	due_date: str = Query("", description="Due date ISO"),
 ) -> JSONResponse:
 	"""Create a new task on a board."""
-	_require_auth_disabled()
 	_validate_project(project)
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	board_ref = _board_ref(board)
@@ -311,7 +301,6 @@ async def task_update(
 	task_ref: str = Query(..., alias="task", description="Task ref (e.g. refs/tags/tasks/task_123)"),
 ) -> JSONResponse:
 	"""Update a task (body: optional title, description, status, priority, assignee, due_date)."""
-	_require_auth_disabled()
 	_validate_project(project)
 	try:
 		body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
@@ -361,7 +350,6 @@ def task_delete(
 	task_ref: str = Query(..., alias="task", description="Task ref"),
 ) -> JSONResponse:
 	"""Delete a task (remove ref and remove from board.tasks)."""
-	_require_auth_disabled()
 	_validate_project(project)
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	ref = task_ref if task_ref.startswith("refs/") else _task_ref(task_ref)
@@ -383,7 +371,7 @@ def task_delete(
 
 # ---------- Comment Routes ----------
 
-comment_router = APIRouter(tags=["comments"])
+comment_router = APIRouter(tags=["comments"], dependencies=[Depends(require_active_user_if_auth_enabled)])
 
 
 def _comment_to_json(c: Comment) -> dict[str, Any]:
@@ -403,7 +391,6 @@ def comment_list(
 	task: str = Query(..., description="Task ref (e.g. refs/tags/tasks/task_123)"),
 ) -> JSONResponse:
 	"""List all comments for a task."""
-	_require_auth_disabled()
 	_validate_project(project)
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	task_ref = task if task.startswith("refs/") else _task_ref(task)
@@ -432,7 +419,6 @@ def comment_create(
 	content: str = Query(..., description="Comment content"),
 ) -> JSONResponse:
 	"""Create a new comment on a task."""
-	_require_auth_disabled()
 	_validate_project(project)
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	task_ref = task if task.startswith("refs/") else _task_ref(task)
@@ -459,7 +445,6 @@ async def comment_update(
 	comment_oid: str = Query(..., alias="comment", description="Comment OID hex"),
 ) -> JSONResponse:
 	"""Update a comment (body: content). Writes new comment object and updates task.comments."""
-	_require_auth_disabled()
 	_validate_project(project)
 	try:
 		body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
@@ -500,7 +485,6 @@ def comment_delete(
 	comment_oid: str = Query(..., alias="comment", description="Comment OID hex"),
 ) -> JSONResponse:
 	"""Delete a comment (object remains in ODB; caller may remove from task.comments)."""
-	_require_auth_disabled()
 	_validate_project(project)
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	oid = pygit2.Oid(hex=comment_oid)
diff --git a/pygitweb/tasks_routes_test.py b/pygitweb/tasks_routes_test.py
index 22a41ed..10d1e5f 100644
--- a/pygitweb/tasks_routes_test.py
+++ b/pygitweb/tasks_routes_test.py
@@ -87,7 +87,7 @@ class TestTaskRoutes:
 				patch.object(settings, "PROJECTS_LIST", str(root)),
 				patch.object(settings, "STRICT_EXPORT", False),
 				patch.object(settings, "EXPORT_OK", ""),
-				patch.object(settings, "AUTH", None),
+				patch.object(settings, "AUTH", False),
 			):
 				client = _build_client()
 				yield {
@@ -187,7 +187,7 @@ class TestTaskRoutes:
 				patch.object(settings, "PROJECTS_LIST", str(root)),
 				patch.object(settings, "STRICT_EXPORT", False),
 				patch.object(settings, "EXPORT_OK", ""),
-				patch.object(settings, "AUTH", None),
+				patch.object(settings, "AUTH", False),
 			):
 				client = _build_client()
 				yield {
diff --git a/pygitweb/templates/login.html b/pygitweb/templates/login.html
new file mode 100644
index 0000000..d991877
--- /dev/null
+++ b/pygitweb/templates/login.html
@@ -0,0 +1,23 @@
+<h1 class="page-title">Sign in</h1>
+<div class="card">
+  <div class="card-body">
+    {% if error %}
+    <div class="alert alert-danger mb-3" role="alert">{{ error }}</div>
+    {% endif %}
+    <form method="post" action="/login" class="needs-validation" novalidate>
+      <input type="hidden" name="next" value="{{ next }}">
+      <div class="mb-3">
+        <label class="form-label required" for="login_username">Username</label>
+        <input type="text" class="form-control" id="login_username" name="username" required
+               autocomplete="username" autofocus>
+      </div>
+      <div class="mb-3">
+        <label class="form-label required" for="login_password">Password</label>
+        <input type="password" class="form-control" id="login_password" name="password" required
+               autocomplete="current-password">
+      </div>
+      <button type="submit" class="btn btn-primary">Sign in</button>
+    </form>
+    <p class="mt-3 mb-0 text-muted small">API clients can use <code>POST /token</code> (OAuth2 password) instead.</p>
+  </div>
+</div>
diff --git a/pygitweb/templates/preamble.html b/pygitweb/templates/preamble.html
index 8345df8..a87e891 100644
--- a/pygitweb/templates/preamble.html
+++ b/pygitweb/templates/preamble.html
@@ -14,6 +14,7 @@
 <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Fira+Mono:wght@400;500;700&family=Fira+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta17/dist/css/tabler.min.css">
 <link rel="stylesheet" href="/static/main.css"/>
+<script src="/static/navbar-auth.js" defer></script>
 </head><body>
 <div class="page">
   <aside class="navbar navbar-vertical navbar-expand-sm position-fixed">
@@ -72,15 +73,15 @@
         </ul>
       </div>
       <div class="navbar-user mt-auto py-3 px-3 border-top" id="navbar-user" data-logged-in="false">
-        <div class="d-flex align-items-center text-reset text-decoration-none">
-          <span class="avatar avatar-sm bg-secondary me-2 d-flex align-items-center justify-content-center">
-            <svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-user" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /></svg>
+        <a class="d-flex align-items-center text-reset text-decoration-none w-100" id="navbar-user-link" href="/login" aria-label="Account">
+          <span class="avatar avatar-sm bg-secondary me-2 d-flex align-items-center justify-content-center" id="navbar-user-avatar">
+            <svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-user" width="22" height="22" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /></svg>
           </span>
-          <div class="d-none d-xl-block">
+          <div class="d-none d-xl-block text-start">
             <div class="navbar-user-name text-body">Guest</div>
             <div class="navbar-user-meta text-muted small">Not signed in</div>
           </div>
-        </div>
+        </a>
       </div>
     </div>
   </aside>
diff --git a/pygitweb/templates/user_profile.html b/pygitweb/templates/user_profile.html
new file mode 100644
index 0000000..ea55cc8
--- /dev/null
+++ b/pygitweb/templates/user_profile.html
@@ -0,0 +1,10 @@
+<h1 class="page-title">Account</h1>
+<div class="card">
+  <div class="card-body">
+    <p class="mb-1"><strong>Username</strong></p>
+    <p class="text-muted mb-4">{{ username | e }}</p>
+    <p class="mb-0">
+      <a class="btn btn-outline-secondary" href="/logout?next=/">Sign out</a>
+    </p>
+  </div>
+</div>
