API Key Rotation
docsfy supports two API key rotation flows:
- Self-service rotation for the currently authenticated user.
- Admin-initiated rotation for any target user.
In the UI, API keys are labeled as Password, but server-side auth and storage use API key semantics.
Note: Login uses
username+api_key, and rotation responses returnnew_api_key.
```163:167:src/docsfy/templates/login.html
## Rotation Paths
| Path | Endpoint | Who can use it | `new_key` behavior | Session effect |
|---|---|---|---|---|
| Self-service | `POST /api/me/rotate-key` | Authenticated DB users (`admin`, `user`, `viewer`) | Optional; omit to auto-generate | All user sessions invalidated; current browser cookie cleared |
| Admin-initiated | `POST /api/admin/users/{username}/rotate-key` | Admin only | Optional; omit to auto-generate | All target user sessions invalidated |
### Self-Service Rotation
```1318:1353:src/docsfy/main.py
@app.post("/api/me/rotate-key")
async def rotate_own_key(request: Request) -> JSONResponse:
"""User rotates their own API key."""
# Don't call _require_write_access -- viewers should be able to change their password
if request.state.is_admin and not request.state.user:
raise HTTPException(
status_code=400,
detail="ADMIN_KEY users cannot rotate keys. Change the ADMIN_KEY env var instead.",
)
body = await request.json()
custom_key = body.get("new_key") # Optional -- if provided, use it
username = request.state.username
try:
new_key = await rotate_user_key(username, custom_key=custom_key)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
logger.info(f"[AUDIT] User '{username}' rotated their own API key")
# Clear current session -- user must re-login with new key
session_token = request.cookies.get("docsfy_session")
if session_token:
await delete_session(session_token)
settings = get_settings()
response = JSONResponse(
content={"username": username, "new_api_key": new_key},
headers={"Cache-Control": "no-store"},
)
response.delete_cookie(
"docsfy_session",
httponly=True,
samesite="strict",
secure=settings.secure_cookies,
)
return response
The dashboard calls this endpoint from the Change Password action:
```2432:2460:src/docsfy/templates/dashboard.html async function rotateOwnKey() { var newKey = await modalPrompt('Change Password', 'Enter new password (min 16 characters), or leave empty to auto-generate:', 'Minimum 16 characters', '', 'password'); if (newKey === null) return; // cancelled
var body = {};
if (newKey.trim()) {
if (newKey.trim().length < 16) {
await modalAlert('Invalid Password', 'Password must be at least 16 characters long.');
return;
}
body.new_key = newKey.trim();
}
try {
var resp = await fetch('/api/me/rotate-key', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'same-origin',
body: JSON.stringify(body),
});
// ...
await modalAlert('Password Changed', 'Your new password (save it now!):\n\n' + data.new_api_key + '\n\nYou will be redirected to login.');
window.location.href="#"
} catch (err) {
await modalAlert('Error', 'Failed: ' + err.message);
}
}
> **Tip:** Leave `new_key` empty to let the server generate a strong random key (`docsfy_...`).
> **Warning:** If you are authenticated via the `ADMIN_KEY` super-admin identity, self-service rotation is blocked. Rotate `ADMIN_KEY` in environment/config instead.
### Admin-Initiated Rotation
```1356:1374:src/docsfy/main.py
@app.post("/api/admin/users/{username}/rotate-key")
async def admin_rotate_key(request: Request, username: str) -> JSONResponse:
"""Admin rotates a user's API key."""
_require_admin(request)
body = await request.json()
custom_key = body.get("new_key")
try:
new_key = await rotate_user_key(username, custom_key=custom_key)
except ValueError as exc:
detail = str(exc)
status = 404 if "not found" in detail else 400
raise HTTPException(status_code=status, detail=detail) from exc
logger.info(
f"[AUDIT] Admin '{request.state.username}' rotated API key for user '{username}'"
)
return JSONResponse(
content={"username": username, "new_api_key": new_key},
headers={"Cache-Control": "no-store"},
)
Admin UI trigger:
```584:602:src/docsfy/templates/admin.html var newKey = await modalPrompt("Change Password", "Enter new password for '" + username + "' (min 16 characters), or leave empty to auto-generate:", "Minimum 16 characters", "", "password"); if (newKey === null) return;
var body = {}; if (newKey.trim()) { if (newKey.trim().length < 16) { showAlert('error', 'Password must be at least 16 characters long.'); return; } body.new_key = newKey.trim(); }
fetch('/api/admin/users/' + encodeURIComponent(username) + '/rotate-key', { method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', redirect: 'error', body: JSON.stringify(body), })
## Validation Rules
Server-side key validation is intentionally minimal and explicit:
```19:29:src/docsfy/storage.py
MIN_KEY_LENGTH = 16
def validate_api_key(key: str) -> None:
"""Validate API key meets minimum requirements."""
if len(key) < MIN_KEY_LENGTH:
msg = f"API key must be at least {MIN_KEY_LENGTH} characters long"
raise ValueError(msg)
Startup validation for ADMIN_KEY:
```83:89:src/docsfy/main.py if not settings.admin_key: logger.error("ADMIN_KEY environment variable is required") raise SystemExit(1)
if len(settings.admin_key) < 16: logger.error("ADMIN_KEY must be at least 16 characters long") raise SystemExit(1)
What this means in practice:
- `new_key` is optional.
- If provided, it must be **at least 16 characters**.
- There is no additional server-side complexity/character-class validation.
- Admin rotation for a missing user returns `404`.
## Session Invalidation Behavior
Key rotation invalidates sessions in storage, then self-service rotation also clears the current browser cookie.
```724:743:src/docsfy/storage.py
async def rotate_user_key(username: str, custom_key: str | None = None) -> str:
"""Generate or set a new API key for a user. Returns the raw new key."""
if custom_key:
validate_api_key(custom_key)
raw_key = custom_key
else:
raw_key = generate_api_key()
key_hash = hash_api_key(raw_key)
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute(
"UPDATE users SET api_key_hash = ? WHERE username = ?",
(key_hash, username),
)
if cursor.rowcount == 0:
msg = f"User '{username}' not found"
raise ValueError(msg)
# Invalidate all existing sessions for this user
await db.execute("DELETE FROM sessions WHERE username = ?", (username,))
await db.commit()
return raw_key
Session and cookie settings relevant to post-rotation re-authentication:
```21:22:src/docsfy/storage.py SESSION_TTL_SECONDS = 28800 # 8 hours SESSION_TTL_HOURS = SESSION_TTL_SECONDS // 3600
```297:304:src/docsfy/main.py
response.set_cookie(
"docsfy_session",
session_token,
httponly=True,
samesite="strict",
secure=settings.secure_cookies,
max_age=SESSION_TTL_SECONDS,
)
Outcome summary:
- Old API key stops authenticating immediately.
- Existing sessions for that user are removed from the database.
- Self-service rotation removes the current
docsfy_sessioncookie and forces re-login. - Admin-initiated rotation logs out the target user(s), not the acting admin.
Configuration
```1:2:.env.example
REQUIRED - Admin key for user management (minimum 16 characters)
ADMIN_KEY=your-secure-admin-key-here-min-16-chars
```27:28:.env.example
# Set to false for local HTTP development
# SECURE_COOKIES=false
```16:23:src/docsfy/config.py admin_key: str = "" # Required — validated at startup ai_provider: str = "claude" ai_model: str = "claude-opus-4-6[1m]" # [1m] = 1 million token context window ai_cli_timeout: int = Field(default=60, gt=0) log_level: str = "INFO" data_dir: str = "/data" secure_cookies: bool = True # Set to False for local HTTP dev
`docsfy` stores only HMAC hashes of API keys, not raw keys:
```588:601:src/docsfy/storage.py
def hash_api_key(key: str, hmac_secret: str = "") -> str:
"""Hash an API key with HMAC-SHA256 for storage.
Uses ADMIN_KEY as the HMAC secret so that even if the source is read,
keys cannot be cracked without the environment secret.
"""
# NOTE: ADMIN_KEY is used as the HMAC secret. Rotating ADMIN_KEY will
# invalidate all existing api_key_hash values, requiring all users to
# regenerate their API keys.
secret = hmac_secret or os.getenv("ADMIN_KEY", "")
Warning: Rotating
ADMIN_KEYchanges the HMAC secret and invalidates all stored user key hashes. Plan a coordinated user key re-issuance.
Verified Test Coverage
Rotation behavior is covered by automated tests:
```709:745:tests/test_auth.py async def test_user_rotates_own_key(_init_db: None) -> None: """A user can rotate their own API key, invalidating the old one.""" # ... resp = await ac.post( "/api/me/rotate-key", cookies={"docsfy_session": cookie}, json={}, ) assert resp.status_code == 200 data = resp.json() assert "new_api_key" in data assert data["new_api_key"] != key
# Old key should no longer work for login
resp = await ac.post(
"/login",
data={"username": "rotatetest", "api_key": key},
follow_redirects=False,
)
assert resp.status_code != 302 # login should fail
```874:898:tests/test_auth.py
async def test_reject_short_custom_key(_init_db: None) -> None:
"""A custom key shorter than 16 characters should be rejected."""
# ...
resp = await ac.post(
"/api/me/rotate-key",
cookies={"docsfy_session": cookie},
json={"new_key": "short"},
)
assert resp.status_code == 400
assert "16 characters" in resp.json()["detail"]
```770:775:tests/test_auth.py async def test_admin_rotates_nonexistent_user_key( admin_client: AsyncClient, ) -> None: """Admin rotating key for a non-existent user should return 404.""" resp = await admin_client.post("/api/admin/users/no-such-user/rotate-key", json={}) assert resp.status_code == 404
Repository test runner config:
```5:7:tox.toml
[env.unittests]
deps = ["uv"]
commands = [["uv", "run", "--extra", "dev", "pytest", "-n", "auto", "tests"]]