Skip to content

Authentication

Patrimoine supports a dual authentication system: OIDC (RS256 via JWKS) for federated identity and local auth (HS256 JWT) for self-hosted accounts. The iss claim in the JWT determines which validation path is used. OIDC is only active when OIDC_ENABLED=true and OIDC_CLIENT_ID is set; otherwise only local auth is offered (GET /auth/config reports oidc_enabled).

The get_current_user dependency (backend/app/dependencies.py) resolves the authenticated user in the following order:

flowchart TD accTitle: Authentication method selection accDescr: The backend checks the Authorization header for an API token, then the access_token cookie. The token issuer selects the local HS256 path or the OIDC RS256 path, a refresh_token cookie is the fallback, and the E2E bypass is the last resort. All valid paths end in an authenticated user. Start["Authorization header / access_token cookie"] --> A{"Bearer ptr_... ?"} A -- yes --> API["validate_api_token (SHA-256 lookup in api_tokens)"] A -- no --> B{"access_token cookie present?"} B -- no --> F{"refresh_token cookie present?"} F -- no --> E2E F -- yes --> G["peek_iss on refresh_token itself"] G --> Local G --> OIDC B -- yes --> C["peek_iss (decode without verifying signature)"] C --> D{"iss == 'patrimoine' ?"} D -- yes --> Local["Local HS256 path: validate_local_token<br/>(refresh via refresh_token cookie on expiry)"] D -- no --> OIDC["OIDC RS256 path: validate_token via JWKS<br/>(refresh via token_endpoint on expiry, with rotation)"] Local --> User["Authenticated user"] OIDC --> User API --> User E2E["E2E bypass: e2e_test_mode + e2e_bypass_token match"] --> User
  1. API token — If the Authorization header starts with Bearer ptr_, the token is passed to validate_api_token which SHA-256 hashes it and looks up the hash in the api_tokens table. API tokens are CSRF-exempt (no cookie involved). Updates last_used_at on success.

  2. Access token cookie (JWT) — If no API token, the access_token cookie is read. peek_iss decodes the JWT without signature verification to read the iss claim:

    • iss == "patrimoine" → Local HS256 path: validate_local_token verifies signature with JWT_SECRET, checks iss/exp/sub/type claims. On ExpiredSignatureError, attempts refresh via the refresh_token cookie using validate_refresh_token + create_access_token.
    • Otherwise → OIDC RS256 path: validate_token (async) fetches the signing key from JWKS ({oidc_issuer}/jwks/) via an async httpx call (non-blocking), verifies signature (RS256), iss, aud (=oidc_client_id), and exp.
      • On ExpiredSignatureError, attempts refresh via the OIDC token_endpoint using the refresh_token cookie. If the provider returns a new refresh_token (token rotation), the refresh_token cookie is updated accordingly.
      • If the OIDC provider itself is unreachable or erroring (e.g. the JWKS endpoint returns 503), httpx.HTTPError is caught and the request is rejected with 401 Not authenticated rather than an unhandled 500.
  3. Access token cookie absent, refresh token cookie present — The access_token cookie’s own max_age matches its short JWT lifetime (see cookies-and-csrf.md), so the browser deletes it entirely once it expires — this is the ordinary case for a returning user, not an edge case.

    • There is no access token to peek_iss on here, so the same peek runs on the refresh_token cookie instead:
      • A local refresh token is a JWT with iss == "patrimoine", so it routes to the local refresh flow.
      • A non-matching or non-JWT value (an OIDC refresh token is Authentik’s opaque, non-JWT string) routes to the OIDC refresh flow.
    • Both flows are otherwise identical to the ExpiredSignatureError refresh branches above — same validation, same rotation, same cookies set.
    • Only when neither cookie is present does the request fall through to the E2E bypass check and then 401 Not authenticated.
  4. E2E bypass — If e2e_test_mode is true and the token matches e2e_bypass_token, a test user is upserted. The bypass token is generated at startup via secrets.token_urlsafe(32) if not set — never a hardcoded string.

Once a user is resolved on any of the four paths above, two additional checks run before the request proceeds:

  • _reject_if_inactive rejects the request (403) if is_active is false.
  • _maybe_promote_admin promotes the user to admin if their sub/email matches ADMIN_EMAILS/ADMIN_SUBS and they aren’t already admin (one-way — never demotes). Local accounts never match: _matches_configured_admin returns False for auth_provider == "local".

