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 return new_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_session cookie 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_KEY changes 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"]]