Skip to content

Security model

This page summarizes protections and conscious trade-offs shipped by the library.

Operator checklist

Before production rollout, verify the deployment-owned security contract in Deployment: reverse-proxy and trust boundaries, cookie transport security requirements, and secrets at rest and key rotation.

Implemented controls

  • Passwords — hashing via pwdlib; hash upgrade on login when parameters change.
  • Credential rotation — self-service password changes use POST /users/me/change-password with current-password re-verification. Self-service profile updates do not accept password changes, so stale profile-edit clients cannot rotate credentials without proving the current password; admin-initiated rotation remains privileged through AdminUserUpdate.
  • Reset tokens — signed JWT-style reset tokens with password fingerprint so tokens die after password change. Library-issued verify and reset tokens include JOSE typ=JWT; missing or unexpected typ headers are rejected before the normal signed decode.
  • JWT — library-issued access tokens include JOSE typ=JWT, and access-token decode rejects missing or unexpected typ headers before signature, audience, issuer, algorithm, and required-claim validation. This is defense-in-depth against token-class confusion, not a substitute for those primary controls. Access-token validation also enforces exp / iat / aud; optional iss; a small exp / nbf leeway for ordinary clock skew; and jti denylist support (InMemoryJWTDenylistStore, RedisJWTDenylistStore) with an explicit JWTStrategy.revocation_posture contract. The in-memory denylist prunes expired JTIs on each revoke and fails closed when max_entries is reached with no reclaimable slots: it does not insert the new revocation and does not drop active revocations (use RedisJWTDenylistStore or raise the cap for high revoke volume). Callers are not misled into thinking logout succeeded: JWTStrategy.destroy_token raises TokenError, and AuthenticationBackend.logout maps that to HTTP 503 with TOKEN_PROCESSING_FAILED for API routes using the bundled exception handler.
  • Session fingerprint — optional claim on JWT tying tokens to current password/email state.
  • Cookie auth — secure defaults (HttpOnly, Secure, SameSite); CSRF for unsafe methods when wired (see Guides — Security).
  • TOTP — pending enrollment secrets stay server-side in totp_enrollment_store; replay protection is enforced when totp_used_tokens_store is configured; production fails fast without required stores; persisted TOTP secrets require encrypted-at-rest storage through BaseUserManager.totp_secret_storage_posture plus UserManagerSecurity.totp_secret_keyring or the one-key totp_secret_key; recovery codes are 112-bit lowercase hex values stored as HMAC lookup digests mapped to Argon2 hashes; pending-login tokens are bound to hashed client IP and User-Agent fingerprints by default; successful app-code verification can record a short-lived server-side step-up marker for downstream sensitive operations.
  • OAuth — state and PKCE verifier evidence in a short-lived HttpOnly flow cookie encrypted/authenticated with a Fernet key HKDF-derived from oauth_flow_cookie_secret; strict state validation; optional encryption at rest for provider tokens (oauth_token_encryption_keyring or one-key oauth_token_encryption_key); OAuth token persistence accepts only current-module OAuthTokenEncryption policies; write-time plaintext snapshots are restored after successful writes and cleared on rollback; guarded associate-by-email rules (oauth_trust_provider_email_verified on plugin-owned routes, trust_provider_email_verified on manual controllers, and oauth_associate_by_email); the associate authorize route is POST + CSRF-protected so a victim's SameSite=Lax session cookie cannot be abused by a cross-site top-level navigation to attach an attacker-controlled provider account to the victim's local user. Login authorize stays GET because anonymous OAuth login has no victim session to abuse.
  • Opaque DB tokens — keyed digest at rest; plugin-managed DB-token wiring uses DatabaseTokenAuthConfig plus LitestarAuthConfig(..., database_token_auth=...).
  • API keys — opt-in user-owned credentials with digest-only bearer storage, one-time raw-secret create responses, soft revocation, expiry, active-key caps, allowed-scope validation, and route-time downscoping by current user roles when scope_subset_check=True. Self-service list/read/create/update/revoke routes and superuser admin mint/list/revoke routes require requires_password_session, so API-key callers cannot enumerate or maintain API-key inventory or cross the password-session boundary. Optional LSA1-HMAC-SHA256 request signing adds timestamp skew and nonce replay checks and caps pre-auth body buffering with api_keys.signed_body_max_bytes, but signing-required keys store an encrypted copy of the raw secret via api_keys.secret_encryption_keyring; this is a deliberate reversible-storage trade-off compared with bearer keys' digest-only storage.
  • Failed-login telemetry — failed-login logs never include the submitted email/username. Configure UserManagerSecurity.login_identifier_telemetry_secret when you want a stable, non-reversible identifier_digest; the digest is omitted when that dedicated secret is unset.
  • Rate limiting — optional per-endpoint limits; in-memory backend is single-process only and fails closed for new keys when its capacity cap is reached.
  • Route-level role checksis_superuser, has_any_role(...), and has_all_roles(...) reuse the same normalized flat-role semantics as persistence and manager writes, and they fail closed if the authenticated user does not expose the documented role-capable contract. Role guard matching uses fixed-work internal comparisons over normalized role strings to avoid role-membership short-circuit predicates; this is a defense-in-depth posture, not a claim of cryptographic constant-time behavior across Python or the network.

