diff --git a/pygitweb/README.md b/pygitweb/README.md
index 2b93912..6af8c72 100644
--- a/pygitweb/README.md
+++ b/pygitweb/README.md
@@ -38,9 +38,8 @@ 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` — `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)
+- `PYGITWEB_AUTH` — `0` / `false` / `off` disables auth; `1` / `true` / `on` enables it. If **unset or empty**, auth defaults to **on**.
+- `PYGITWEB_AUTH_CONFIG` — path to the auth JSON file (default: `~/.pygitweb/auth.json`). Generated to a reasonable default if not present.
 - Browser login: `GET /login` (optional `?next=/path`), `POST /login` with form fields `username`, `password`, `next`; sets an HttpOnly cookie with an opaque in-process session id (also accepted by protected routes via `Authorization: Bearer`). Sessions are wiped when the process exits. `GET /logout?next=/` revokes the session and clears the cookie.
 
 ## Routes
diff --git a/pygitweb/auth.py b/pygitweb/auth.py
index c4486bb..5980f1f 100644
--- a/pygitweb/auth.py
+++ b/pygitweb/auth.py
@@ -1,5 +1,5 @@
 """
-OAuth2 password flow (tutorial-style) with credentials from PYGITWEB_ADMIN_USER / PYGITWEB_ADMIN_PASSWORD.
+Local and (future) OAuth authentication with credentials from ~/.pygitweb/auth.json.
 
 Browser login: GET/POST /login sets an HttpOnly cookie holding an opaque session id.
 APIs accept Authorization: Bearer <same session id>. Sessions live in memory only (see pygitweb.sessions).
@@ -15,6 +15,11 @@ from fastapi.responses import HTMLResponse, RedirectResponse
 from fastapi.security import APIKeyCookie, OAuth2PasswordBearer, OAuth2PasswordRequestForm
 from pydantic import BaseModel
 
+from pygitweb.auth_config import (
+	auth_config,
+	is_auth_configured,
+	is_local_auth_available,
+)
 from pygitweb.config import settings
 from pygitweb.sessions import create_session, get_session, revoke_session
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
@@ -36,22 +41,15 @@ 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:
+	if not settings.AUTH or not is_local_auth_available(auth_config):
 		return None
-	if not hmac.compare_digest(stored.username, username):
-		return None
-	return stored
+	for entry in auth_config.local_users:
+		if not entry.password:
+			continue
+		if hmac.compare_digest(entry.user, username):
+			return UserInDB(username=entry.user, password=entry.password)
+	return None
 
 
 def authenticate_user(username: str, password: str) -> User | None:
@@ -63,16 +61,23 @@ def authenticate_user(username: str, password: str) -> User | None:
 	return User(username=user.username)
 
 
+def is_valid_session_username(username: str) -> bool:
+	if auth_config.auth_mode in ("local", "both"):
+		for entry in auth_config.local_users:
+			if hmac.compare_digest(entry.user, username):
+				return True
+	return False
+
+
 def decode_access_token(token: str) -> User | None:
-	stored = get_auth_credentials()
-	if stored is None:
+	if not settings.AUTH:
 		return None
 	rec = get_session(token)
 	if rec is None:
 		return None
-	if not hmac.compare_digest(stored.username, rec.username):
+	if not is_valid_session_username(rec.username):
 		return None
-	return User(username=stored.username)
+	return User(username=rec.username)
 
 
 def safe_next_url(next_raw: str | None) -> str:
@@ -94,10 +99,10 @@ def access_token_from_request(
 def ensure_active_user_if_auth_enabled(token: str | None) -> None:
 	if not settings.AUTH:
 		return
-	if get_auth_credentials() is None:
+	if not is_auth_configured(auth_config):
 		raise HTTPException(
 			status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
-			detail="Authentication enabled but ADMIN_USER / ADMIN_PASSWORD are not configured",
+			detail="Authentication enabled but auth.json is not configured",
 		)
 	if not token:
 		raise HTTPException(
@@ -211,7 +216,7 @@ async def user_account_page(
 		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:
+	if not is_auth_configured(auth_config):
 		raise HTTPException(
 			status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
 			detail="Authentication is misconfigured",
diff --git a/pygitweb/auth.schema.json b/pygitweb/auth.schema.json
new file mode 100644
index 0000000..78ca2cb
--- /dev/null
+++ b/pygitweb/auth.schema.json
@@ -0,0 +1,69 @@
+{
+    "$schema": "http://json-schema.org/draft-04/schema#",
+    "description": "",
+    "type": "object",
+    "properties": {
+      "auth_mode": {
+        "type": "string",
+        "pattern": "^(oauth|local|both)$"
+      },
+      "oauth_client_secret": {
+        "type": "string",
+        "minLength": 0
+      },
+      "oauth_redirect_uri": {
+        "type": "string",
+        "minLength": 0
+      },
+      "oauth_username_from": {
+        "type": "string",
+        "pattern": "^(sub|email|preffered_username|login|name)$"
+      },
+      "oauth_allowed_emails": {
+        "type": "array",
+        "items": {
+          "required": [],
+          "properties": {}
+        }
+      },
+      "oauth_permissions": {
+        "type": "object",
+        "properties": {
+          "*": {
+            "type": "array",
+            "items": {
+              "required": [],
+              "properties": {}
+            }
+          }
+        },
+        "required": [
+          "*"
+        ]
+      },
+      "local_users": {
+        "type": "array",
+        "uniqueItems": true,
+        "minItems": 1,
+        "items": {
+          "required": [
+            "user",
+            "pass"
+          ],
+          "properties": {
+            "user": {
+              "type": "string",
+              "minLength": 2
+            },
+            "pass": {
+              "type": "string",
+              "minLength": 8
+            }
+          }
+        }
+      }
+    },
+    "required": [
+      "auth_mode"
+    ]
+  }
\ No newline at end of file
diff --git a/pygitweb/auth_config.py b/pygitweb/auth_config.py
new file mode 100644
index 0000000..388ee9f
--- /dev/null
+++ b/pygitweb/auth_config.py
@@ -0,0 +1,159 @@
+"""
+Authentication settings loaded from ~/.pygitweb/auth.json (or PYGITWEB_AUTH_CONFIG).
+
+On first run with auth enabled, creates the file with generated local credentials.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import secrets
+import stat
+import sys
+from pathlib import Path
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+from pygitweb.config import Settings
+
+DEFAULT_AUTH_CONFIG_PATH = Path.home() / ".pygitweb" / "auth.json"
+_AUTH_CONFIG_MODE = 0o600
+
+
+def _is_windows() -> bool:
+	return sys.platform == "win32"
+
+
+def _auth_config_mode(path: Path) -> int:
+	return stat.S_IMODE(path.stat().st_mode)
+
+
+def _set_auth_config_mode(path: Path) -> None:
+	if _is_windows():
+		return
+	os.chmod(path, _AUTH_CONFIG_MODE)
+
+
+class AuthConfigPermissionError(RuntimeError):
+	"""Auth config file exists but is not mode 0600 (non-Windows only)."""
+
+
+def _require_auth_config_mode(path: Path) -> None:
+	if _is_windows():
+		return
+	mode = _auth_config_mode(path)
+	if mode != _AUTH_CONFIG_MODE:
+		raise AuthConfigPermissionError(
+			f"PyGitWeb: auth config {path} must have mode 0600 (found {mode:#04o}). Run: chmod 600 {path}"
+		)
+
+
+AuthMode = Literal["oauth", "local", "both"]
+OAuthUsernameFrom = Literal["sub", "email", "preffered_username", "login", "name"]
+
+
+class LocalUser(BaseModel):
+	model_config = ConfigDict(populate_by_name=True)
+
+	user: str = Field(min_length=2)
+	password: str = Field(default="", alias="pass", serialization_alias="pass")
+
+
+class AuthConfig(BaseModel):
+	model_config = ConfigDict(populate_by_name=True)
+
+	auth_mode: AuthMode = "local"
+	oauth_client_secret: str = ""
+	oauth_redirect_uri: str = ""
+	oauth_username_from: OAuthUsernameFrom = "email"
+	oauth_allowed_emails: list[str] = Field(default_factory=list)
+	oauth_permissions: dict[str, list[str]] = Field(default_factory=lambda: {"*": []})
+	local_users: list[LocalUser] = Field(default_factory=list)
+
+	@field_validator("oauth_permissions", mode="before")
+	@classmethod
+	def _ensure_wildcard_permissions(cls, v: object) -> object:
+		if v is None:
+			return {"*": []}
+		if isinstance(v, dict) and "*" not in v:
+			return {**v, "*": []}
+		return v
+
+
+def auth_config_path(settings: Settings) -> Path:
+	if settings.AUTH_CONFIG:
+		return Path(settings.AUTH_CONFIG).expanduser()
+	return DEFAULT_AUTH_CONFIG_PATH
+
+
+def default_auth_config(*, admin_password: str) -> AuthConfig:
+	return AuthConfig(
+		auth_mode="local",
+		oauth_client_secret="",
+		oauth_redirect_uri="",
+		oauth_username_from="email",
+		oauth_allowed_emails=[],
+		oauth_permissions={"*": []},
+		local_users=[LocalUser(user="admin", password=admin_password)],
+	)
+
+
+def write_auth_config(path: Path, config: AuthConfig) -> None:
+	path.parent.mkdir(parents=True, exist_ok=True)
+	path.write_text(
+		json.dumps(config.model_dump(by_alias=True, mode="json"), indent=2) + "\n",
+		encoding="utf-8",
+	)
+	_set_auth_config_mode(path)
+
+
+def load_auth_config_file(path: Path) -> AuthConfig:
+	return AuthConfig.model_validate_json(path.read_text(encoding="utf-8"))
+
+
+def is_local_auth_available(config: AuthConfig) -> bool:
+	if config.auth_mode not in ("local", "both"):
+		return False
+	return any(u.password for u in config.local_users)
+
+
+def is_oauth_auth_available(config: AuthConfig) -> bool:
+	if config.auth_mode not in ("oauth", "both"):
+		return False
+	return bool(config.oauth_client_secret.strip() and config.oauth_redirect_uri.strip())
+
+
+def is_auth_configured(config: AuthConfig) -> bool:
+	if config.auth_mode == "local":
+		return is_local_auth_available(config)
+	if config.auth_mode == "oauth":
+		return is_oauth_auth_available(config)
+	return is_local_auth_available(config) or is_oauth_auth_available(config)
+
+
+def init_auth_config(settings: Settings) -> AuthConfig:
+	global auth_config
+	path = auth_config_path(settings)
+	if path.is_file():
+		if settings.AUTH:
+			_require_auth_config_mode(path)
+		auth_config = load_auth_config_file(path)
+		return auth_config
+	if not settings.AUTH:
+		auth_config = AuthConfig(auth_mode="local", local_users=[])
+		return auth_config
+	password = secrets.token_urlsafe(16)
+	auth_config = default_auth_config(admin_password=password)
+	write_auth_config(path, auth_config)
+	print(
+		f"WARNING: PyGitWeb: created auth config at {path}\n"
+		"         Set PYGITWEB_AUTH=0 to disable auth, or edit this file for fixed credentials.\n",
+		file=sys.stderr,
+	)
+	print(f"  Username: admin\n  Password: {password}\n", file=sys.stderr)
+	return auth_config
+
+
+auth_config: AuthConfig = AuthConfig(auth_mode="local", local_users=[])
diff --git a/pygitweb/auth_test.py b/pygitweb/auth_test.py
index 15c62a9..aafa9e8 100644
--- a/pygitweb/auth_test.py
+++ b/pygitweb/auth_test.py
@@ -1,12 +1,21 @@
+import os
+import sys
 from collections.abc import Generator
