Skip to main content

auth

Every way a caller proves who it is: email/password (argon2id), OAuth 2.1, MFA, passkeys, machine-to-machine service accounts, plus the RS256 JWT, session, and RBAC middleware the rest of the platform leans on. This page is the module's contract; for the narrative of how a request becomes an identity, read Authentication flow.

What it owns​

  • Email + password — argon2id in PHC format (OWASP 2025 params), transparent rehash on login when the stored hash is weaker than the current default.
  • OAuth 2.1 — Google, Apple, GitHub, and Discord, each independently enable-able per surface, with a web flow (PKCE sent to the providers proven to accept it) and a two-step mobile flow bound to a server-issued, single-use challenge. One identity has one owner: the provider collection is the source of truth and ownership is written before a session is ever minted.
  • Tokens and sessions — RS256 access + refresh JWTs from one key pair, refresh rotation with family replay detection, and a Redis-backed revocation set so logout kills an access token instantly rather than waiting out its TTL.
  • MFA — TOTP with backup codes, WebAuthn passkeys, the step-up gate (RequireStepUp) that catastrophic actions sit behind, and the enrolment proof gate (RequireEnrolmentProof) that every factor creation or replacement sits behind. Removing or replacing a factor moves the user's MFA epoch, which ends the MFA authority of every token they hold — the caller's own included — on its next request; see Removing or replacing a factor.
  • Device trust — per-surface trusted-device grants that let a known device skip the second factor.
  • Service accounts — OAuth2 client-credentials machine identities (ADR-0014). See below.
  • RBAC middleware — RequirePermission / RequireAudience / RequireStepUp, satisfying the SDK's RoleMiddleware seam, plus RequireEnrolmentProof on the separate additive module.EnrolmentProofGate sub-interface. The permission decisions belong to authz; auth only enforces them at the route.

Two surfaces, one module​

