diff --git a/pygitweb/auth.py b/pygitweb/auth.py
index 4ea4d4d..e5dd19c 100644
--- a/pygitweb/auth.py
+++ b/pygitweb/auth.py
@@ -31,6 +31,7 @@ from pygitweb.auth_config import (
 from pygitweb.auth_oauth import OAUTH_STATE_COOKIE_NAME, oauth_router
 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.sessions import create_session, get_session, revoke_session
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
@@ -128,30 +129,31 @@ def require_permission(
 	return _dep
 
 
-class UserInDB(User):
-	password: str
-
-
-def get_local_user(username: str) -> UserInDB | None:
+def get_local_user(username: str) -> User | None:
 	if not settings.AUTH or not is_local_auth_available(auth_config):
 		return None
 	for entry in auth_config.local_users:
-		if not entry.password:
+		if not entry.pass_hash and not entry.password:
 			continue
 		if hmac.compare_digest(entry.user, username):
-			return UserInDB(username=entry.user, password=entry.password, email=entry.email)
+			return User(username=entry.user, email=entry.email)
 	return None
 
 
 def authenticate_local_user(username: str, password: str) -> User | None:
 	if auth_config.auth_mode not in ("local", "both"):
 		return None
-	user = get_local_user(username)
-	if user is None:
-		return None
-	if not hmac.compare_digest(user.password, password):
+	for entry in auth_config.local_users:
+		if not hmac.compare_digest(entry.user, username):
+			continue
+		if entry.pass_hash:
+			if not verify_password(password, entry.pass_hash, config_salt=auth_config.salt):
+				return None
+			return User(username=entry.user, email=entry.email)
+		if entry.password is not None and hmac.compare_digest(entry.password, password):
+			return User(username=entry.user, email=entry.email)
 		return None
-	return User(username=user.username, email=user.email)
+	return None
 
 
 def decode_access_token(token: str) -> User | None:
diff --git a/pygitweb/auth.schema.json b/pygitweb/auth.schema.json
index d0ead41..28fd860 100644
--- a/pygitweb/auth.schema.json
+++ b/pygitweb/auth.schema.json
@@ -3,6 +3,11 @@
     "description": "",
     "type": "object",
     "properties": {
+      "salt": {
+        "type": "string",
+        "pattern": "^[0-9a-fA-F]{16}$",
+        "description": "16-character hex salt applied to local password hashes."
+      },
       "auth_mode": {
         "type": "string",
         "pattern": "^(oauth|local|both)$"
@@ -64,8 +69,11 @@
         "minItems": 1,
         "items": {
           "required": [
-            "user",
-            "pass"
+            "user"
+          ],
+          "oneOf": [
+            { "required": ["pass_hash"] },
+            { "required": ["pass"] }
           ],
           "properties": {
             "user": {
@@ -74,7 +82,13 @@
             },
             "pass": {
               "type": "string",
-              "minLength": 8
+              "minLength": 8,
+              "description": "Plaintext password; hashed to pass_hash on first server start, then removed."
+            },
+            "pass_hash": {
+              "type": "string",
+              "minLength": 1,
+              "description": "scrypt password hash (written by the server)."
             },
             "email": {
               "type": "string",
@@ -86,6 +100,7 @@
       }
     },
     "required": [
-      "auth_mode"
+      "auth_mode",
+      "salt"
     ]
   }
\ No newline at end of file
diff --git a/pygitweb/auth_config.py b/pygitweb/auth_config.py
index befb7eb..edcc85b 100644
--- a/pygitweb/auth_config.py
+++ b/pygitweb/auth_config.py
@@ -17,6 +17,7 @@ from typing import Literal
 from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
 
 from pygitweb.config import Settings
+from pygitweb.password_hash import hash_password
 from pygitweb.permissions import PermissionsMap
 
 DEFAULT_AUTH_CONFIG_PATH = Path.home() / ".pygitweb" / "auth.json"
@@ -55,12 +56,19 @@ AuthMode = Literal["oauth", "local", "both"]
 OAuthProvider = Literal["google", "github"]
 OAuthUsernameFrom = Literal["sub", "email", "preffered_username", "login", "name"]
 
+_AUTH_SALT_HEX_LEN = 16
+
+
+def generate_auth_salt() -> str:
+	return secrets.token_hex(_AUTH_SALT_HEX_LEN // 2)
+
 
 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")
+	password: str | None = Field(default=None, alias="pass", serialization_alias="pass")
+	pass_hash: str = ""
 	email: str | None = None
 
 	@field_validator("email", mode="before")
@@ -77,6 +85,7 @@ class LocalUser(BaseModel):
 class AuthConfig(BaseModel):
 	model_config = ConfigDict(populate_by_name=True)
 
+	salt: str = ""
 	auth_mode: AuthMode = "local"
 	oauth_provider: OAuthProvider | str = ""
 	oauth_client_id: str = ""
@@ -87,6 +96,16 @@ class AuthConfig(BaseModel):
 	oauth_permissions: PermissionsMap = Field(default_factory=PermissionsMap.empty)
 	local_users: list[LocalUser] = Field(default_factory=list)
 
+	@field_validator("salt", mode="before")
+	@classmethod
+	def _validate_salt(cls, value: object) -> str:
+		if value is None or value == "":
+			return ""
+		salt = str(value).strip().lower()
+		if len(salt) != _AUTH_SALT_HEX_LEN or not all(c in "0123456789abcdef" for c in salt):
+			raise ValueError(f"salt must be a {_AUTH_SALT_HEX_LEN}-character hex string")
+		return salt
+
 	@field_validator("oauth_permissions", mode="before")
 	@classmethod
 	def _validate_oauth_permissions(cls, v: object) -> PermissionsMap | dict[str, list[str]]:
@@ -105,8 +124,9 @@ def auth_config_path(settings: Settings) -> Path:
 	return DEFAULT_AUTH_CONFIG_PATH
 
 
-def default_auth_config(*, admin_password: str) -> AuthConfig:
+def default_auth_config(*, admin_password: str, salt: str) -> AuthConfig:
 	return AuthConfig(
+		salt=salt,
 		auth_mode="local",
 		oauth_provider="",
 		oauth_client_id="",
@@ -115,19 +135,54 @@ def default_auth_config(*, admin_password: str) -> AuthConfig:
 		oauth_username_from="email",
 		oauth_allowed_emails=[],
 		oauth_permissions=PermissionsMap.empty(),
-		local_users=[LocalUser(user="admin", password=admin_password)],
+		local_users=[LocalUser(user="admin", pass_hash=hash_password(admin_password, config_salt=salt))],
 	)
 
 
 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",
+		json.dumps(config.model_dump(by_alias=True, mode="json", exclude_none=True), indent=2) + "\n",
 		encoding="utf-8",
 	)
 	_set_auth_config_mode(path)
 
 
+def _ensure_auth_salt(path: Path, config: AuthConfig) -> AuthConfig:
+	if config.salt:
+		return config
+	salt = generate_auth_salt()
+	updated = config.model_copy(update={"salt": salt})
+	write_auth_config(path, updated)
+	return updated
+
+
+def _migrate_plaintext_passwords(path: Path, config: AuthConfig) -> AuthConfig:
+	updated_users: list[LocalUser] = []
+	changed = False
+	for entry in config.local_users:
+		if not entry.password:
+			updated_users.append(entry)
+			continue
+		updated_users.append(
+			LocalUser(
+				user=entry.user,
+				pass_hash=hash_password(entry.password, config_salt=config.salt),
+				email=entry.email,
+			)
+		)
+		changed = True
+	if not changed:
+		return config
+	migrated = config.model_copy(update={"local_users": updated_users})
+	write_auth_config(path, migrated)
+	print(
+		f"WARNING: hashed plaintext password(s) in {path}.\n",
+		file=sys.stderr,
+	)
+	return migrated
+
+
 def load_auth_config_file(path: Path) -> AuthConfig:
 	return AuthConfig.model_validate_json(path.read_text(encoding="utf-8"))
 
@@ -135,7 +190,7 @@ def load_auth_config_file(path: Path) -> AuthConfig:
 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)
+	return any(u.pass_hash or u.password for u in config.local_users)
 
 
 def is_oauth_auth_available(config: AuthConfig) -> bool:
@@ -164,13 +219,17 @@ def init_auth_config(settings: Settings) -> AuthConfig:
 	if path.is_file():
 		if settings.AUTH:
 			_require_auth_config_mode(path)
-		auth_config = load_auth_config_file(path)
+		auth_config = _migrate_plaintext_passwords(
+			path,
+			_ensure_auth_salt(path, 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)
+	salt = generate_auth_salt()
+	auth_config = default_auth_config(admin_password=password, salt=salt)
 	write_auth_config(path, auth_config)
 	print(
 		f"WARNING: PyGitWeb: created auth config at {path}\n"
diff --git a/pygitweb/auth_test.py b/pygitweb/auth_test.py
index c410562..9a24ae8 100644
--- a/pygitweb/auth_test.py
+++ b/pygitweb/auth_test.py
@@ -1,3 +1,4 @@
+import json
 import os
 import sys
 from collections.abc import Generator
@@ -8,12 +9,13 @@ import pytest
 from fastapi.testclient import TestClient
 
 from pygitweb.auth import ACCESS_TOKEN_COOKIE_NAME
-from pygitweb.auth_config import AuthConfig, LocalUser
+from pygitweb.auth_config import AuthConfig, LocalUser, init_auth_config, write_auth_config
 from pygitweb.auth_oauth import OAUTH_STATE_COOKIE_NAME, _seal_oauth_state, is_oauth_email_allowed
-from pygitweb.config import settings
+from pygitweb.config import Settings, settings
 from pygitweb.conftest import clear_client_cookies
 from pygitweb.gravatar import gravatar_url
 from pygitweb.main import app
+from pygitweb.password_hash import hash_password, verify_password
 from pygitweb.permissions import PERMISSION_ADD_PROJECTS, PermissionsMap
 from pygitweb.sessions import get_session
 
@@ -147,16 +149,20 @@ def test_projectnamevalid_ok_with_bearer_when_auth_enabled(client: TestClient, l
 
 
 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
+	assert len(cfg.salt) == 16
+	assert cfg.local_users[0].pass_hash.startswith("scrypt$")
+	assert cfg.local_users[0].password is None
+	on_disk = json.loads(cfg_path.read_text(encoding="utf-8"))
+	assert len(on_disk["salt"]) == 16
+	user_entry = on_disk["local_users"][0]
+	assert "pass" not in user_entry
+	assert "pass_hash" in user_entry
 	if sys.platform != "win32":
 		assert oct(os.stat(cfg_path).st_mode & 0o777) == "0o600"
 
@@ -172,9 +178,6 @@ def test_auth_config_no_file_when_auth_disabled(tmp_path: Path) -> None:
 
 
 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,
@@ -183,6 +186,42 @@ def test_auth_config_does_not_regenerate_missing_password(tmp_path: Path) -> Non
 	s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
 	cfg = init_auth_config(s)
 	assert cfg.local_users[0].password == ""
+	assert cfg.local_users[0].pass_hash == ""
+
+
+def test_auth_config_adds_salt_to_existing_file(tmp_path: Path) -> None:
+	cfg_path = tmp_path / "auth.json"
+	write_auth_config(
+		cfg_path,
+		AuthConfig(
+			auth_mode="local",
+			local_users=[LocalUser(user="admin", pass_hash=hash_password("secret"))],
+		),
+	)
+	s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
+	cfg = init_auth_config(s)
+	assert len(cfg.salt) == 16
+	assert verify_password("secret", cfg.local_users[0].pass_hash)
+
+
+def test_auth_config_migrates_plaintext_pass_to_pass_hash(tmp_path: Path) -> None:
+	cfg_path = tmp_path / "auth.json"
+	cfg_path.write_text(
+		'{"auth_mode": "local", "local_users": [{"user": "admin", "pass": "secret1234"}]}\n',
+		encoding="utf-8",
+	)
+	if sys.platform != "win32":
+		os.chmod(cfg_path, 0o600)
+	s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
+	cfg = init_auth_config(s)
+	assert cfg.local_users[0].password is None
+	assert verify_password("secret1234", cfg.local_users[0].pass_hash, config_salt=cfg.salt)
+	on_disk = cfg_path.read_text(encoding="utf-8")
+	root = json.loads(on_disk)
+	assert len(root["salt"]) == 16
+	user_entry = root["local_users"][0]
+	assert "pass" not in user_entry
+	assert "pass_hash" in user_entry
 
 
 @pytest.mark.skipif(sys.platform == "win32", reason="Unix file modes")
@@ -334,3 +373,18 @@ def test_authenticate_local_user_disabled_in_oauth_only_mode() -> None:
 
 	with patch("pygitweb.auth.auth_config", _OAUTH_CFG):
 		assert authenticate_local_user("admin", "secret") is None
+
+
+def test_authenticate_local_user_pass_hash() -> None:
+	from pygitweb.auth import authenticate_local_user
+
+	cfg = AuthConfig(
+		auth_mode="local",
+		salt="0123456789abcdef",
+		local_users=[LocalUser(user="admin", pass_hash=hash_password("secret", config_salt="0123456789abcdef"))],
+	)
+	with patch("pygitweb.auth.auth_config", cfg):
+		user = authenticate_local_user("admin", "secret")
+	assert user is not None
+	assert user.username == "admin"
+	assert authenticate_local_user("admin", "wrong") is None
diff --git a/pygitweb/password_hash.py b/pygitweb/password_hash.py
new file mode 100644
index 0000000..f4949b7
--- /dev/null
+++ b/pygitweb/password_hash.py
@@ -0,0 +1,74 @@
+"""Password hashing with stdlib scrypt (Argon2id is not used)."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import hmac
+import secrets
+
+_SCRYPT_N = 2**17
+_SCRYPT_R = 8
+_SCRYPT_P = 1
+_SCRYPT_DKLEN = 64
+_SCRYPT_SALT_BYTES = 16
+_SCRYPT_MAXMEM = 128 * _SCRYPT_N * _SCRYPT_R * (_SCRYPT_P + 2)
+_ALGORITHM = "scrypt"
+
+
+def _password_material(password: str, config_salt: str) -> str:
+	if not config_salt:
+		return password
+	return f"{config_salt}:{password}"
+
+
+def _scrypt_key(password: str, *, salt: bytes, n: int, r: int, p: int, dklen: int) -> bytes:
+	return hashlib.scrypt(
+		password.encode("utf-8"),
+		salt=salt,
+		n=n,
+		r=r,
+		p=p,
+		maxmem=max(128 * n * r * (p + 2), _SCRYPT_MAXMEM),
+		dklen=dklen,
+	)
+
+
+def hash_password(password: str, *, config_salt: str = "") -> str:
+	material = _password_material(password, config_salt)
+	salt = secrets.token_bytes(_SCRYPT_SALT_BYTES)
+	key = _scrypt_key(material, salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_DKLEN)
+	salt_b64 = base64.b64encode(salt).decode("ascii")
+	key_b64 = base64.b64encode(key).decode("ascii")
+	return f"{_ALGORITHM}${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt_b64}${key_b64}"
+
+
+def verify_password(password: str, pass_hash: str, *, config_salt: str = "") -> bool:
+	if not pass_hash:
+		return False
+	if config_salt and _verify_password_material(_password_material(password, config_salt), pass_hash):
+		return True
+	return _verify_password_material(password, pass_hash)
+
+
+def _verify_password_material(material: str, pass_hash: str) -> bool:
+	if not pass_hash:
+		return False
+	parts = pass_hash.split("$")
+	if len(parts) != 6 or parts[0] != _ALGORITHM:
+		return False
+	try:
+		n = int(parts[1])
+		r = int(parts[2])
+		p = int(parts[3])
+		if n < _SCRYPT_N or r < _SCRYPT_R or p < _SCRYPT_P:
+			return False
+		salt = base64.b64decode(parts[4], validate=True)
+		expected = base64.b64decode(parts[5], validate=True)
+	except (ValueError, TypeError):
+		return False
+	try:
+		actual = _scrypt_key(material, salt=salt, n=n, r=r, p=p, dklen=len(expected))
+	except ValueError:
+		return False
+	return hmac.compare_digest(actual, expected)
diff --git a/pygitweb/password_hash_test.py b/pygitweb/password_hash_test.py
new file mode 100644
index 0000000..327b692
--- /dev/null
+++ b/pygitweb/password_hash_test.py
@@ -0,0 +1,20 @@
+from pygitweb.password_hash import hash_password, verify_password
+
+
+def test_hash_and_verify_roundtrip() -> None:
+	stored = hash_password("secret-password", config_salt="a1b2c3d4e5f67890")
+	assert stored.startswith("scrypt$131072$8$1$")
+	assert verify_password("secret-password", stored, config_salt="a1b2c3d4e5f67890")
+	assert not verify_password("wrong", stored, config_salt="a1b2c3d4e5f67890")
+
+
+def test_verify_legacy_hash_without_config_salt() -> None:
+	stored = hash_password("legacy")
+	assert verify_password("legacy", stored, config_salt="0123456789abcdef")
+
+
+def test_verify_rejects_weak_parameters() -> None:
+	stored = hash_password("x")
+	parts = stored.split("$")
+	parts[1] = "65536"
+	assert not verify_password("x", "$".join(parts))