+from pathlib import Path
 from unittest.mock import patch
 
 import pytest
 from fastapi.testclient import TestClient
 
+from pygitweb.auth_config import AuthConfig, LocalUser
 from pygitweb.config import settings
 from pygitweb.main import app
 
+_LOCAL_ADMIN = AuthConfig(
+	auth_mode="local",
+	local_users=[LocalUser(user="admin", password="secret")],
+)
+
 
 @pytest.fixture
 def client() -> Generator[TestClient, None, None]:
@@ -14,6 +23,16 @@ def client() -> Generator[TestClient, None, None]:
 		yield c
 
 
+@pytest.fixture
+def local_auth_config() -> Generator[AuthConfig, None, None]:
+	with (
+		patch("pygitweb.auth_config.auth_config", _LOCAL_ADMIN),
+		patch("pygitweb.auth.auth_config", _LOCAL_ADMIN),
+		patch("pygitweb.main.auth_config", _LOCAL_ADMIN),
+	):
+		yield _LOCAL_ADMIN
+
+
 def test_token_disabled(client: TestClient) -> None:
 	with patch.object(settings, "AUTH", False):
 		r = client.post("/token", data={"username": "a", "password": "b"})
@@ -21,12 +40,8 @@ def test_token_disabled(client: TestClient) -> None:
 		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"),