Rejection runs first, so a disabled account listed in ADMIN_EMAILS is never promoted on a request that gets rejected anyway. See Admin Role below.

Every JWT includes a jti (JWT ID) claim. auth_service.revoke_token decodes the token (without signature verification — it was already validated) and inserts the jti into the revoked_tokens table. It’s called on:

  • logout (routers/auth.py)
  • local-auth refresh-token rotation (dependencies.py — see Token Refresh below)

It lives in the service layer rather than the router specifically so dependencies.py can call it without a circular import.

_check_revocation is called on every JWT validation path to reject revoked tokens.

The insert uses a savepoint (begin_nested) with IntegrityError catch. This ensures that a duplicate jti (concurrent logout) only rolls back the failed INSERT, not the entire session. This is critical because logout revokes both access and refresh tokens sequentially — a full rollback() would undo the first revocation if the second fails.

Expired revocation rows are purged once a day by the revoked-token-purge background task (the expires_at column is indexed).

Every local cookie sign-in (POST /auth/login, registration, invite redemption) opens a tracked browser session: start_local_session (routers/auth.py) inserts a user_sessions row and mints the access and refresh tokens with its sid claim. The sid is a random secrets.token_urlsafe(16) value. Sign-ins that carry no browser cookie have no row and no sid: API tokens (ptr_..., including the one POST /auth/login/token returns to the mobile app), OIDC sessions (the identity provider owns them) and tokens minted before sessions were tracked.

  • One query per validation — _check_revocation runs a single SELECT that answers both questions: an EXISTS on the jti in revoked_tokens, and scalar subqueries on the token’s user_sessions row. A token whose sid has no row, whose row belongs to another user than the token’s uid, or whose row is revoked or past expires_at, is rejected with 401 session_invalid. A token without sid is only checked against its jti. The check runs for access tokens and for the refresh token on rotation, so an ended session cannot refresh.
  • last_seen_at is written only at rotation — touch_session runs inside the refresh-and-rotate flow, which is already exempt from the GET-write guard. An ordinary request never writes, so the “last used” time can be up to one access-token lifetime (30 minutes by default) behind.
  • Rows are committed before the response — start_local_session calls commit() itself instead of leaving it to the get_db exit code, which FastAPI runs after the response has been sent. Without it the browser’s next request could carry a sid whose row is not visible yet and be refused.
  • What is stored — expires_at is the sign-in time plus MAX_SESSION_DAYS. user_agent holds a coarse “Browser on System” label built by ua_label, and ip_network the client address truncated to its /24 (IPv4) or /48 (IPv6) network. The raw User-Agent header and the full address never reach the database, a log line or an audit entry.
  • Sessions from before tracking — a token without sid cannot be tied to a row. “Sign out everywhere else” stamps users.legacy_sessions_revoked_at, and _reject_if_token_not_bound then refuses any sid-less local token whose auth_time is at or before that moment (access and refresh alike, with no extra query: the user row is already loaded). GET /api/v1/auth/sessions shows these tokens as one synthetic entry with id 0 and is_legacy: true, for local users only and only while such tokens can still be alive. That entry cannot be revoked on its own.
  • Revoking never touches API tokens — GET /api/v1/auth/sessions (paginated, X-Total-Count), DELETE /api/v1/auth/sessions/{id} and POST /api/v1/auth/sessions/revoke-others (routers/sessions.py, services/session_service.py) only set revoked_at (and, for revoke-others, the legacy cutoff). They neither delete API tokens nor bump token_version. Unknown, foreign, revoked or expired ids answer 404 session_not_found, the caller’s own session 400 session_is_current. Each action is audited (user.session_revoked, user.sessions_revoked_others). A caller on a pre-tracking cookie is retired with the rest, so revoke-others re-issues it a tracked session in the same response. A ptr_ token may call these endpoints: it has full account scope.
  • Credential changes still end everything — invalidate_user_sessions (password change and reset, TOTP enable and disable, an admin MFA reset) bumps token_version, deletes every API token and revokes every session row. A caller authenticated by a ptr_ token keeps that one token for password change and TOTP enable and disable (keep_api_token; API tokens are validated by hash, independently of token_version), while every other token and session still ends; password reset and admin actions end everything. An email change (user or admin) changes sub, which ends the cookie sessions without a token_version bump, so it revokes every row explicitly and leaves API tokens alone. Logout revokes the row of the session that signed out. Deleting the account deletes its rows.
  • Cleanup — a daily background task (run_session_purge in app/main.py) calls purge_expired_sessions, which deletes rows whose expires_at is more than 30 days in the past, revoked or not.