Almost every route is mirrored under /v1/auth/operator/* (Tier-1) and /v1/auth/client/* (Tier-2) — same handlers, different collections, cookie domains, and lockout accounting: the email scope is counted separately per surface, so an operator and a client account sharing an address lock independently, while the source-address scope is shared across both. A token minted for one surface is rejected at the edge of the other with 401 audience_mismatch, above and beyond per-route RBAC.

Some surfaces exist on one tier only: session listing, OAuth link/unlink and initial-password enrollment are operator-side self-service, while registration exists on both but is independently gated by config.

Web OAuth is tier-aware end to end: a flow started on the client surface lands on the client SPA and a flow started on the console lands on the console, each under one closed callback contract (success/provider, an allowlisted error code, or an MFA continuation carried in the URL fragment) that never contains a token, an email or a user id. Because every provider redirects to the operator host and that host cannot set a client cookie, a client-tier login finishes through a single-use relay on the client API host, which also verifies that the browser completing the flow is the one that started it. A provider is offered to a surface only when its toggle is on and every field the flow needs is present; a defect on one provider removes that provider alone, while an unreadable auth document answers 503 everywhere so a configuration outage can never quietly widen access. An identity that has never been linked is matched to an existing account — or allowed to sign up — only with an address the identity provider itself marked verified.

Initial-password enrollment (Tier-1)​

An active human operator with a verified account email and no password can add email/password sign-in to the same account after signing in through OAuth. In /user/security?tab=password, the email is read-only and the Add password form asks for a new password and confirmation. Existing passwords use the ordinary change-password form, including when password login is disabled; only first-password creation requires the method to be enabled.

POST /v1/auth/operator/me/password
Authorization: Bearer <access-token>
Content-Type: application/json

{"newPassword":"<policy-compliant-password>"}

newPassword is the only required writable body field. No email, target user, current password or confirmation is sent. The route is guarded by operator bearer/audience middleware, RequireGlobal() and RequireEnrolmentProof(5m): an enrolled caller must provide fresh MFA; a caller without a factor can use a recent interactive auth_time. A stale no-factor session must sign in again; refresh does not renew presence. There is no /v1/auth/client/me/password endpoint.

StatusMeaning
200The password was created; body {"success":true,"message":"Password sign-in added."}. Reload auth methods and offer change-password.
401Missing/invalid bearer or wrong audience under the existing middleware contract; step_up_required asks for fresh MFA, and reauthentication_required asks a stale no-factor caller to sign in again.
403auth.password_login_disabled when the operator password method is off, auth.email_not_verified for an unverified account email, or the existing uncoded account-inactive refusal for an inactive/service account.
409auth.password_already_set: a password already exists or another request won the atomic creation race. Clear submitted secrets, reload auth methods and use change-password.
400The new password fails the live password policy; choose a compliant password.
422Request-body validation failed, for example a missing required newPassword or an unknown property.
503auth.policy_unavailable when the live password-method policy cannot be established; auth.unavailable when a fork's user provider lacks the initial-password capability. Retry after availability is restored.

The strict passwordLoginEnabledAdmin read is authoritative; operator break-glass never permits credential creation. The service uses the live password policy and argon2id. The user module's additive iface.InitialPasswordSetter performs a create-only update on the non-deleted caller with an absent/empty hash, writing the hash and both timestamps atomically. It cannot replace an existing password.

The caller's current SID is preserved. Every other session/refresh path, including refresh credentials whose session document is missing, is revoked or denylisted, and all device-trust grants are revoked with reason password_added. This teardown always runs regardless of revokeSessionsOnPasswordChange. Unexpired refresh history includes rotating predecessors, allowing every other non-empty family to be fenced before session cleanup so a concurrent successor cannot escape. The caller's SID and families survive; orphan and legacy empty-family SIDs are also revoked and denylisted. A failed lineage read or family fence marks teardown incomplete. Teardown is best effort after persistence: a failure does not roll back the new hash or change the successful creation response. The self_password_added security event and auth.password.added compliance action record actor/resource UUID, operator audience, source IP, current SID, a conservative otherSessionsRevoked count and teardownComplete. Event metadata contains no password, hash, token, email or OAuth profile data.

OAuth links, UUID, role and memberships are retained. Tier-2 enrollment and changing the account email are outside this feature. The complete sequence is described in Authentication flow.

SSO-only surface​

Either surface can be made OAuth-only: passwordLoginEnabledAdmin and passwordLoginEnabledClient (Login & Sessions, both default true) decide whether email/password is still an accepted way to sign in there. They are independent — the console can go SSO-only while the client app keeps passwords, or the reverse.

Turning one off is guarded so it cannot lock the surface out. A config write that would leave a surface with nothing to sign in with is refused with 422 auth.login_method_lockout, on every mutation path (an ordinary save, a save to a named environment, and activating an environment). There are exactly two ways to satisfy the guard for a surface: keep the password method enabled, or leave at least one OAuth provider both enabled for that surface and fully configured for it — client ID, redirect URL and secret; for Apple also team ID, key ID and a private key — together with "Auto-link OAuth provider to existing email account". Auto-link is part of the exit because it is what lets an account that was created with a password still be reached by its provider-verified email; without it, an SSO-only surface can be a closed door for everyone already registered. The guard reads the configuration you are about to save, not the one currently live, and it judges secret presence only — no secret value is involved.

The read is strict and fails closed. The persisted value is parsed as a literal true/false; anything else — a malformed value, a missing auth config document, an unreachable store — answers 503 auth.policy_unavailable rather than assuming a default. An outage never re-opens a method an operator turned off, and it is never reported as a rejected password either.

What the switch blocks is new authentication, not existing access. With it off for a surface, login, register and forgot-password on that surface answer 403 auth.password_login_disabled; a login already paused for MFA cannot complete (the second factor is re-checked against the policy before it is even verified); an operator's send password reset for a user on that surface answers 409 rather than mailing a link for a method the surface refuses; step-up re-authentication stops offering the password-reconfirm fallback; and a password no longer counts as a credential when deciding whether unlinking an OAuth identity would strand the account. Two operator-only bootstrap paths stay open by design — the very first account on a fresh install, and the setup wizard's initial administrator — so a fresh install can never be unreachable. reset-password, accept-invite, verify-email and change-password also stay open: they redeem or replace a credential that already exists, and closing them would strand a reset already in flight.

Existing sessions are deliberately not revoked. Flipping the switch changes what can authenticate next; anyone already signed in with a password stays signed in until their session ends normally. Revoking them is a separate, deliberate action. Two capabilities are named as follow-ups rather than shipped here: a "revoke every password-opened session on this surface" admin action (sessions already record the login method, so it is feasible), and consuming an invite token inside the OAuth callback so SSO-only clients need not lean on auto-link by email at all.

The unauthenticated pages read the resulting state from GET /v1/auth/{tier}/policy, which carries passwordLoginEnabled for the surface, so the login page hides the password form instead of showing one that would 403 on submit. Both SPAs do this: the operator console renders its labelled emergency form only under break-glass, and the client SPA replaces the password form with the surface's usable OAuth providers (or a no-sign-in-method notice when none is configured).

If the OAuth configuration then breaks on an SSO-only console, the operator break-glass restores password login for POST /v1/auth/operator/login alone, without reopening signups, resets or the client surface — see Going SSO-only for the procedure and the drill.

OAuth identities​

An OAuth identity — one Google account, one GitHub account — has exactly one owner. Both provider collections carry a unique index on (provider, providerId), created on a fresh install by the module's own declaration and on an existing one by migration 0010 (backend/migrations/20260903_oauth_provider_identity_unique.js). The migration never decides ownership: a duplicate on that pair is one identity claimed by two different users, which it reports and refuses until an operator names the keeper per identity; every deletion it then makes is printed for the change record.

The module verifies that index at every start, by name and by its unique option (an ordinary index on the same keys carries the same name and guarantees nothing). While it is missing or not unique on either collection the module's health check reports degraded, naming the migration, and every OAuth login and link path answers a retryable oauth_store_unavailable instead of running the ownership flow without the constraint that detects a conflict. Nothing else is affected, and a module restart after the migration lifts the degradation.

The provider collection is also what every listing reads: the Security page, the admin auth-methods view and the unlink safety check ("would this leave the account with no way in?") all count the identities that actually exist there, so a link created by a sign-in is visible and can be unlinked (unlinking removes the identity row, so it stops signing in at once), and an identity that carries an unlink tombstone is neither listed nor counted as a way in. A sign-in with a tombstoned identity is refused with oauth_identity_unlinked, never silently re-linked by the email match: the user signs in another way and re-links it from Security. The account itself is untouched.

Removing a credential is serialized per account: the "no way in" check and the removal run under a short per-account lock, so two removals racing each other cannot both pass the check on the other's link and leave nothing. A removal that arrives while another one holds the account answers credential_change_in_progress and can simply be retried; if the lock store cannot answer, the removal is refused with oauth_store_unavailable rather than run unguarded.

Ownership is written before anything else. Every path that attaches an identity to an account — a first social sign-in, an automatic link by verified email, a link started from Security — records the identity first, and only the outcome of that write lets the flow continue: a session is never minted for an identity the store has not recorded as that user's. If the identity turns out to belong to a different account the sign-in is refused with oauth_identity_conflict; if your account already has a different identity of the same provider, with oauth_provider_already_linked; if the store cannot answer, with a retryable oauth_store_unavailable. A first sign-in that fails halfway (the identity reserved, the account not created) is cleaned up on the spot, and if that clean-up itself fails the leftover reservation heals on the next sign-in with that identity, counted in orkestra_auth_oauth_compensation_failures_total so an operator sees it.

Lockout​

Failed attempts are counted in Redis in fixed windows, so the limits hold across every replica and survive a restart.

ScopeCounted onLimit
Email (independent per surface)login, change-password, password-confirmaccountLockoutThreshold / accountLockoutDuration (5 / 15m)
Source address (shared across both surfaces)the same three, plus the service-account grantipLockoutThreshold / ipLockoutDuration (100 / 15m)
Client IDservice-account grantthe account pair
Reset / verification requestsforgot-password, resend-verification3 per email address per 15m, 60 per source address per 15m
MFA verify (per user, independent per surface)the authenticated mfa/verify and passkey verify/finish routes5 failures per 5m
MFA login completion (per user, independent per surface, its own budget)the public mfa/login/verify route5 failures per 5m

An address threshold must be at least the account threshold: an address that locks first would tell an attacker which accounts exist behind a shared office or VPN egress. The console refuses a lower value with 422 auth.ip_threshold_below_account.

A locked scope answers 429 auth.too_many_attempts with a Retry-After header, and answers it identically for an address that exists and one that does not. A successful login clears the email counter; the address counter is left alone, so one correct sign-in cannot launder a credential-stuffing run.

The MFA-verify scope is keyed on the user rather than an address — the caller is already signed in — and it is the outer bound on second-factor guessing. A passkey assertion runs inside a challenge that has its own five-attempt budget, but a caller can always start another challenge, so that inner bound never capped the caller; the authenticator-code route has no challenge at all and so had no bound whatsoever. One request costs at most one failure, including a backup-code attempt, which compares the submitted code against every stored code before rejecting it. A successful verification clears the counter; anything that was not a rejected credential — no factor enrolled, an expired or already-spent challenge, a storage failure — costs nothing, so one subsystem's degradation can never spend a legitimate user's budget in another.

The public login-completion route, mfa/login/verify, carries the same outer bound on a budget of its own. Its per-challenge counter only ever bounded one challenge: a correct password mints a fresh challenge, and a successful password login clears the email lockout on the way, so a caller who already held the password could buy five new authenticator-code guesses per login without limit. The budget is keyed on the challenge's user, since the caller is not signed in yet, and is deliberately separate from the step-up budget: an attacker burning the login budget must not lock the account holder out of step-up, and a fumbled step-up must not close the login door.

The durable per-account lock (User.LockedUntil) runs alongside the counters rather than behind them: an account locks when either its counter window or its cumulative failure count reaches accountLockoutThreshold. That second rule is what caps an attacker slow enough to keep every window under the threshold, and it is what is left when Redis is unavailable and the counters fail open — a login outage is never the answer to a cache outage. orkestra_auth_attempt_store_failures_total is the signal that the counters are the ones that dropped out. A successful sign-in clears the cumulative count, and so does a lockout once it expires, so the count is a budget per lockout rather than a life sentence.

The durable lock is also where the identical answer above stops, and that is a known residual rather than a closed hole: only the durable lock needs an account to exist, and it does not always lock or expire together with the counter. Whenever a live User.LockedUntil is reached with no counter lock in front of it — Redis down, the counter's fixed window already expired while the account's lock has not, or a slow attacker who never fills a single window — an email that exists gets the 429, while an email that does not has no record to lock and gets a plain 401. Closing it would change what a legitimately locked-out user is told (429 with Retry-After → a bare 401), so it is left to a deliberate contract decision rather than papered over.

Service accounts​

A machine caller — an integration, a CI job, a fork's own backend — authenticates with the OAuth2 client-credentials grant. Adopted in ADR-0014; operator console at /admin/service-accounts.

The model​

A service account is an operator user row, not a new entity. iface.User carries an additive Kind field — empty for a human, service for a machine principal. The account is created with a synthetic, undeliverable email under the RFC 2606 reserved .invalid TLD, the minimum system role (guest), and no password hash. Because it is a user, tenant membership, custom roles, authz bindings, Cedar evaluation, audit provenance, and JWT membership embedding all work unchanged — real capability comes from tenant-scoped custom-role bindings granted on top. Kind is immutable after creation: it is not part of the user-update input shape, so no update path can turn a human account into a machine one or back.

Credentials​

Credentials live in their own service_account_credentials collection, hashed with the same argon2id service as passwords. The client ID is an opaque sa_-prefixed identifier distinct from the user UUID; the client secret is sas_-prefixed, generated server-side from 32 random bytes, and returned exactly once — at creation or rotation — never persisted or logged in plaintext.

At most two credentials may be active per account. That is what makes rotation zero-downtime: issue the new one, migrate the caller, revoke the old. Note it is a count-then-insert check, not an atomic conditional insert, so two concurrent issues can both briefly succeed — operational hygiene, not a security boundary, since each credential stays individually revocable.

The grant​

curl -X POST https://console.example.com/v1/auth/token \
-H 'Content-Type: application/json' \
-d '{"grantType":"client_credentials","clientId":"sa_...","clientSecret":"sas_..."}'
{ "accessToken": "eyJhbGciOiJSUzI1NiIs...", "tokenType": "Bearer", "expiresIn": 900 }

The endpoint is public and locked out the same way login is. Two things to expect that differ from an off-the-shelf OAuth2 client:

  • JSON, camelCase. Not RFC 6749's form-encoded grant_type/access_token shape — the platform is JSON-first throughout. A stock OAuth2 library needs a thin adapter or a hand-rolled request.
  • No refresh token. The client credential is already a long-lived secret; a service account simply repeats the grant when its access token expires.

Every rejection reason — unknown client ID, revoked credential, wrong secret, disabled account, a user that is not a service account — collapses into one indistinguishable error, and an unknown client ID still burns a dummy hash-verify so that path costs the same wall-clock time as a wrong secret. Failed attempts are throttled per source IP and per targeted client ID, through the same lockout counters login uses.

Audience​

The grant mints aud: "service". The operator host mux accepts the audience set operator, service; the client mux is unchanged and accepts client only. Service accounts therefore reach the Tier-1 operator surface and nothing else — a Tier-2 machine credential is out of scope and would be a separate design.

Admin surface​

MethodPathPurpose
GET/v1/admin/service-accountsList service accounts
POST/v1/admin/service-accountsCreate one — returns the client secret once
GET/v1/admin/service-accounts/{id}Get one with credential metadata
PATCH/v1/admin/service-accounts/{id}Rename, enable, or disable
POST/v1/admin/service-accounts/{id}/credentialsIssue a rotation credential — secret returned once
DELETE/v1/admin/service-accounts/{id}/credentials/{credentialId}Revoke a credential

Reads require auth.service_accounts.read. Every mutation requires auth.service_accounts.manage plus a step-up MFA proof fresher than 5 minutes, the same bar as every other secret-revealing admin mutation in this module. Revocation is deliberately not idempotent: revoking an already-revoked credential returns the same not-found outcome as revoking one that never existed, so there is no oracle distinguishing the two.

The four {id} routes above gate on a directory read, and they classify it. An id the directory answers for and does not know — and a target that is a human user rather than a machine one — is 404 service account not found, unchanged. A directory that could not be read at all is 503, carrying service_account_lookup_unavailable in the response detail: an outage is not proof the account was deleted, and reporting it as one sends an operator hunting for a deletion that never happened. This is the same rule the refresh path applies to refresh_lookup_unavailable, one surface over.

Operational caveats​

  • Disabling has bounded latency. It stops new grants instantly, but a token minted just before remains cryptographically valid for up to the access-token TTL (default 15 minutes). This is accepted because permissions are never embedded in the token — the authz evaluator resolves them fresh per request, so unbinding roles or disabling the account changes its authorization immediately.
  • Fails closed everywhere else. Password login, every OAuth flow, and all refresh-token read paths reject a service principal. The client-credentials grant is the only path that can mint a token for one.
  • No privileged roles. super_admin and administrator can never be assigned to a service account, and that guard fails closed even when the pre-read needed to classify the target is unavailable — the assignment is refused rather than risked.

Session lifetime​

Three separate lifetimes govern how long a user stays signed in. Before ADR-0017 only the first two existed, and the second was never documented as a timeout at all.

LifetimeControlled byDefaultWhat it means
Access tokenadmin accessTokenTTL (the level that governs — JWT_ACCESS_TOKEN_EXPIRY is a fallback a seeded install never reaches)15mHow long a minted access token is accepted. Range 1m–24h; longer values are clamped, because the Redis revocation denylist stores entries for 24h + 1min and a token must never outlive its own revocation entry.
Idle windowJWT_REFRESH_TOKEN_EXPIRY7dThis is the idle timeout. Rotation writes a fresh now + this on every refresh, so a session ends only after this long without activity — it is not a separate control.
Absolute capadmin sessionAbsoluteTTL30d (720h)The maximum total age of a session, measured from login, independent of activity. Range 1h–89d; clearing the field disables the cap.

Changing the access-token TTL. Set accessTokenTTL at /admin/modules/auth, or PATCH /v1/admin/modules/auth. The environment variable JWT_ACCESS_TOKEN_EXPIRY is the documented fallback, but on a normal deployment it is never reached: the field is declared in the auth module's config schema with a 15m default and no environment binding, so first boot seeds that value into module_configs and the config service returns the same default even for a field an operator has cleared — the admin value always wins. Setting the variable on its own changes nothing.

Reaching the absolute cap is a logout, not a denial: the session's refresh tokens are revoked, the session document is marked inactive, and the session id is pushed onto the revocation denylist — the same three steps as an administrative termination. The client receives a 401 with code: "session_max_age_reached".

Both refresh paths enforce the cap. GET /v1/auth/session mints an access token without rotating the refresh cookie (a deliberate anti-replay split), so a client calling only that endpoint would otherwise hold a session open forever.

Both refresh paths also read the session document on every call and refuse a session that has been terminated — by the user, an administrator, a password change, a replay verdict or the cap — with a 401 carrying code: "session_revoked", the same code a protected route answers once the session's access token meets the denylist, and with the refresh cookie expired. This is what stops a copy of the cookie from keeping a dead session alive when a rotation races the revocation. An unreadable session store on that read is the same 503 session_enforcement_unavailable the cap answers: an outage, never a sign-out.

Rotation only ever happens on an explicit refresh call (/refresh-cookie, /refresh). RequireAuth itself is bearer-only — it never reads or rotates the refresh cookie (ADR-0020); an expired access token is a plain 401 that the client answers with /refresh-cookie and a retry.

The same session_max_age_reached code comes back from any protected route once the terminated session's access token meets the revocation denylist — in practice that is the response a user actually sees, since the refresh endpoints are read by the SPA's own plumbing rather than shown to anyone. It is deliberately distinct from session_revoked: a session that simply reached its maximum age was not revoked, and the difference matters to whoever reads the resulting support ticket.

If session state cannot be evaluated at all, the refresh endpoints answer 503 session_enforcement_unavailable rather than 401. That is not a sign-out and a client must not treat it as one — both shipped SPAs keep the access token and retry on the next request. Reporting a storage outage as an authentication failure would train clients to discard sessions that are still perfectly valid.

The same argument covers the rotation itself. When the rotation path cannot complete — a cookie candidate cannot be looked up, the token store or the user store is unreachable, signing fails, the rotating write fails, or the server holds no verifying key to check the presented refresh token with — POST /v1/auth/{tier}/refresh-cookie and POST /v1/auth/{tier}/refresh answer 503 refresh_lookup_unavailable rather than 401. It is a separate code from session_enforcement_unavailable only so the failing subsystem is legible in a support ticket; both are 503 and every client already treats 503 as transient. An account that has genuinely been deleted still answers 401 — otherwise a dead session could never end.

A successful POST /v1/auth/{tier}/refresh-cookie answers with the new access token, its expiresIn, the user and the linked providers — and nothing durable. The rotated refresh token is written only into the HttpOnly cookie; it never appears in the body, because a body is readable by any script on the SPA origin and the cookie's whole purpose is to keep that credential out of script's reach. Only a caller that sent {"refreshToken"} in the request body (no cookie to mint) receives the successor in the body.

The bootstrap endpoint GET /v1/auth/session is covered end to end too. Its cookie-classification step answers 503, and so does the read-only mint it performs afterwards (MintAccessTokenFromRefresh) — its own token lookup, its own user lookup and its own signing call. That mint reads the store a second time, after the cookie has already been classified, so a blip opening between the two used to answer session bootstrap with a codeless 401: the one status a client reads as the end of the session. A genuinely deleted account still answers 401 there as well, so a dead session can still end. Every other validation failure at those endpoints — a malformed JWT, a bad signature, a wrong token type, a wrong audience, an expired refresh row — is a verdict on the credential and keeps its 401; only the server's own failures move.

Protected routes have their own version of the same rule. If no verifying key is loaded, RequireAuth can check no bearer on any route on either tier, and it answers 503 token_verification_unavailable instead of the codeless 401 it used to give. No WWW-Authenticate accompanies it: the header names a scheme to retry with and there is none. A boot with no key material is not a blip — no client-side retry helps — so this is the server saying it cannot authenticate anyone, not that this session ended, and neither SPA clears anything on it. Nothing about what is accepted changes.

Upgrading​

Deploying the absolute cap for the first time signs out any session that began more than sessionAbsoluteTTL ago, on its next refresh. Clear the field at /admin/modules to keep the previous unbounded behaviour.

Retention​

The three lifetimes above govern authentication; retention is a separate question again. Session documents are audit and device history that the risk scorer reads — nothing authenticates off them — and are deleted at expiresAt, which every login sets to 90 days out (models.AuthSessionRetention). The index deletes the row when that timestamp passes; it does not add a further 90 days on top of it, so 90 days from login is the whole of the window.

That window is also the risk scorer's history depth: its device-fingerprint and IP factors count session rows, so a device or IP last seen more than 90 days ago reads as new and scores accordingly.

Refresh-token rows have their own retention story, covered next.

Refresh-token retention​

Expired refresh-token rows are deleted by a background sweep the module starts with itself, not by a Mongo TTL index — the row may only go once its own expiresAt is past, and the first cleanup of an upgraded install needs bounded progress and a visible backlog, which a TTL monitor gives you neither of. See ADR-0017.

Three things an operator should know about it:

  • One replica sweeps, whichever wins a Redis lease. The lease is held across the idle wait as well as the drain, so the per-cycle bound of 5,000 rows per tier is a cluster-wide figure rather than something you multiply by your replica count. If Redis is unreachable, or the lease is lost to another replica, the sweeper steps down and re-contends five minutes later rather than stopping: a Redis restart or a failover costs one cycle, not the rest of the process's life. Authentication is never affected either way.
  • The cadence adapts; the batch does not. While a batch reports more work waiting, the next pass is five minutes away; once it comes back dry, six hours. A million-row backlog drains in under a day at roughly 17 deletes per second, and the loop returns to idle on its own.
  • There is nothing to configure and nothing to trigger. The two intervals are maintenance constants, and because the sweep drains itself there is no "run extra cycles during a maintenance window" procedure. Watch orkestra_auth_token_sweep_backlog_estimate{tier} fall to zero.

Permissions​

KeySystem setGrants
auth.self—Edit your own password and sessions
auth.mfa.self—Enroll, verify, and remove your own MFA factors
system.users.mfa_reset✅Reset another user's MFA factors
system.users.password_reset✅Trigger a password-reset email for another user
system.users.email_verify_resend✅Resend another user's email-verification mail
system.users.oauth_unlink✅Unlink an OAuth identity from another user
auth.service_accounts.read✅List and inspect service accounts
auth.service_accounts.manage✅Create service accounts, issue, rotate, and revoke credentials

Keys in the system set are granted by the super_admin / administrator / developer role shortcuts without an explicit global binding, and the seeder excludes them from org roles. Without that flag a freshly created administrator — one with no binding to the seeded administrator role — would 403 on the admin credential endpoints.

Config​

auth is by far the largest configuration surface in the base — 65 fields at /admin/modules/auth, arranged into a group rail: Registration, Login & Sessions, Password Policy, MFA, OAuth Providers (with Google, Apple, GitHub, and Discord as child nodes), Anti-abuse & Notifications, and Sessions & Account. Fields are seeded from their EnvVar on first boot and owned by the ConfigService thereafter — see Module configuration and OAuth providers for the operator walkthrough.

The Login & Sessions group's sessionAbsoluteTTL is the admin-facing field behind the absolute cap — see Session lifetime above for what it does and enforces.

:::note Where the routes are This page names the surfaces, not all ~90 of the module's operations. The generated per-endpoint reference — request/response schemas included — is under API Reference, grouped by tag: Authentication, MFA, WebAuthn, Auth - Device Trust, Service Accounts, Mobile, and Self-Service. :::