-	):
+def test_token_success(client: TestClient, local_auth_config: AuthConfig) -> None:
+	with patch.object(settings, "AUTH", True):
 		r = client.post("/token", data={"username": "admin", "password": "secret"})
 	assert r.status_code == 200
 	data = r.json()
@@ -36,34 +51,22 @@ def test_token_success(client: TestClient) -> None:
 	assert data["access_token"] != "admin"
 
 
-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"),
-	):
+def test_token_wrong_password(client: TestClient, local_auth_config: AuthConfig) -> None:
+	with patch.object(settings, "AUTH", True):
 		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"),
-	):
+def test_users_me_with_bearer(client: TestClient, local_auth_config: AuthConfig) -> None:
+	with patch.object(settings, "AUTH", True):
 		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"),
-	):
+def test_login_form_sets_cookie_and_users_me(client: TestClient, local_auth_config: AuthConfig) -> None:
+	with patch.object(settings, "AUTH", True):
 		r = client.post(
 			"/login",
 			data={"username": "admin", "password": "secret", "next": "/"},
@@ -75,12 +78,8 @@ def test_login_form_sets_cookie_and_users_me(client: TestClient) -> None:
 		assert r2.json()["username"] == "admin"
 
 
-def test_logout_revokes_cookie_session(client: TestClient) -> None:
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "ADMIN_USER", "admin"),
-		patch.object(settings, "ADMIN_PASSWORD", "secret"),
-	):
+def test_logout_revokes_cookie_session(client: TestClient, local_auth_config: AuthConfig) -> None:
+	with patch.object(settings, "AUTH", True):
 		client.post(
 			"/login",
 			data={"username": "admin", "password": "secret", "next": "/"},
@@ -92,12 +91,8 @@ def test_logout_revokes_cookie_session(client: TestClient) -> None:
 		assert client.get("/users/me").status_code == 401
 
 
-def test_logout_with_bearer_revokes_token(client: TestClient) -> None:
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "ADMIN_USER", "admin"),
-		patch.object(settings, "ADMIN_PASSWORD", "secret"),
-	):
+def test_logout_with_bearer_revokes_token(client: TestClient, local_auth_config: AuthConfig) -> None:
+	with patch.object(settings, "AUTH", True):
 		tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
 		headers = {"Authorization": f"Bearer {tok}"}
 		assert client.get("/users/me", headers=headers).status_code == 200