Plugin-managed security posture paths

The plugin keeps these security-sensitive paths explicit and ties them to the same runtime posture contracts used by startup warnings and fail-closed validation where applicable:

Surface Runtime contract Compatibility / tradeoff Plugin-managed production behavior
JWTStrategy(allow_inmemory_denylist=True) JWTStrategy.revocation_posture JWTStrategy requires an explicit denylist_store, or allow_inmemory_denylist=True for single-process in-memory revocation. Plugin-managed production has no separate JWT revocation compatibility flag; startup warns when a strategy is explicitly wired to process-local in-memory revocation.
user_manager_security.totp_secret_keyring / totp_secret_key BaseUserManager.totp_secret_storage_posture BaseUserManager.totp_secret_storage_posture is the Fernet-encrypted at-rest contract; omitting both totp_secret_keyring and totp_secret_key means non-null persisted TOTP secrets cannot be stored or read. Pending-enrollment secrets are plaintext only under explicit unsafe_testing=True. With totp_config enabled, plugin-managed production requires user_manager_security.totp_secret_keyring or the one-key totp_secret_key shortcut unless unsafe_testing=True or a custom user_manager_factory explicitly owns that wiring. totp_enrollment_store is also required unless unsafe_testing=True.

Direct/manual posture contracts

When you assemble JWTStrategy or BaseUserManager yourself, inspect the runtime posture objects directly instead of inferring security behavior from constructor kwargs later:

  • JWTStrategy(secret=..., denylist_store=RedisJWTDenylistStore(...)) reports the durable shared_store posture.
  • JWTStrategy(secret=..., allow_inmemory_denylist=True) reports the explicit process-local in_memory posture. revocation_is_durable stays False and logout / revoke remains single-process.
  • Plugin-managed JWT revocation notices consume the live JWTRevocationPosture exported from litestar_auth.authentication.strategy.jwt and returned by JWTStrategy.revocation_posture; posture-shaped wrappers or objects retained from earlier module identities are ignored.
  • BaseUserManager.totp_secret_storage_posture reports the fernet_encrypted persisted-secret contract. Supplying totp_secret_keyring=FernetKeyringConfig(...) on UserManagerSecurity(...) lets direct/custom integrations store and read encrypted TOTP secrets with an active key id and configured old keys. The one-key totp_secret_key field remains a deliberate ergonomic shortcut and is encoded under the default key id.
  • New persisted TOTP secret writes use fernet:v1:<key_id>:<ciphertext> values. BaseUserManager.totp_secret_requires_reencrypt(...) and BaseUserManager.reencrypt_totp_secret_for_storage(...) are the manager helpers for explicit at-rest rotation jobs.
  • With totp_secret_keyring and totp_secret_key omitted, None still represents disabled 2FA, but non-null TOTP secret writes and unprefixed legacy plaintext reads fail closed. Encrypt, rotate, or clear existing plaintext TOTP secret rows before upgrading.
  • TOTP step-up for sensitive operations is documented in TOTP step-up for sensitive operations, including totp_stepup_ttl_seconds, totp_stepup_policy, totp_stepup_allow_recovery, the TOTP_STEPUP_REQUIRED 403 contract, default endpoint policies, recovery-code behavior, and the API-key transport rationale.
  • BaseUserManager uses UserManagerSecurity.login_identifier_telemetry_secret only for failed-login identifier digests. It is optional; omitting it keeps logs correlation-safe by leaving identifier_digest out rather than reusing another auth secret.

Secret-at-rest rotation

OAuth token encryption and TOTP secret encryption share the same versioned Fernet storage format: fernet:v1:<key_id>:<ciphertext>. The key id is operational metadata, not secret material; the ciphertext remains sensitive.