The OIDC Authorization Code + PKCE flow is implemented in backend/app/routers/auth.py and backend/app/utils/oidc_client.py:

sequenceDiagram accTitle: OIDC login flow with PKCE accDescr: The browser starts login at the backend, which generates a PKCE verifier and state and redirects to the provider. After the user authenticates, the provider redirects back to the callback, the backend verifies the state, exchanges the code for tokens, fetches user info, upserts the user and sets the session cookies. participant Browser participant Backend participant OIDC as OIDC Provider Browser->>Backend: GET /auth/oidc/start Backend->>Backend: generate code_verifier + code_challenge (S256), state Backend-->>Browser: Set-Cookie pkce_verifier, oauth_state#59; redirect to authorization_endpoint Browser->>OIDC: authorize request (code_challenge, state) OIDC->>Browser: user authenticates OIDC-->>Browser: redirect GET /auth/callback?code=...&state=... Browser->>Backend: GET /auth/callback Backend->>Backend: verify state == oauth_state cookie Backend->>OIDC: exchange code for tokens (code_verifier) OIDC-->>Backend: access_token, refresh_token, id_token Backend->>OIDC: fetch userinfo OIDC-->>Backend: claims (sub, email, name) Backend->>Backend: upsert_from_claims Backend-->>Browser: Set-Cookie access_token, refresh_token#59; delete PKCE/state cookies
  1. GET /auth/oidc/start — Generates a PKCE code_verifier (32 random bytes → base64url) and code_challenge (S256: BASE64URL(SHA256(code_verifier))). A random state is generated via secrets.token_urlsafe(32). Both are set as cookies (pkce_verifier, oauth_state) with 5-minute TTL, HttpOnly, SameSite=Lax. The user is redirected to the OIDC provider’s authorization_endpoint.

  2. GET /auth/callback

    • Verifies state matches the oauth_state cookie.
    • Exchanges the authorization code for tokens using the code_verifier cookie.
    • Fetches userinfo.
    • Validates that the sub claim is non-empty (400 if missing).
    • Upserts the user via upsert_from_claims (only updates email/name if the OIDC claims are non-None, preserving user-edited profile fields).
    • Stores the provider’s id_token in an HttpOnly id_token cookie, only to send it as id_token_hint on logout; it is deleted at logout.
    • Sets the access_token cookie with max_age capped to the token’s own exp (oidc_access_cookie_max_age) and the refresh_token cookie with max_age matching refresh_token_expire_days — the access token cookie deliberately cannot outlive the refresh token cookie (see cookies-and-csrf.md).
    • If the OIDC provider does not return a refresh_token, a warning is logged — sessions will expire when the access token expires.
    • When the OIDC provider returns an empty refresh_token, the cookie is not set (preventing an empty/"""" cookie value). A warning is logged.
    • Enable DEBUG=true in backend/container/.env to see diagnostic logging of the token response keys and refresh_token presence/length.
    • Deletes the OIDC flow cookies.
  3. Discovery — The discovery document is fetched on demand (fetch_discovery_document) with the shared client and a bounded retry with exponential backoff; it is not cached. Only the JWKS is cached (1 hour).

  4. Shared HTTP client — All OIDC HTTP calls (discovery, token exchange, userinfo, refresh, JWKS) use a shared httpx.AsyncClient with connection pooling, created lazily via get_oidc_client() and closed on application shutdown via close_oidc_client().

  5. JWKS validation — JWKS keys are fetched asynchronously (non-blocking) via get_signing_key(), cached in memory with a 1-hour TTL, and protected by an asyncio.Lock to prevent duplicate fetches on concurrent requests.

  6. Timeouts — All OIDC HTTP calls use httpx.Timeout(10.0, connect=5.0).

  7. Cookie attributes — OIDC flow cookies use oidc_cookie_kwargs: {secure: is_https(), samesite: "lax", path: "/"}.

    • Session cookies use auth_cookie_kwargs for their shared attributes. max_age differs per cookie:
      • refresh_token gets auth_cookie_max_age() (refresh_token_expire_days * 86400, 7 days default).
      • access_token gets its own shorter local_access_cookie_max_age()/oidc_access_cookie_max_age() so it can never outlive the refresh cookie.
    • See cookies-and-csrf.md for details.

Both local and OIDC auth paths support transparent token refresh. The backend uses the refresh_token cookie to obtain a new access token without user interaction whenever the current access_token isn’t usable:

  • It’s still present but its exp claim has passed (ExpiredSignatureError).
  • The cookie is gone entirely.

The second case is the everyday “remember me” scenario, not a fallback for a rare timing window: the access_token cookie’s own max_age matches its short JWT lifetime (see cookies-and-csrf.md), so the browser deletes it well before the refresh_token cookie expires.

Both triggers run the identical refresh-and-rotate flow described below — get_current_user just reaches it by a different route (peek_iss on the refresh_token itself instead of on an access token that no longer exists) when the access_token cookie is absent, per the priority chain above.

  1. validate_local_token raises ExpiredSignatureError (access token present, expired) — or the access_token cookie is absent entirely and peek_iss(refresh_token) == "patrimoine".
  2. The refresh_token cookie is validated via validate_refresh_token (checks signature, iss, exp, type == "refresh"), then checked against revoked_tokens.
    • Local tokens also carry tv (compared with users.token_version, bumped by password reset/change and TOTP enable/disable to end every session), uid (must equal users.id, so a re-registered email cannot reuse an old token) and auth_time (original sign-in time, carried unchanged through rotation) and, for cookie sign-ins, sid (the id of the session row, carried unchanged through rotation; see Sessions). A refresh older than MAX_SESSION_DAYS after auth_time is rejected. Tokens issued before these claims existed are treated as tv 0 and unbound, and keep working until they expire.
  3. A new access token is issued via create_access_token(sub) and set as a cookie.
  4. Refresh token rotation — a new refresh token is minted via create_refresh_token(sub), the old one is revoked via auth_service.revoke_token (added to revoked_tokens), and the new one replaces the refresh_token cookie.
    • This mirrors the OIDC path below: without rotation, a stolen local refresh cookie stayed valid for its full 7-day lifetime even after a legitimate refresh consumed it.
    • This write happens on the first request of any route once the access token cookie is expired or absent, not a specific path, so it’s exempted from the GET-write guard the same way _maybe_promote_admin’s write is (see app/database.py’s get_write_exempt session flag).
  1. validate_token raises ExpiredSignatureError (access token present, expired) — or the access_token cookie is absent entirely and peek_iss(refresh_token) doesn’t match "patrimoine" (Authentik’s refresh tokens are opaque, non-JWT strings, so this is the normal case for OIDC).
  2. The refresh_token cookie is sent to the OIDC provider’s token_endpoint via refresh_access_token.
  3. The new access_token from the response is validated and set as a cookie.
  4. Refresh token rotation — If the OIDC provider returns a new refresh_token in the refresh response (as Authentik does by default), the refresh_token cookie is updated with the new value. This is critical because many OIDC providers invalidate the old refresh token after use. Without rotation, the second refresh attempt would fail and the user would be logged out.

If the OIDC provider does not return a refresh_token during the initial token exchange (e.g., because the offline_access scope is not granted), a warning is logged in the callback handler. The user can still authenticate, but sessions will expire when the access token expires (typically 5–15 minutes for OIDC providers). To fix this, ensure the OIDC provider grants the offline_access scope to the application. See Debugging for details.

Local authentication uses HS256 JWTs signed with JWT_SECRET:

  • Registration (POST /auth/register) — Only available when REGISTRATION_ENABLED=true (defaults to false). Passwords hashed with argon2 via pwdlib (PasswordHash.recommended()). Creates access + refresh tokens immediately.
  • Login (POST /auth/login)
    • Checks locked_until first (see Login Lockout below), then verifies email/password against password_hash, then checks is_active (disabled accounts get 403 account_disabled — checked after password verification, not before, so a wrong-password guess against a disabled account’s email still gets the generic invalid_credentials response rather than leaking account status).
    • Sets access + refresh cookies.
    • POST /auth/login/token (mobile) and POST /auth/mobile/exchange (OIDC PKCE mobile) apply the same is_active check before issuing a token.
  • Login lockout
    • authenticate_local_user tracks failed_login_attempts and locked_until on User.
    • A wrong password or a wrong TOTP code both count as a guess; totp_required (the normal first half of a two-step login) does not.
    • Failed guesses are counted per (account, client IP) in memory (app/utils/login_throttle.py): after LOGIN_MAX_ATTEMPTS (default 5) from one IP, that IP is locked out of the account for LOGIN_LOCKOUT_MINUTES (default 15), doubling with each further failure up to LOGIN_LOCKOUT_MAX_MINUTES (default 240). A success from that IP clears its counter.
    • failed_login_attempts sums failures from every IP; reaching LOGIN_ACCOUNT_MAX_ATTEMPTS (default 25) sets locked_until for everyone, with the same doubling backoff. A success resets it.
    • A locked account gets the exact same invalid_credentials response as a wrong password — lockout state is never distinguishable from an ordinary failed login, matching the no-enumeration design used for password-reset requests below.
    • This supplements the IP-based /auth/* rate limit (10 req/60s) with a per-account backoff that an IP-rotating attacker can’t route around.
  • Password change (POST /api/v1/auth/change-password) — Requires current password confirmation. Available only for auth_provider == "local" accounts.
  • Token structure — iss: "patrimoine", sub: "local:{email}", type: "access" or "refresh", jti: secrets.token_urlsafe(16), exp based on access_token_expire_minutes (30min) or refresh_token_expire_days (7days).

Every code-accepting path — login (POST /auth/login, POST /auth/login/token, which share authenticate_local_user) and TOTP setup verification (POST /api/v1/auth/totp/verify) — records the RFC 6238 time step (users.totp_last_step) of the code it just accepted:

  • A code from the same or an earlier time step is rejected with the same totp_invalid 401 as a wrong code, so a replayed or intercepted code is never distinguishable from a guess, and it counts toward the same per-account lockout.
  • The +/-1 time-step drift window is unchanged — the next step’s code still succeeds; only a non-increasing step is rejected.
  • The check-and-set is a single conditional UPDATE ... WHERE totp_last_step IS NULL OR totp_last_step < :step, so two concurrent requests with the same code cannot both succeed (no read-then-write race window).
  • The recorded step is reset to NULL whenever the secret itself changes: starting a new enrolment (POST /api/v1/auth/totp/setup), a self-service disable (POST /api/v1/auth/totp/disable), and an admin-initiated MFA disable (POST /api/v1/admin/users/{user_id}/mfa/disable) all clear it, so a re-enrolled user’s first code is never blocked by a stale step from a previous secret.

A local user who enables TOTP gets ten one-time recovery codes (app/services/recovery_code_service.py), so losing the authenticator does not lock them out.

  • Format — XXXXX-XXXXX-XXXXX: fifteen symbols from the Crockford base32 alphabet (0-9 and A-Z without I, L, O and U), drawn with secrets.choice. The first group is a lookup id, stored in plain and unique per user (uq_totp_recovery_codes_user_lookup). The other ten symbols are the secret (50 bits), stored only as an Argon2 hash made by app/utils/password.py. No code is ever stored in plain, logged, audited or emailed, and none is included in a backup.
  • Normalisation — whitespace and hyphens are dropped, letters are upper-cased, and O, I and L are read as 0, 1 and 1, so a hand-copied code still matches.
  • Routing — _verify_totp_code_or_raise sends exactly six ASCII digits to the TOTP path and any other value to the recovery path, which never decrypts the TOTP secret and never touches totp_last_step.
  • One verify per attempt — consume_recovery_code does one indexed lookup on (user, lookup id, unused) and exactly one Argon2 verify. When the code is malformed or no unused row matches, it verifies a dummy hash with the real parameters instead, so a wrong, unknown, spent or malformed code costs the same. Every failure is the same 401 totp_invalid as a wrong TOTP code and counts toward the shared per-account lockout (the failed attempt is audited with the reason invalid_recovery_code).
  • Atomic single use — a conditional UPDATE ... WHERE id = :id AND used_at IS NULL sets used_at, so of two racing sign-ins with the same code only one succeeds.
  • Lifecycle
    • POST /api/v1/auth/totp/verify enables TOTP and returns the first set in the 200 response (Cache-Control: no-store).
    • POST /api/v1/auth/totp/recovery-codes replaces the whole set after a password check (and nothing else, so a user who has just signed in with a recovery code and has no authenticator can still renew the set). It takes a FOR UPDATE lock on the user row, so two concurrent calls leave exactly one set.
    • GET /api/v1/auth/totp/recovery-status returns {remaining, total}.
    • Starting a new enrolment, disabling TOTP (user or admin) and deleting the account delete the codes: codes never outlive the secret they back.
  • Audit and notices — user.recovery_codes_generated (with via: totp_enable or regenerate) and user.recovery_code_used (with the remaining count). When SMTP is configured, each use and each regeneration also sends the user an email in their language, with a count and a UTC time but never a code.

JWT_SECRET must be at least 32 characters (enforced at startup — app/main.py’s lifespan raises RuntimeError otherwise). Generate one with:

Terminal window
python -c "import secrets; print(secrets.token_urlsafe(48))"

When to rotate — the secret is compromised or suspected leaked, or as part of a routine credential-rotation policy.

What the secret protects — three things, all tied to local auth:

  1. Signs and verifies every local-auth access/refresh JWT through a key derived from JWT_SECRET with HKDF (app/utils/local_jwt.py; new tokens carry kid: v2).
  2. Derives, through HKDF with a TOTP-specific context, the Fernet key that encrypts every stored TOTP secret at rest (app/utils/totp_crypto.py).
  3. Derives, through HKDF with a webhook-specific context, the Fernet key that encrypts every stored webhook signing secret at rest (encrypt_webhook_secret in app/utils/totp_crypto.py).

What rotation breaks:

  • All local-auth sessions end immediately. Every existing access/refresh JWT was signed with the old secret and fails signature verification against the new one — every local user is logged out and must sign in again.
  • Every TOTP-enabled local user is stranded at the TOTP step.
    • Their stored totp_secret was encrypted with a Fernet key derived from the old JWT_SECRET; decrypting it with the new secret fails.
    • Signing in with a six-digit code fails with 503 totp_unavailable (“Two-factor verification is temporarily unavailable”): _verify_totp_code_or_raise catches the cryptography.fernet.InvalidToken raised by the decryption and logs a warning naming the user.
    • A user who holds an unused recovery code is not stranded: recovery codes are verified against their own Argon2 hashes and never need JWT_SECRET, so the code is accepted after a rotation (see the recovery below).
    • The admin recovery below works regardless: it never needs to decrypt the old secret.
  • Every webhook stops delivering. Stored webhook secrets were encrypted under a key derived from the old JWT_SECRET and can no longer be decrypted; each delivery fails with secret_unavailable. Delete and recreate the webhooks (a new secret is shown once at creation).

What rotation does not break:

  • OIDC sessions — signed RS256 via the identity provider’s own keys (JWKS), entirely independent of JWT_SECRET.
  • API tokens (ptr_...) — stored as a plain SHA-256 hash (api_token_service._hash_token), independent of JWT_SECRET. They keep working across a rotation.

Post-rotation recovery (per stranded user):

A user who still has a recovery code can recover alone:

  1. The user signs in with email, password and, on the verification step, a recovery code.
  2. The user turns two-factor authentication off with their password (Settings → Security → Disable MFA, as in the pre-rotation option above), then enrols again (POST /api/v1/auth/totp/setup → POST /api/v1/auth/totp/verify) under the new secret. Enrolling returns a fresh set of recovery codes.

A user without a usable recovery code needs an admin:

  1. An admin disables MFA for the affected user: Administration → Users → select the user → Disable MFA (or POST /api/v1/admin/users/{user_id}/mfa/disable directly — admin_user_service.disable_user_mfa, idempotent, clears totp_secret/totp_enabled and the user’s recovery codes). This does not require decrypting the old secret.
  2. The user signs in with email and password alone (no TOTP code required once MFA is disabled).
  3. The user re-enrols TOTP (POST /api/v1/auth/totp/setup → POST /api/v1/auth/totp/verify) under the new secret if they want MFA again.

API tokens provide long-lived, header-based authentication for scripts and integrations:

  • Format — ptr_ prefix + 64 hex chars (secrets.token_hex(32)).
  • Storage — SHA-256 hash stored in api_tokens.token_hash. Plaintext is never persisted.
  • Creation — POST /api/v1/tokens returns the plaintext token once. Auto-clears from the frontend DOM after 60 seconds.
  • Limits — Max 10 tokens per user. Optional expiry via expires_in_days (1 to 3650 days; omit it for a token that never expires). The token minted by the OIDC mobile exchange (POST /auth/mobile/exchange) expires after 90 days; password-login tokens and tokens issued earlier keep their existing lifetime.
  • Validation — validate_api_token hashes the incoming token and looks up the hash. Updates last_used_at.
  • CSRF — Exempt from CSRF middleware because authentication is via Authorization header, not cookies.

Cookie attributes are centralized in backend/app/utils/cookies.py:

  • is_https(request) — When TRUSTED_PROXY=true (default) and the direct peer is a trusted proxy (TRUSTED_PROXIES, default private networks), reads X-Forwarded-Proto; otherwise falls back to request.url.scheme.
  • Secure flag — Protocol-aware: set to true when the request is HTTPS, false over HTTP. This allows local dev over HTTP without DEBUG=true.
  • SameSite — Lax for all cookies (session and OIDC flow). See cookies-and-csrf.md for the rationale.
  • HttpOnly — Always true for all auth cookies.
Cookie Max-Age Purpose
access_token 30min (access_token_expire_minutes) for local auth; the token’s own remaining exp for OIDC JWT for API authentication. Cookie max_age deliberately matches the JWT’s own (short) lifetime, not the refresh window, so this cookie can never outlive the refresh_token cookie in the browser — it gets deleted by the browser once it expires. When that happens, get_current_user transparently refreshes via the refresh_token cookie (see Token Refresh) exactly as it would for a present-but-expired access token.
refresh_token 7 days (refresh_token_expire_days) Used to obtain new access tokens; its max_age is what actually determines how long a session survives across browser restarts. Rotated on every use for both auth paths: for OIDC, when the provider returns a new refresh token during the refresh flow; for local auth, unconditionally (see Token Refresh).
Cookie Max-Age Purpose
pkce_verifier 300 seconds (5 min) PKCE code verifier for token exchange
oauth_state 300 seconds (5 min) CSRF state verification for OIDC callback

All OIDC flow cookies use SameSite=Lax (not None, which causes Firefox TCP partitioning).

An optional is_admin flag on User, scoped strictly to account management (list/enable/disable/promote/demote users, issue invites) — an admin can never query another user’s financial data (accounts, snapshots, transactions).

  • OIDC bootstrap — ADMIN_EMAILS/ADMIN_SUBS (comma-separated) promote a matching OIDC user on their next authenticated request (see the priority-chain note above), and bootstrap_configured_admins re-promotes every matching live OIDC user at backend startup. Emails match case-insensitively, sub values exactly. One-way: removing an entry from the env var never demotes an already-admin user.
  • Local bootstrap — local accounts are never matched against those variables, because a self-registered email is not proof of ownership. Instead register_user makes the first account registered admin when the users table holds no live (non-deleted) user. If an install ends up with accounts but no admin, see ADMIN_EMAILS has no effect for the database fallback.
  • In-app grants — Beyond bootstrap, an existing admin can promote/demote any other user via PATCH /api/v1/admin/users/{id}.
  • Authorization — All /api/v1/admin/* endpoints depend on get_current_admin_user, which wraps get_current_user and raises APIError(code="admin_required", status_code=403) for non-admins.
  • Last-admin guard — Demoting, disabling, or deleting the sole remaining admin (is_admin AND is_active) is rejected with 409 last_admin. Self-disable is allowed as long as another admin remains.
  • Invites — Admin-issued, single-use, 7-day-expiry tokens (SHA-256 hashed at rest, plaintext never persisted — same pattern as PasswordResetToken). Redemption (POST /auth/invites/redeem, public/unauthenticated) always creates a regular (non-admin) user and bypasses REGISTRATION_ENABLED (which only gates the public self-service /auth/register form).
  • Audit — Promote/demote/enable/disable/invite actions are logged via log_entity_event and exposed through GET /api/v1/admin/users/audit, a cross-user audit view scoped strictly to entity_type="user" (account-status metadata only, never financial data).