@@ -112,21 +107,17 @@ def test_projectnamevalid_no_auth_required_when_auth_disabled(client: TestClient
 	assert r.status_code == 200
 
 
-def test_projectnamevalid_401_without_credentials_when_auth_enabled(client: TestClient) -> None:
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "ADMIN_USER", "admin"),
-		patch.object(settings, "ADMIN_PASSWORD", "secret"),
-	):
+def test_projectnamevalid_401_without_credentials_when_auth_enabled(
+	client: TestClient, local_auth_config: AuthConfig
+) -> None:
+	with patch.object(settings, "AUTH", True):
 		r = client.get("/projectnamevalid", params={"name": "newproj"})
 	assert r.status_code == 401
 
 
-def test_projectnamevalid_ok_with_bearer_when_auth_enabled(client: TestClient) -> None:
+def test_projectnamevalid_ok_with_bearer_when_auth_enabled(client: TestClient, local_auth_config: AuthConfig) -> None:
 	with (
 		patch.object(settings, "AUTH", True),
-		patch.object(settings, "ADMIN_USER", "admin"),
-		patch.object(settings, "ADMIN_PASSWORD", "secret"),
 		patch("pygitweb.main.project_visible_in_list", return_value=False),
 	):
 		tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
@@ -136,3 +127,58 @@ def test_projectnamevalid_ok_with_bearer_when_auth_enabled(client: TestClient) -
 			headers={"Authorization": f"Bearer {tok}"},
 		)
 	assert r.status_code == 200
