What the service protects, against whom, and how. Each control below is pinned by tests (the control catalog at the end) so that a regression fails the build. The threat model maps threats to these controls.
| Asset | Worst outcome |
|---|---|
| Passwords | Offline cracking after a database leak |
| Sessions and tokens | Account takeover without the password |
| Second factors | Bypass by someone who holds the password |
| Account existence | Enumeration for phishing or credential stuffing |
| Security history, addresses | Personal data exposure |
Adversaries considered: an anonymous attacker on the network, an attacker
holding a leaked password, an attacker holding a stolen access or refresh
token, a malicious client application, and a read-only database leak.
An attacker with code execution on the host, or with the ENCRYPTION_KEY and
the database together, is out of scope.
- Argon2id, 64 MiB and 3 iterations by default; hashes run on a bounded
pool (
ARGON2_MAX_CONCURRENCY) so a login storm queues instead of exhausting memory. - No account oracle. An unknown identifier still pays a full hash against a
decoy. A locked account answers the same whatever the password. Registration
answers
202identically whether or not the address is taken (the owner is emailed instead; a pending one gets its verification again). Forgot-password and verification resends take a constant minimum time and answer identically. - Addresses cannot be squatted. A password reset proves ownership of the
address: it verifies a pending account with the password its owner chose, so
an account someone else registered with the address is taken back. Accounts
never verified are deleted after
CLEANUP_UNVERIFIED_ACCOUNT_DAYS. - Breached passwords are refused at registration, change and reset, through the Pwned Passwords range API: only the first five characters of the SHA-1 leave the service, answers are padded, and the check runs before the address is looked up so it costs the same whether the address is taken.
- Lockout after
LOCKOUT_THRESHOLDconsecutive wrong passwords. Failed second factors never count: whoever fails a second factor already holds the password, and letting them lock the account would let them shut the owner out. - Brute force is bounded per identifier and per address (database counters), across identifiers from one address (HyperLogLog), and per submitted token. Budgets are consumed atomically in Redis before the guarded check runs, so parallel requests cannot all pass; they fail closed when Redis is down.
- New devices are announced: a sign-in from a browser and operating system
family the account never used e-mails its owner and is audited as
new_device_login. The first sign-in of an account and browser updates do not alert. - Access tokens are ES256 JWTs (15 minutes) carrying
iss,aud,sidandjti. Every authenticated request checks, in one Redis round trip, that thejtiwas not revoked by a logout and that the session is still active. A Redis failure refuses the request: revocation cannot be proven. - Refresh tokens are opaque, stored as SHA-256 digests, and rotated on every use. Presenting a rotated token again revokes the whole session family; within 2 seconds of the rotation it is treated as a concurrent refresh from the same client (two tabs) and refused without revocation.
- Absolute lifetime. A sign-in ends after
JWT_MAX_SESSION_LIFETIME_SECShowever often it is refreshed, and no rotation dates a session past that moment.JWT_STRICT_SESSION_BINDINGrefuses a refresh from another address. - Sensitive actions require a recent re-authentication: changing the password, username or email, deleting the account, revoking sessions, and adding or removing a second factor. A fresh sign-in does not count - a stolen refresh token or an approved device must not change the credentials.
- A pre-auth token (5 minutes) is bound to the method it was issued for: a TOTP challenge cannot be completed with an email code or vice versa.
- TOTP codes are accepted once (durable replay table), with per-challenge and per-account failure budgets. Confirming a new TOTP method consumes its code in the same table, under its own budget. Email codes have their own budgets; recovery codes share one per-account budget between the sign-in challenge and the authenticated route.
- A second factor answers an account status exactly as the password sign-in does.
- Adding or removing a method notifies the account's address. Removing the last method deletes the recovery codes; removing a primary method promotes another.
- TOTP secrets are encrypted with AES-256-GCM. Ciphertexts name their key, so the key can be rotated without downtime and the rotation can be resumed.
- An external identity signs in only after the signed-in owner of an account linked it, with a recent re-authentication. No account is found or created from an email address, verified or not: an attacker controlling an address at a provider gains nothing.
- The flow uses PKCE,
stateandnonce; the ID token's signature is checked against the provider's published keys, and its issuer, audience, authorized party, expiry and nonce against the flow. - The callback's outcome is redeemed once, within two minutes, only with the binding secret the starting browser kept: a callback URL forwarded to a victim cannot sign the victim in to the attacker's account.
- The identity stands for the password: a second factor enrolled on the account is still required.
- Registration needs a recent re-authentication. A response is accepted only for the challenge issued to the session, from an allowed origin, for this relying party, with user presence and verification; attestation is not requested nor trusted.
- A sign-in challenge is used once, whatever the outcome. The assertion must
verify against the stored key, come from an allowed origin with user
verification, carry the credential's user handle, and make a kept signature
counter grow; a counter that does not grow refuses the sign-in (possible
clone). Every refusal answers
invalid_credentialsand counts against the client address.
- Only registered clients can obtain sessions, through the standard OAuth 2.1 endpoints. A confidential client proves its secret at every token and device authorization request; the secret (256 random bits) is stored as a digest and compared in constant time.
- Authorization endpoint: an unknown client or an unregistered redirect URI is answered directly, never redirected; the stored request lives ten minutes and is decided once. Consenting to a third-party client requires a re-authentication, checked before the request is used up.
- Device flow (RFC 8628): user codes are reserved atomically, polling is paced, an approval is collected exactly once and only by the client that started the flow, and account status and session limits are rechecked when tokens are issued.
- Authorization code with PKCE: S256 only, exact redirect URIs (loopback on
any port only for a registered path, never
localhost), single-use codes consumed atomically, a replayed code revokes its session. - Scopes: a request may narrow the client's registered scopes, never widen them; a client's tokens carry only the consented permissions, re-derived from the user's current permissions on every refresh, and no roles.
- Refresh: a client's session is refreshed only by that client at the token endpoint, with its authentication; the first-party route refuses it.
- OpenID Connect: identity scopes grant no permission and release only
their own claims; the ID token is bound to the client (
aud), the request (nonce) and the access token (at_hash). - Client credentials: only a confidential client with scopes that has the grant turned on obtains tokens for itself; they carry no user, so account routes refuse them, and they stop being active when the grant is turned off.
- Introspection and revocation: only confidential clients introspect, and
an inactive token reveals nothing but
active: false. A client revokes its own tokens only; any other token gets the same answer and is left alone.
/adminroutes require an administrative permission in the token, a second factor enrolled on the administrator's account, and the permission of the action still granted in the database: revoking a role takes effect on the next request, not when the token expires.- Administrators cannot suspend, sign out, reset or delete their own account
from
/admin, and deleting an account needs their recent re-authentication. - Every change is audited on the account it changed, with the administrator's id, so owners see it in their own history; changes to roles and clients are audited in the administrator's own history.
- Granting a role, changing what a role grants and saving a client need a
recent re-authentication. No change may leave the deployment without an
account holding
roles:manage.
- Deliveries are recorded in the transaction of the change, like events: an endpoint never hears of a change that rolled back.
- Each delivery is signed with the endpoint's secret (HMAC-SHA256 over id, timestamp and body); secrets are encrypted with the keyring and shown once.
- Before each delivery the host is resolved and every address checked: loopback, private, link-local, shared, documentation, multicast and reserved ranges, and IPv6 forms embedding them, are refused. The connection is pinned to the checked address and redirects are not followed, so a DNS answer or a redirect cannot turn a webhook against the internal network.
- Every token, code and refresh token is stored as a digest.
- The audit log is append-only (enforced by a trigger) and holds no personal
data such as addresses in its metadata. Its client addresses keep only their
network after 90 days and are removed when the account is deleted, with its
sign-in attempts (personal data). Users read their own history
through
GET /users/me/audit. - An email change is confirmed on both addresses, revokes the other sessions, and notifies the previous address.
- Account deletion records
user.deletedin the event outbox in the same transaction as the deletion, so downstream erasure cannot be lost and is never announced for an account that still exists; the relay delivers it to JetStream, waiting for the broker when it is down.
- Client addresses come from forwarding headers only when the direct peer is a
trusted proxy (
TRUSTED_PROXY_CIDRS); IPv6 clients are limited per/64. - Rate limits: a sliding-window estimate per client, one script per request, fail closed in production.
- Request bodies are capped at 64 KB and handlers at 30 seconds. Responses carry
HSTS, CSP
default-src 'none',nosniff,DENYframing andno-store. - Logs record route templates, never raw paths carrying codes. Metrics are served on a loopback-only listener.
Production refuses to start with a configuration that disables a control: HTTP public or frontend URLs, no trusted proxy, committed development keys, a wildcard CORS origin, fail-open rate limiting or CAPTCHA, and more. The full list is in Configuration.
- An administrative revocation outside the API (a direct database update) takes effect within the 5-second session cache.
- Email codes are 6 digits; their strength is the attempt budgets and short lifetime, not their entropy.
- Outside production, rate limiting and CAPTCHA fail open by default.
- Anyone holding both
ENCRYPTION_KEYand a database dump can read TOTP secrets. - A logout racing a refresh of the same session, more than 2 seconds after its rotation, reads as a replay: the family is revoked and a replay audited. Kept on purpose, since the audit signal outweighs this rare race.
- Webhook signing secrets, like TOTP secrets, are readable by anyone holding
ENCRYPTION_KEYand a database dump. - Passkey attestation is not verified: the account's re-authentication vouches for a new passkey, not the authenticator's make.
- An access token revoked through
POST /oauth/revokeis remembered in Redis only, until it expires; a Redis failover can forget it. - Resource servers verifying tokens offline accept a revoked access token until it expires, unless they introspect.
- A refresh does not check the account lockout. A lockout can be triggered by anyone who knows the identifier; cutting the owner's live sessions would turn it into a way to sign them out. Suspending the account does end them.
Each control names the tests that pin it; tests/security/catalog.rs fails
when a cited test no longer exists.
| ID | Control | Tests |
|---|---|---|
| SEC-01 | Passwords are hashed with Argon2id, salted per hash | hash_and_verify_correct_password, same_password_produces_different_hashes, async_hash_and_verify_match_sync_behavior |
| SEC-02 | No account oracle: unknown identifiers, locked accounts, taken addresses, forgotten passwords and verification resends answer alike | locked_account_answers_the_same_whatever_the_password, registering_a_taken_email_looks_like_a_new_signup, forgot_password_takes_the_same_minimum_time_either_way, forgot_password_returns_200_for_unknown_email, resending_the_verification_looks_the_same_for_every_address, login_unknown_user |
| SEC-03 | Lockout after consecutive wrong passwords, never after failed second factors | account_locked_after_threshold_failures, account_unlocked_after_lockout_expires, second_factor_failures_do_not_lock_the_account, a_zero_threshold_never_locks, validate_rejects_zero_lockout_threshold |
| SEC-04 | Attempt budgets are consumed atomically and fail closed | concurrent_attempts_never_exceed_the_budget, unreachable_redis_fails_closed, refresh_rate_limited_after_20_invalid_tokens, forgot_password_is_capped_per_account, concurrent_reauthentication_guesses_never_exceed_the_budget, rotating_ipv6_addresses_within_a_64_does_not_reset_the_failure_budget |
| SEC-05 | Access tokens: ES256 only, issuer, audience and time claims checked, revocation checked on every request | missing_or_malformed_credentials_are_refused, forged_tokens_for_a_live_session_are_refused, a_token_outlives_neither_its_expiry_nor_its_logout, time_claims_follow_the_supplied_clock, decode_rejects_non_es256_alg, a_rotation_keeps_every_published_key_valid, a_kid_does_not_lend_its_key_to_another_signature |
| SEC-06 | Every operation outside a short public list requires an access token | the_public_list_matches_the_document, a_valid_token_passes_authentication_everywhere |
| SEC-07 | Refresh tokens rotate; a replay revokes the family, a concurrent refresh does not | refresh_token_replay_is_rejected, refresh_token_theft_invalidates_entire_session_family, concurrent_refreshes_keep_the_family_alive, rotated_within_accepts_the_grace_boundary_only |
| SEC-08 | Absolute session lifetime and optional address binding | session_lifetime_counts_from_the_first_sign_in, a_rotation_inherits_the_family_start, refresh_rejects_mismatched_ip_with_strict_binding, rotations_never_outlive_the_absolute_lifetime, a_rotated_session_is_never_dated_past_its_absolute_lifetime |
| SEC-09 | Sensitive actions need a recent re-authentication; signing in does not count | signing_in_does_not_grant_sensitive_actions, revoke_session_requires_recent_reauth, delete_account_without_password_and_no_recent_reauth_rejected, a_device_session_cannot_change_the_password_without_reauthentication, enrolling_a_second_factor_requires_reauthentication |
| SEC-10 | A pre-auth token completes only the method it was issued for | totp_challenge_cannot_be_completed_with_an_email_code, a_pre_auth_state_without_a_method_cannot_complete_with_a_recovery_code, seeds_and_regressions_hold |
| SEC-11 | Second-factor codes are single-use and budgeted per challenge and per account | totp_replay_within_window_rejected, totp_replay_rejected_even_after_redis_key_loss, concurrent_totp_guesses_never_exceed_the_token_budget, account_budget_blocks_fresh_pre_auth_tokens, recovery_challenge_rate_limited_after_max_failures, email_2fa_lockout_after_max_failures, recovery_login_replay_rejected, a_code_confirming_a_new_method_cannot_complete_a_sign_in, confirming_a_new_method_has_an_attempt_budget, recovery_code_guesses_share_one_budget_across_routes, a_challenge_owns_its_state_and_every_failure_budget |
| SEC-12 | Changes to second factors are notified and keep a usable configuration | removing_the_last_method_drops_recovery_codes, removing_the_primary_method_promotes_the_remaining_one, disable_totp_sends_two_factor_disabled_email |
| SEC-13 | TOTP secrets are encrypted with named keys; rotation is resumable | keyring_writes_versioned_ciphertexts_it_can_read, keyring_refuses_a_key_it_does_not_hold, encrypt_produces_different_output_each_call, rotate_is_idempotent_when_run_twice, rotate_re_encrypts_totp_secret_with_new_key |
| SEC-14 | Only registered clients obtain sessions through client flows | a_flow_needs_a_registered_client |
| SEC-15 | Device flow: user codes reserved atomically, polling paced, approval collected once, account and session limit rechecked under lock | a_live_user_code_is_never_handed_out_twice, polling_faster_than_the_interval_is_slowed_down, an_approval_is_collected_exactly_once_under_concurrent_polls, a_suspended_account_cannot_collect_approved_tokens, a_non_primary_client_is_capped_without_a_quota_row, unknown_user_codes_are_rate_limited, concurrent_approvals_never_exceed_the_session_limit, a_second_factor_answers_an_inactive_account_like_the_password_sign_in |
| SEC-16 | Authorization code: S256 only, exact or loopback redirects, single use, replay revokes, third-party consent re-authenticates | only_s256_challenges_are_accepted, only_registered_or_loopback_redirects_are_accepted, loopback_redirects_accept_any_port_on_a_registered_path, a_replayed_code_is_refused_and_revokes_its_session, a_wrong_verifier_burns_the_code, a_code_is_bound_to_its_client_and_redirect, a_third_party_client_requires_a_fresh_reauthentication, challenges_and_verifiers_follow_rfc_7636 |
| SEC-17 | Client tokens carry only consented permissions, re-derived on refresh | tokens_carry_only_the_consented_scopes_even_after_refresh, granted_is_an_intersection_unless_unrestricted |
| SEC-18 | Tokens and codes are stored as digests | sessions_require_32_byte_hashes, email_verification_tokens_are_fixed_length |
| SEC-19 | The audit log is append-only and holds no personal data | audit_log_is_append_only, audit_log_delete_blocked_by_trigger, account_deletion_leaves_no_identity_in_the_audit_log, an_email_change_keeps_the_status_and_audits_no_address, a_forged_cursor_is_refused_and_the_history_needs_a_session, audit_addresses_can_only_be_forgotten_or_coarsened, a_deleted_account_leaves_no_address_or_sign_in_attempt_behind, old_audit_addresses_keep_only_their_network |
| SEC-20 | An email change is confirmed on both addresses by the user who started it | email_change_full_flow_success, email_change_steps_cannot_be_skipped, email_change_token_bound_to_initiating_user |
| SEC-21 | Account deletion and its user.deleted event commit together, and the event goes out once the broker is back |
account_deletion_publishes_user_deleted_through_jetstream |
| SEC-22 | Forwarding headers count only from trusted proxies; IPv6 clients share their /64 | direct_peer_ignores_forwarded_headers, trusted_proxy_uses_forwarded_client_ip, ipv6_addresses_share_their_64, every_forwarded_line_counts_as_one_list, an_unreadable_hop_stops_the_walk_at_the_proxy, sql_budgets_group_addresses_like_redis_budgets |
| SEC-23 | Rate limits per client, failing closed in production | auth_rate_limit_blocks_requests_exceeding_limit, auth_routes_fail_closed_when_rate_limiter_backend_is_down, a_refused_request_consumes_nothing, validate_rejects_production_config_with_rate_limit_fail_open |
| SEC-24 | Bounded bodies, security headers, CORS allowlist, one error format | an_oversized_body_is_refused_before_the_handler, security_headers_present_on_200_response, security_headers_enable_hsts_for_https_production, cross_origin_access_is_limited_to_the_allowlist, plain_text_errors_become_error_bodies_with_their_headers, parser_details_do_not_leak |
| SEC-25 | Logs carry route templates and never a secret | access_logs_carry_route_templates_not_codes, account_flows_never_log_their_secrets |
| SEC-26 | Production refuses a configuration that disables a control | validate_accepts_hardened_production_config, validate_rejects_committed_dev_key_in_production, validate_rejects_wildcard_cors_in_production, validate_rejects_non_https_public_url_in_production, validate_rejects_zero_device_poll_interval, validate_rejects_poll_interval_not_below_device_ttl, validate_rejects_zero_session_lifetime |
| SEC-27 | Code hygiene: bound SQL parameters, no unsafe code, no panics on request paths, released migrations frozen | sql_is_never_assembled_from_strings, there_is_no_unsafe_code, request_paths_never_unwrap, released_migrations_are_never_edited |
| SEC-28 | Every response matches the published OpenAPI contract | schemas_are_enforced_through_references, undocumented_statuses_and_bodies_are_violations, documented_schemas_have_unique_names |
| SEC-29 | Passwords found in known data breaches are refused, and only a hash prefix leaves the service | registration_refuses_a_breached_password, only_the_hash_prefix_leaves_the_service_with_padding_asked, a_breached_password_is_refused_on_change_and_on_reset, the_range_key_splits_the_uppercase_sha1 |
| SEC-30 | A sign-in from a new device is announced to the owner | a_sign_in_from_a_new_device_alerts_the_owner, the_first_sign_in_and_a_browser_update_raise_no_alert, versions_do_not_make_a_new_device |
| SEC-31 | Administration needs the permission in the token and in the database, and a second factor | an_account_without_administrative_permission_is_refused, an_administrator_without_a_second_factor_is_refused, a_permission_revoked_in_the_database_stops_working_before_the_token_expires, each_action_requires_its_own_permission, an_administrator_cannot_suspend_their_own_account_or_a_pending_one, deleting_an_account_needs_a_recent_reauthentication_and_announces_it, granting_a_role_needs_a_recent_reauthentication, nobody_can_remove_the_last_way_to_manage_roles_or_the_default_role |
| SEC-32 | The data export needs a recent re-authentication and holds no secret and no other account | the_export_holds_the_account_its_history_and_no_secret, exporting_needs_a_recent_reauthentication |
| SEC-33 | Sign-in links are single-use, short-lived, replaced by the next one, off by default, and never skip the second factor | a_link_signs_in_once, a_new_link_replaces_the_previous_one_and_an_old_link_expires, a_second_factor_is_still_required, unknown_pending_and_suspended_addresses_answer_alike_and_get_nothing, links_are_capped_per_account_and_off_unless_enabled |
| SEC-34 | Personal access tokens are stored as digests, shown once, scoped to permissions the account holds, and end with their session or account | a_token_is_exchanged_for_access_tokens_carrying_its_scopes_only, a_revoked_token_and_its_access_tokens_stop_working, tokens_expire_and_follow_the_account_status, creation_is_checked, scopes_are_limited_to_the_permissions_held |
| SEC-35 | Webhooks are signed, never reach internal addresses or follow redirects, and deliver exactly the committed events | a_subscribed_endpoint_receives_signed_events, internal_addresses_are_never_called, internal_addresses_are_refused, only_plain_https_urls_are_registered, endpoints_are_checked_updated_rotated_and_removed, validate_rejects_production_webhooks_to_http_or_internal_addresses |
| SEC-36 | Confidential clients authenticate at every token request, and client sessions are refreshed only by their client | a_confidential_client_must_authenticate_with_its_secret, a_public_client_has_no_secret_to_present, a_device_code_works_for_its_client_only, tokens_carry_only_the_consented_scopes_even_after_refresh, basic_credentials_are_form_decoded, a_request_asks_for_a_subset_of_the_client_scopes |
| SEC-37 | Introspection is reserved to confidential clients and says nothing of inactive tokens; revocation reaches only the requesting client's tokens | a_resource_server_learns_what_a_token_is_worth, revoking_a_refresh_token_ends_its_session, revoking_an_access_token_ends_that_token_only, a_client_cannot_revoke_the_tokens_of_another |
| SEC-38 | The client credentials grant is limited to confidential, scoped clients that enable it, and its tokens never act as a user | a_client_obtains_a_token_for_itself_with_its_scopes, the_grant_is_reserved_to_confidential_clients_that_enable_it, a_client_token_is_introspected_and_revoked, a_client_subject_is_stable_and_never_a_user_id |
| SEC-39 | ID tokens are bound to their client, nonce and access token, and identity scopes release only their claims | an_openid_request_gets_an_id_token_bound_to_its_nonce_and_access_token, userinfo_releases_the_claims_of_the_granted_scopes, scopes_release_their_claims_only |
| SEC-40 | Passkeys: registration re-authenticated and verified, sign-in challenges single use, signatures verified, cloned counters refused | a_registration_is_verified_before_it_is_stored, forged_replayed_or_cloned_assertions_are_refused, a_passkey_signs_in_without_password_or_second_factor, a_removed_passkey_no_longer_signs_in, assertions_verify_against_the_stored_key_only, client_data_answers_the_challenge_from_an_allowed_origin, validate_rejects_production_passkey_origins_outside_the_relying_party |
| SEC-41 | External identities sign in only once linked by the owner, bound to the starting browser, with verified ID tokens | a_linked_identity_signs_in_and_an_unlinked_one_never_does, an_outcome_is_used_once_by_the_browser_that_started_it, an_id_token_that_does_not_verify_identifies_nobody, a_token_for_something_else_is_refused |