Security¶
JAFAAL is designed to own the security-critical path so that embedding applications inherit strong defaults. This page summarises the built-in protections and the deployment steps you are responsible for.
Built-in protections¶
Credentials¶
- Argon2id password hashing (via
pwdlib), with transparent rehashing on verify when the configured cost parameters change. It is the only supported algorithm: a second one exists only to read hashes you no longer want, and every additional verifier is another way to get a password comparison wrong. - A timing-equalising dummy verify on the "user not found" login branch, so response time cannot be used to enumerate valid usernames.
- Password hashes live in JAFAAL's own
users_local_credentialstable, never on your user row — so SSO-only accounts simply have no credential.
Tokens & sessions¶
- JWTs default to HS256, with opt-in asymmetric signing. The decode
allow-list is mandatory (pinned to the configured algorithm), so
alg=noneand algorithm-confusion are rejected. Configure an asymmetric algorithm (RS256/ES256/…) with aprivate_keyto sign with a private key and let resource servers verify statelessly via the published JWKS — no shared secret (see Asymmetric signing & JWKS). OIDC ID tokens are verified against a separate asymmetric allow-list (blocking RS256→HS256 confusion), and theiriss/aud/exp/iat,nonce,azp, and (when present)at_hashclaims are checked, and the discovery document'sissuermust equal the configured issuer URL (OIDC Discovery §4.3) soissanchors to the provider you configured rather than to the document itself. When an identity provider declares an issuer, discovery failing is a failed login, not an unverified one: the callback refuses to fall back to trusting the userinfo response alone, so an attacker who can disrupt discovery cannot downgrade the flow past ID-token verification. - Adopting an existing account over SSO is opt-in. A provider can only
claim a pre-existing local account by matching email when that provider has
allow_email_linkingset and assertsemail_verified— becauseemail_verifiedis the provider's own claim, and an always-on email fallback would make the weakest enabled provider a takeover path for every account. Profile sync withholds an unverified email for the same reason. - Refresh-token rotation with reuse detection. Every refresh rotates the
token; presenting an already-rotated token past a short grace window is treated
as theft and invalidates the entire token family. A racing/duplicate
refresh within grace replays the same replacement idempotently, once — a
lost response produces exactly one retry, so a second replay is reuse and
invalidates the family too. The rotation write is a compare-and-swap gated
on the session still holding the old digest, so two requests carrying the same
token cannot both rotate it. Sessions
store the refresh token as a keyed HMAC-SHA256 digest — unforgeable without
secret_key, and microseconds to verify, since a refresh token is a high-entropy server-minted JWT rather than a user-chosen secret. - Sessions are always finite.
absolute_timeout_hours(30 days by default) is enforced whether or not the optional idle timeout is on, and a session'sexpires_atis capped at that deadline every time it rotates — so refreshing extends a login up to the ceiling, never past it. - CSRF binding for web clients, with a bootstrap rule for page reloads (the
in-memory CSRF token is lost on reload while the
HttpOnlycookie persists)./refreshadditionally rejects any request the browser marks as off-site:OriginandSec-Fetch-Siteare forbidden header names, so page script can neither forge nor strip them — unlike a customX-CSRF-Tokenheader, which a cross-site attacker simply omits. Setcsrf_trusted_originswhen the frontend is served from a different origin than the API. Refresh cookies areHttpOnly,SameSite=Strict, andSecurein deployed environments, with an optional__Secure-/__Host-name prefix. - Per-purpose key derivation.
secret_keyis never used directly as a MAC key. Each job (session refresh-token digest, rotated-token lookup, CSRF, API keys, password-reset / sign-up / IdP-link tokens, the WebAuthn user handle) gets its own HKDF-SHA256 subkey with a distinct label, so a digest computed for one purpose can never be replayed as another. - Zero-downtime key rotation. Both the JWT signing key and the Fernet at-rest
key accept previous keys as verify-/decrypt-only fallbacks
(
secret_key_fallbacks/fernet_key_fallbacks), so keys rotate without invalidating live tokens or stored secrets.
MFA¶
- TOTP with QR provisioning and single-use backup codes.
- The second factor stays a second factor. When a password login needs MFA,
/auth/loginreturns an opaque, single-usemfa_tokenalongside the challenge. That ticket — not the username — addresses the pending login at/auth/mfa/verifyand at the WebAuthn second-factor endpoints, so possession of a valid one-time code alone cannot complete a login that somebody else's password step opened. Hold it in memory, never persist it; it expires in five minutes and is consumed atomically on success. - Replay protection: a matched TOTP timestep is atomically claimed and a
second use within the validity window is rejected (constant-time comparison
throughout). The claim is a single compare-and-set in the state store, so two
concurrent verifications of the same code cannot both succeed. On a state-store
outage this fails closed by default (configurable via
totp_replay_fail_open). - Backup-code verification leaks the remaining count, deliberately. The
verification loop does not short-circuit on a match, so latency is independent
of which code matched. It is, however, proportional to how many unused codes
remain — one Argon2 verification each — and drops to nearly nothing once none
are left. That is observable to anyone already holding a valid
mfa_token, i.e. someone who has already passed the password factor for that account. Equalising it would mean padding every call to the maximum code count and paying the full Argon2 cost on every MFA attempt. What leaks is a count of remaining recovery codes, not a credential, so the trade is not worth making; progressive MFA lockout remains the guard against guessing.
Network & abuse¶
- SSRF guard on outbound OIDC calls: the scheme is allow-listed, every resolved A/AAAA address must be public (DNS-rebinding defence), and the guard re-runs on every redirect hop.
- HTTPS for identity-provider endpoints (default). The OIDC client refuses
http://IdP URLs — the browser-facing authorization endpoint and the server-side token, userinfo, JWKS, discovery and revocation URLs — so authorization codes, tokens and client credentials are never sent in cleartext. Setidp_require_https=Falseto allowhttp://for local or self-hosted development. - Upstream PKCE (S256) on the authorization-code flow to the IdP: JAFAAL
sends a per-login
code_challengeand replays thecode_verifier(held Fernet-encrypted with thestate) at the token exchange, so an intercepted authorization code cannot be redeemed by an attacker. - Progressive per-account lockout (escalating windows) on failed login, MFA, and step-up attempts, keyed on a normalised, hashed identifier.
- Unspoofable client IP. The lockout and rate-limit keys, and the source IP
in audit records, come from the forwarded chain resolved right to left —
the first hop that is not listed in
trusted_proxies. A client cannot pick its own apparent address by prepending anX-Forwarded-Forvalue (the leftmost element is attacker-controlled, because proxies append). List every hop your infrastructure adds; see Configuration. - Rate limiting hooks for sensitive/write endpoints (you inject the limiter).
Error handling¶
The core raises framework-agnostic JafaalError subclasses (no fastapi
import) that a single edge handler maps to HTTP once, at the boundary. Each
error carries a stable machine-readable code alongside its HTTP status_code.
Audit logging¶
Every security-relevant event is emitted as a structured record on the
dedicated jafaal.audit logger, separate from the human-readable jafaal.*
application logs. Wire a SIEM or audit sink by attaching a handler and reading
the fields off each record — no message-string parsing:
import logging
audit = logging.getLogger("jafaal.audit")
audit.addHandler(my_json_handler) # e.g. python-json-logger
audit.setLevel(logging.INFO)
audit.propagate = False # keep audit off the app log if you prefer
Each record carries event (a stable slug such as login.failure), outcome
(success / failure / blocked), and event-specific fields (user_id,
username, ip, session_id, key_prefix, token_family_id, …). The log
message is the event slug, so even a plain-text handler stays readable.
Event catalog¶
The stream records successes as well as failures: a trail of nothing but
rejections can tell you an account was attacked, but not whether the attacker
got in and what they changed once there. The slugs are declared on
jafaal.audit.Event:
| Area | Events |
|---|---|
| Authentication | login.success, login.failure, logout, lockout.applied |
| MFA | mfa.success, mfa.failure, mfa.enabled, mfa.disabled, mfa.backup_codes_generated, mfa.replay_check_unavailable |
| Step-up | step_up.success, step_up.failure |
| WebAuthn | webauthn.registered, webauthn.credential_deleted, webauthn.auth_success, webauthn.auth_failure |
| Credentials | password.changed, password.reset_requested, password.reset_completed, signup.confirmed |
| Tokens & sessions | token.refreshed, token.revoked, token.reuse_grace, token.theft_detected, session.revoked |
| API keys | api_key.created, api_key.revoked, api_key.deleted, api_key.auth_success, api_key.auth_failure |
| Identity providers | idp.link_added, idp.link_removed, idp.email_linked, idp.email_link_refused, idp.discovery_failed, oauth_state.replay_rejected |
State-changing events an account owner would want to know about
(mfa.disabled, password.changed by an admin, api_key.deleted,
idp.link_removed, scope.denied) are emitted at WARNING so a coarse level
filter still surfaces them.
Actionable security events
Beyond the SIEM-facing audit stream, the host
AuthEventSink also receives best-effort
security notifications it can turn into user-facing alerts:
on_new_device_login, on_account_locked,
on_refresh_token_theft_detected, on_idp_account_linked, and
on_authenticator_changed (any factor added or removed). They are
fire-and-forget and forward-compatible — a sink that does not implement a
method simply skips it.
Delivery never runs inline on the auth path: from synchronous code the
coroutine is handed to a background dispatch loop, so a slow SMTP server or
webhook cannot pin a request worker. Each delivery is abandoned after
jafaal.ports.EVENT_DISPATCH_TIMEOUT_SECONDS (10s), and events beyond
jafaal.ports.MAX_INFLIGHT_EVENTS (256) concurrent deliveries are dropped
and logged rather than queued without bound. Call
jafaal.ports.wait_for_pending_events() to flush the queue on shutdown.
Audit records are sensitive
To be useful for a SIEM, audit records contain identifiers the application
logs deliberately omit — plaintext usernames from failed logins and client
IPs. Treat the jafaal.audit stream as sensitive and route it accordingly,
or set audit_include_pii=False to drop those identifiers (usernames are
then emitted only as a one-way hash) for PII-minimal retention.
Deployment hardening¶
JAFAAL runs out of the box in a single process, but a deployed environment
(environment="production" or "demo") fails closed at startup —
create_auth_router() / verify_configuration() raise — on either of these
until you address them (each has an explicit opt-out for the rare case you
accept the risk):
- Inject a real rate limiter. The default is a no-op, so endpoints are not
rate-limited until you install one (per-account progressive lockout still
applies). The batteries-included
StateStoreRateLimiterneeds no extra dependency — pass it viacreate_auth_router(rate_limiter=...)or calljafaal.configure_rate_limiter(StateStoreRateLimiter()). Opt out withallow_no_rate_limit_when_deployed=True. - Use a distributed state store for multi-worker deployments. The in-memory
StateStoreis process-local, so multiple workers/replicas would keep lockout and TOTP-replay state per worker. Configure a shared backend such asRedisStateStoreviajafaal.configure_state_store(...), or setallow_in_memory_state_store_when_deployed=Truefor a single-worker deployment.
Also make sure to:
- Provide a high-entropy
secret_key(≥ 32 bytes) and a validfernet_key, and plan their rotation. - Set
trusted_proxiesto your actual proxy addresses when running behind a reverse proxy, so client IPs (which key the lockout counters) cannot be spoofed. - Keep
allow_query_paramdisabled unless you specifically need it. - Keep the API-key scope allow-list (
configure_api_key_scopes) as small as the integrations genuinely require. A key can additionally never carry a scope the account minting it does not itself hold, so allow-listing an admin scope does not hand it to regular users — but a tight allow-list is still the first line.
Optional hardening knobs¶
refresh_cookie_prefix— set to"__Secure-"to prove the refresh cookie was set over HTTPS (compatible with the default path-scoped cookie), or"__Host-"for the strongest binding (requiresrefresh_cookie_path="/", no Domain). The prefix is applied only in a deployed environment (browsers reject__Secure-/__Host-cookies sent withoutSecure, which would break local http development).leeway_seconds— small clock-skew tolerance (e.g.30) applied to theexp/nbfclaims of JAFAAL's own JWTs, to avoid spurious 401s across slightly-skewed nodes. Defaults to0(strict); keep it small.totp_replay_fail_open— leaveFalse(default) so TOTP replay protection fails closed on a state-store outage; setTrueonly if you prefer MFA availability over the single-use guarantee during an outage.
Password policy (NIST SP 800-63B)¶
The password_type on your PasswordPolicy selects the
validation applied at sign-up and password change:
"length_only"(the shipped default) — enforces only minimum/maximum length. SP 800-63B-4 §3.1.1.2 states verifiers SHALL NOT impose composition rules, in favour of length plus breached-password screening. Pair it with a breach check: install aPasswordBreachCheckerviajafaal.configure_password_breach_checker(...)(e.g. an HIBP k-anonymity lookup or a local blocklist) — it is consulted after the length policy and before hashing, and should fail open on an upstream error. JAFAAL logs a startup warning while no checker is installed, because dropping composition rules without a blocklist is the wrong half of the guidance."strict"— additionally requires upper/lower/digit/special. Available for hosts bound by legacy composition requirements, but it is a deliberate deviation from the standard.
StaticSettingsProvider defaults to length_only with a 15-character regular
minimum (20 for admins) — the length SP 800-63B-4 §3.1.1.1 recommends, not the 8
it merely permits.
Passwords are NFKC-normalized before hashing (§3.1.1.2), so a passphrase enrolled
on one platform verifies on another. Argon2id is the only hashing algorithm and
it never truncates, so max_length (default 128, minimum 64) is the sole upper
bound and exists only to cap hashing work on an unauthenticated endpoint. Every
password request field shares one transport bound, and max_length is validated
against it, so the policy can never be set to a value the schemas would reject
first.
Response headers for SSO redirect pages¶
JAFAAL issues browser redirects for the SSO callback, always to the
redirect_uri the initiating client registered and never to a configured
fallback path. The target is matched byte-for-byte against
OAuthClient.redirect_uris before the flow starts, so
there is no open redirect to defend against — but setting transport and
content-security headers is the host's responsibility. On the frontend pages
that receive these redirects, and on your API responses generally, set at least:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; form-action 'self'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Guidance specific to the SSO round trip:
- Scope the CSP
form-actionandconnect-srcto your own origin and your IdP(s) so the authorization request and token exchange cannot be redirected to an attacker-controlled endpoint. frame-ancestors 'none'(or a strict allow-list) prevents the login/result pages from being framed for clickjacking.Referrer-Policy: strict-origin-when-cross-originavoids leaking thesession_id/statequery parameters to third parties via theRefererheader.- Prefer setting these at the reverse proxy or via a FastAPI middleware so they apply uniformly, including to the redirect responses JAFAAL returns.
Reporting a vulnerability¶
Please report suspected vulnerabilities privately — see SECURITY.md for the disclosure process. Do not open a public issue for a security report.