+
+
+def test_auth_config_init_creates_file_only_when_auth_enabled(tmp_path: Path) -> None:
+	from pygitweb.auth_config import init_auth_config
+	from pygitweb.config import Settings
+
+	cfg_path = tmp_path / "auth.json"
+	s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
+	assert not cfg_path.is_file()
+	cfg = init_auth_config(s)
+	assert cfg_path.is_file()
+	assert cfg.local_users[0].user == "admin"
+	assert len(cfg.local_users[0].password) >= 8
+	if sys.platform != "win32":
+		assert oct(os.stat(cfg_path).st_mode & 0o777) == "0o600"
+
+
+def test_auth_config_no_file_when_auth_disabled(tmp_path: Path) -> None:
+	from pygitweb.auth_config import init_auth_config
+	from pygitweb.config import Settings
+
+	cfg_path = tmp_path / "auth.json"
+	s = Settings(AUTH=False, AUTH_CONFIG=str(cfg_path))
+	init_auth_config(s)
+	assert not cfg_path.is_file()
+
+
+def test_auth_config_does_not_regenerate_missing_password(tmp_path: Path) -> None:
+	from pygitweb.auth_config import init_auth_config, write_auth_config
+	from pygitweb.config import Settings
+
+	cfg_path = tmp_path / "auth.json"
+	write_auth_config(
+		cfg_path,
+		AuthConfig(auth_mode="local", local_users=[LocalUser(user="admin", password="")]),
+	)
+	s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
+	cfg = init_auth_config(s)
+	assert cfg.local_users[0].password == ""
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="Unix file modes")
+def test_auth_config_rejects_insecure_permissions(tmp_path: Path) -> None:
+	from pygitweb.auth_config import AuthConfigPermissionError, init_auth_config, write_auth_config
+	from pygitweb.config import Settings
+
+	cfg_path = tmp_path / "auth.json"
+	write_auth_config(
+		cfg_path,
+		AuthConfig(auth_mode="local", local_users=[LocalUser(user="admin", password="secret")]),
+	)
+	os.chmod(cfg_path, 0o644)
+	s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
+	with pytest.raises(AuthConfigPermissionError, match="0600"):
+		init_auth_config(s)
diff --git a/pygitweb/config.py b/pygitweb/config.py
index ca855d2..53cc9e8 100644
--- a/pygitweb/config.py
+++ b/pygitweb/config.py
@@ -5,7 +5,6 @@ PyGitWeb configuration: env-driven settings via pydantic-settings, constants, an
 from __future__ import annotations
 
 import os