Use FernetKeyringConfig(active_key_id=..., keys=...) for production. During rotation, deploy a keyring that contains both the old and new ids, switch active_key_id to the new id for writes, run row-level re-encryption with OAuthTokenEncryption.requires_reencrypt(...) / OAuthTokenEncryption.reencrypt(...) and BaseUserManager.totp_secret_requires_reencrypt(...) / BaseUserManager.reencrypt_totp_secret_for_storage(...).

API-key signing secrets use the same Fernet envelope through api_keys.secret_encryption_keyring, but only signing-required keys have reversible storage. Bearer API keys remain digest-only, cannot be upgraded to signing mode, and are not rotation candidates. For signing rows, scan rows where signing_required is true and encrypted_secret is non-null, call BaseUserManager.api_key_signing_secret_requires_reencrypt(row), and rewrite one row at a time with BaseUserManager.reencrypt_api_key_signing_secret(row_or_key_id). The helper returns the updated row metadata, never the plaintext signing secret, and it does not invoke API-key create/revoke/use lifecycle hooks. Verify no rows still require rotation, and only then remove the retired key id. The full operator checklist lives in Deployment.

Unknown key ids, malformed Fernet envelopes, missing encrypted_secret values on signing rows, and bearer rows must be treated as migration errors or explicit data-cleanup cases. Do not catch and ignore those failures while removing an old key id.

Legacy unversioned Fernet values must be handled as explicit migration input because they do not identify the decrypting key. They are not a general runtime compatibility mode. Plaintext persisted TOTP secrets remain fail-closed and must be cleared or encrypted before production use.

Additional explicit opt-ins to weaker behavior:

Surface Risk
totp_enable_requires_password=False Weakens step-up for TOTP enrollment.
csrf_secret unset with plugin-owned cookie auth Plugin validation fails closed unless cookie auth explicitly opts out.
csrf_protection_managed_externally=True on manual cookie auth You are asserting that app-owned CSRF middleware or an equivalent framework-level control protects the manual route table.
CookieTransport(allow_insecure_cookie_auth=True) Allows cookie auth without CSRF for controlled non-browser scenarios only.
ApiKeyConfig(signing_enabled=True, secret_encryption_keyring=...) Enables request signing, but stores an encrypted copy of signing-required key secrets so signatures can be verified.
InMemoryApiKeyNonceStore Process-local API-key signing replay cache; use RedisApiKeyNonceStore for multi-worker deployments.

Bearer failure-code taxonomy

Bearer API-key authentication returns the same outer response shape for unknown, revoked, and expired keys: HTTP 401 with a JSON code value. The distinct API_KEY_INVALID, API_KEY_REVOKED, and API_KEY_EXPIRED codes are a deliberate client-semantics trade-off, not an accidental disclosure channel.

The bearer credential embeds a generated key_id with at least 128 bits of entropy before the raw secret is verified against the stored HMAC digest. Blind enumeration of real key_id values is therefore impractical under ordinary online attack constraints. Once a request names a valid high-entropy key_id, separate codes let clients choose the correct remediation:

  • API_KEY_INVALID — the key id is unknown or the presented raw secret does not match; rotate or report the credential rather than retrying indefinitely.
  • API_KEY_REVOKED — the key row is known but was intentionally disabled; stop using the credential and create a replacement through a password-backed session.
  • API_KEY_EXPIRED — the key row is known but past its configured expiry; refresh the credential through normal key-management flows.

Do not depend on the specific code to grant access. All three outcomes are authentication failures and must remain non-authorizing.

Limitations (by design)

  • No built-in email sending — you must implement hooks.
  • No RBAC or WebAuthn in core — the built-in role guards are flat membership checks only; extend in your application for permission matrices or object-level policy.
  • Durable JWT revocation requires an explicit shared store — JWTStrategy(secret=...) without denylist_store or allow_inmemory_denylist=True fails closed at construction time. Use Redis (or equivalent) denylist for multi-worker production if you rely on revoke; reserve allow_inmemory_denylist=True for single-process development or tests.
  • API keys are user-owned delegated credentials only. Service-account-only keys, HKDF child keys, IP allowlists, per-key audit tables, and mTLS binding are intentionally outside this release.
  • API-key signing-secret rotation is operator-owned row processing. The library does not provide built-in batching, locking, audit-log storage, per-key audit tables, or an automatic database migration service for this workflow.

Further reading