-import secrets
 import sys
 from pathlib import Path
 
@@ -91,11 +90,11 @@ class Settings(BaseSettings):
 	Runtime settings sourced from PYGITWEB_* env vars (and optionally a .env file).
 
 	When AUTH is true, PyGitWeb uses OAuth2 password flow (POST /token), Bearer tokens,
-	and browser login. Use ADMIN_USER / ADMIN_PASSWORD for credentials.
+	and browser login. Local credentials live in the auth JSON file (see PYGITWEB_AUTH_CONFIG).
 
 	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.
+	and a random password is written to the auth config file on first run (see stderr).
+	Set PYGITWEB_AUTH=0 or PYGITWEB_AUTH=1 explicitly as needed.
 	"""
 
 	model_config = SettingsConfigDict(
@@ -118,8 +117,7 @@ class Settings(BaseSettings):
 	MAXLOAD: float | None = None
 
 	AUTH: bool | None = None
-	ADMIN_USER: str = "admin"
-	ADMIN_PASSWORD: str | None = None
+	AUTH_CONFIG: str = ""
 
 	@field_validator("AUTH", mode="before")
 	@classmethod
@@ -152,32 +150,23 @@ 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:
+	if implicit_auth and s.AUTH:
+		cfg_path = Path(s.AUTH_CONFIG).expanduser() if s.AUTH_CONFIG else Path.home() / ".pygitweb" / "auth.json"
 		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",
+			"WARNING: PyGitWeb: PYGITWEB_AUTH was not set; authentication defaults to ON.\n"
+			"         Set PYGITWEB_AUTH=0 to disable, or PYGITWEB_AUTH=1 and configure "
+			f"{cfg_path}.\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)
 
+from pygitweb.auth_config import init_auth_config  # noqa: E402
+
+auth_config = init_auth_config(settings)
+
 
 def get_loadavg() -> float:
 	"""First element of load average, or 0 if unavailable. Port of get_loadavg."""
diff --git a/pygitweb/conftest.py b/pygitweb/conftest.py
index 1d5d928..71c6ab2 100644
--- a/pygitweb/conftest.py
+++ b/pygitweb/conftest.py
@@ -4,10 +4,13 @@ from __future__ import annotations
 
 import asyncio
 import os
+from pathlib import Path
 
 import pytest
 
+_test_auth_config = Path(os.environ.get("TEMP", "/tmp")) / "pygitweb-test-auth.json"
 os.environ.setdefault("PYGITWEB_AUTH", "0")
+os.environ.setdefault("PYGITWEB_AUTH_CONFIG", str(_test_auth_config))
 
 
 @pytest.fixture(autouse=True)
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 1bb7a80..bb1f87a 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -59,9 +59,9 @@ from pygitweb.auth import (
 	auth_router,
 	decode_access_token,
 	ensure_active_user_if_auth_enabled,
-	get_auth_credentials,
 	require_active_user_if_auth_enabled,
 )
+from pygitweb.auth_config import auth_config, is_auth_configured
 from pygitweb.change_queue import CHANGE_QUEUE
 from pygitweb.config import ACTIONS, get_loadavg, settings
 from pygitweb.dependencies import (
@@ -451,7 +451,7 @@ def addproject_page(
 ):
 	"""Add project form page."""
 	show_sign_in_notice = (
-		settings.AUTH and get_auth_credentials() is not None and (not token or decode_access_token(token) is None)
+		settings.AUTH and is_auth_configured(auth_config) and (not token or decode_access_token(token) is None)
 	)
 	pre = PREAMBLE.render(
 		title=f"{settings.SITE_NAME} - Add Project",
