Authentication

JWT, sessions, OAuth, captcha & the full auth surface

Authentication is the largest and most consequential block in NucleusConfigOptions. It decides whether your service is an Identity Provider or a resource server, how tokens are minted and delivered, what makes a valid password, and which of the dozen-plus auth routes are exposed.

The design philosophy is opt-in: nothing is mounted unless you ask for it. You enable the routes you need, and each carries a small, predictable set of switches. Secrets are always referenced by environment-variable name, and the frontend components in nucleus-core-ts/fe read this very config so the UI and API can never drift apart.

This page walks the block top to bottom — modes and tokens first, then every route, then the external integrations (captcha, OAuth, API keys, cohorts).

Modes — IDP vs consumer#

Authentication runs in one of two modes, and the choice reshapes what the framework mounts. full mode makes the service an Identity Provider; consumer mode makes it a resource server that trusts tokens minted elsewhere.

enabledbooleanOptional

Master switch for the entire authentication subsystem.

Defaultfalse
mode'full' | 'consumer'Required

Required when enabled. full provisions the auth tables, mounts all auth routes and runs the session middleware — this is your IDP. consumer mounts no auth tables or routes; it only verifies incoming JWTs, delegating login to a separate IDP.

  • full— IDP: auth tables + auth routes + JWT/session middleware.
  • consumer— Resource server: JWT-verify middleware only, no auth tables or routes.
defaultRolestringOptional

Role assigned to every newly created user (register, OAuth auto-create, invite). If the named role is missing a warning is logged and sign-up still succeeds — default-role assignment is intentionally non-fatal.

Example: "user-free"

idpUrlstringOptional

For consumer mode with authorization enabled: the URL (or env-var name) of the IDP whose POST /auth/check this service calls for scope-based decisions.

Example: "http://idp-api:9000"

Cookies & cross-subdomain auth#

Tokens are delivered as HttpOnly cookies by default. These two settings control how those cookies behave across your domains and how aggressively the middleware refreshes them.

cookieDomainstringOptional

Cookie Domain attribute for sharing auth across subdomains. Use a leading dot — ".vorion.ai" — to cover every subdomain. Accepts a literal value or an env-var name. Omit for host-only cookies.

cookieMaxAgeBufferSecondsnumberOptional

A safety buffer applied to cookie Max-Age so the middleware proactively refreshes a token slightly before it actually expires, avoiding edge-of-expiry race conditions.

Default0
tenantBinding'strict' | 'lenient' | 'off'Optional

Signed tenant binding for schema-per-tenant deployments, where the active tenant is resolved from the request independently of the caller — so an access token minted for tenant A would otherwise be structurally valid against tenant B. When enabled, tokens carry a signed tenant claim the middleware checks against the resolved x-tenant-schema. 'lenient' (default) enforces the claim when present but allows legacy tokens without it (no flag-day logout; closes within the token TTL); 'strict' requires the claim whenever a tenant is resolved; 'off' disables it. Single-tenant deployments are unaffected in any mode.

  • lenient— Enforce the tenant claim when present; allow legacy tokens that lack it.
  • strict— Require the tenant claim whenever a tenant is resolved.
  • off— Disable tenant binding entirely.
Default'lenient'

Security controls#

Top-level authentication hardening switches. These are material security controls that live alongside the mode and token settings but govern link-poisoning, account enumeration, and CSRF rather than token shape.

trustedAppOriginsstring[] | stringOptional

Allow-list of frontend origins permitted to drive the outbound host of email links (reset / magic-link / verify / invite) via the x-app-origin / Origin / x-forwarded-host headers. The configured action redirect URL's own host is ALWAYS trusted; this adds extra hosts for multi-tenant deployments where tenants live on different subdomains. Accepts exact hosts or *.domain wildcards. A header origin not on this list (and not the configured URL's host) is ignored, preventing link poisoning / token exfiltration via a spoofed host header. Besides an array, a single bare string is accepted and resolved as an env-var name (e.g. "FRONTEND_URL") — the same env-var-name resolution cookieDomain uses — whose value may itself be a comma/space-separated list of origins; a literal comma-separated string also works. This lets a deployment avoid hardcoding customer domains in the image.

Example: ["app.example.com", "*.tenants.example.com"] // or "FRONTEND_URL"

preventUserEnumerationbooleanOptional

When true, register / resend-verification / invite return a UNIFORM generic response whether or not the email already exists, so an attacker cannot enumerate registered accounts by diffing responses. Side effects (creating users, sending mail, cooldown/attempt limits) still run silently server-side. Default false preserves the explicit 'email already registered' / 'already verified' responses; enable for public sign-up.

Defaultfalse
csrfboolean | { enabled?: boolean; cookieName?: string; headerName?: string }Optional

Double-submit-cookie CSRF protection for COOKIE-authenticated, state-changing requests (POST/PUT/PATCH/DELETE). Default off (cookies are already SameSite=Lax). When enabled the middleware sets a readable csrf_token cookie and requires the client to echo it in an x-csrf-token header; requests authenticated via an Authorization header are exempt (not CSRF-able). Pass true to enable with defaults, or an object to rename the cookie / header.

Defaultfalse

Password policy#

The shape of an acceptable password. Note this policy is primarily a FRONTEND contract: the nucleus-core-ts/fe components read it to render a live strength meter and block weak input. Server-side, only the cohort bulk-create endpoint reads the configurable rules — and even it reads just min/max length + require upper/lower/number/special, ignoring the custom specialChars set (it uses a fixed special-char regex) and the preventCommonPasswords / preventUserInfoInPassword flags. Every other write path — register AND the password change / set / reset routes — enforces a fixed baseline via validatePasswordStrength (min 8 chars + at least one uppercase, lowercase, and digit), ignoring the finer flags here; a password lacking any of those is rejected with 400 'Password too weak'.

passwordPolicyobjectOptional

Composable rules for what makes an acceptable password.

minLengthnumberOptional

Minimum character count.

maxLengthnumberOptional

Maximum character count (guards against DoS via huge inputs).

requireUppercasebooleanOptional

Require at least one A–Z character.

requireLowercasebooleanOptional

Require at least one a–z character.

requireNumberbooleanOptional

Require at least one digit.

requireSpecialCharbooleanOptional

Require one character from specialChars.

specialCharsstringOptional

The exact set of characters counted as special.

preventCommonPasswordsbooleanOptional

Reject passwords found in a common-password blocklist.

preventUserInfoInPasswordbooleanOptional

Reject passwords containing the user's email or name fragments.

showStrengthIndicatorbooleanOptional

Hint the FE to render a live strength meter.

Token strategy#

Nucleus issues two signed JWTs plus one opaque session token. The access token (JWT) authorizes API calls (short-lived). The refresh token (JWT) mints new access tokens (long-lived). The session token is NOT a JWT — it is an opaque crypto.randomUUID() stored server-side in Redis, resolved per request to identify the device session for management and revocation. All three share the same config field shape (shown under accessToken below), but on the session token name, expiresIn, setHeadersEnabled and returnJson apply (the last two gate whether the opaque session cookie is set and whether sessionId is echoed in the JSON body) — its secret is only presence-checked at boot and algorithm / issuer / audience are never read to sign anything (the id is not a JWT).

accessTokenobjectOptional

Short-lived API credential — minutes, not days. Verified on every protected request.

secretstringOptional

Name of the env var holding the signing secret (e.g. "JWT_SECRET"). Resolved at boot — never the literal key.

expiresInstringOptional

Lifetime as a duration string: 15m, 1h, 7d, 30d. Drives both the JWT exp claim and the cookie Max-Age.

algorithmAlgorithmOptional

JWT signing algorithm — HS256 by default.

issuerstringOptional

The iss claim written into and verified on the token.

audiencestringOptional

The aud claim written into and verified on the token.

namestringOptional

The cookie name this token is stored under (e.g. access_token, refresh_token, session_token).

setHeadersEnabledbooleanOptional

When true the token is written as an HttpOnly Set-Cookie on auth responses. Disable for pure header/JSON token flows.

Defaulttrue
returnJsonbooleanOptional

When true the raw token is also included in the JSON response body — useful for native/mobile clients that don't use cookies. The config default is true, but the /auth/refresh route is an exception: it defaults the rotated refreshToken's returnJson to false, so the new refresh token is delivered only as a cookie unless you set it explicitly (login / register / oauth still echo the refresh token in JSON).

Defaulttrue
refreshTokenobjectOptional

Long-lived (days). Exchanged at the refresh route for a fresh access token. Same fields as accessToken, with its own secret, name and a longer expiresIn (e.g. 7d).

sessionTokenobjectOptional

Identifies the device session (e.g. 30d) so users can list and revoke devices. Unlike the other two this is NOT a JWT — it is an opaque random id kept in Redis. Its secret must still be set in full mode (boot + middleware presence check) but is never used to sign; algorithm / issuer / audience are ignored. name (cookie name), expiresIn, setHeadersEnabled and returnJson apply (see the callout below).

The shape of an auth route#

Every auth route shares three fields, so once you understand these you understand them all. Routes you don't configure simply aren't mounted — you opt in to exactly the surface you need.

<route>objectOptional

Each route object (login, logout, register, …) carries at least these three fields, plus route-specific extras documented below.

enabledbooleanOptional

Whether the route is mounted at all.

isPublicbooleanOptional

When true the route skips the auth middleware (anyone can call it). Login and register are public; logout and me are not.

routestringOptional

The path the handler is mounted at, e.g. /auth/login.

Login#

Email + password sign-in that mints the three tokens.

loginobjectOptional

Credential sign-in configuration.

rememberMebooleanOptional

Allow a persistent session — extends cookie lifetimes when the client opts in.

redirectUrlstringOptional

Where the FE should land after a successful login.

captcha{ enabled?: boolean; requireAfterFailures?: number }Optional

Require a solved captcha before accepting credentials — a strong brute-force deterrent. requireAfterFailures tunes WHEN it is demanded: 0 or omitted = always require captcha while enabled (legacy); N = only once the target user already has N failed attempts (e.g. 1 → required from the 2nd attempt onward). The login handler honors requireAfterFailures even though it is not yet surfaced on the public login.captcha config type — like includeClaims, it currently lives only on the internal auth config.

Register & email verification#

Self-service sign-up. It can optionally create a profile row, surface name fields, link terms/privacy URLs, and gate the account behind email verification with fully templated mails.

registerobjectOptional

Sign-up configuration.

showFirstName / showLastNamebooleanOptional

Toggle name fields on the FE register form.

createProfileOnRegisterbooleanOptional

Insert a linked profile row as part of sign-up.

termsUrl / privacyUrlstringOptional

Links rendered beside the consent checkbox.

emailVerificationobjectOptional

Gate the account until the email is confirmed. Fields: enabled, provider ('gmail' | 'azure'), tokenExpiresIn, redirectUrl, resendCooldown, maxResendAttempts, and templates for the verification email (subject + htmlTemplatePath) and the welcome email (enabled + subject + htmlTemplatePath — the welcome send is gated by its own enabled flag).

Sessions & device management#

Beyond issuing tokens, Nucleus tracks each session as a device. Users can list active devices and revoke them; new devices can require email approval before they're trusted.

sessionsobjectOptional

Device-session policy.

maxActiveSessionsnumberOptional

Cap on concurrent sessions per user; oldest is evicted past the limit.

inactivityTimeoutstringOptional

Idle duration after which a session expires (e.g. 30d).

allowMultipleDevicesbooleanOptional

Permit simultaneous sessions on different devices.

trustNewDevicesbooleanOptional

When false, a login from an unrecognised device requires approval before it's granted full access.

notifyOnNewDevicebooleanOptional

Email the user when a new device signs in.

approvalRedirectUrlstringOptional

Production URL the device-approval email links to. Always set this to a real URL — proxies make request-origin unreliable.

deviceTrustobjectOptional

Remembered-device trust (opt-in, off by default). A server-issued high-entropy device TOKEN — stored SHA-256-hashed in the trusted_devices table, carried in an HttpOnly cookie — replaces the spoofable UA fingerprint as the 'known device' anchor for new-device approval. It is a recognition marker layered on full auth, never a replacement. Fields: enabled, tokenExpiresIn ('30d'), cookieName ('device_token'), cookiePrefix ('' | '__Secure-' | '__Host-'), sameSite ('Strict' | 'Lax', default Lax), onUnidentifiable ('require_approval' default | 'deny' hard-blocks tokenless devices with 403), approvalMethods (default ['password','magic_link','sso','webauthn','oauth:*','register']). Requires the trusted_devices table. A password CHANGE (not an initial OAuth-user password set) untrusts all remembered devices — passwordChange calls revokeUserDevices when device-trust is enabled (the auth routes read it from authentication.deviceTrust, which the plugin re-surfaces from authentication.sessions.deviceTrust) — so a compromise-recovery change can't leave a stolen device token skipping new-device approval (ASVS 7.4.3).

adminRolesstring[]Optional

Roles that — alongside godmin — may list and revoke OTHER users' sessions. Feeds the godmin guard as allowedRoles; empty (the default) means godmin only. Powers the two admin session routes described below.

Passwordless, recovery & lifecycle routes#

The remaining routes round out the identity surface. Each follows the route triad (enabled / isPublic / route) plus the extras noted here.

magicLinkobjectOptional

Passwordless sign-in via emailed link. Extras: verifyRoute, expiresIn (e.g. 15m), redirectUrl.

inviteobjectOptional

Admin-initiated user creation. Extras: tokenExpiresIn (e.g. 7d), redirectUrl to a set-password page.

passwordResetobjectOptional

Forgot-password flow. Extra: redirectUrl to the reset form.

passwordChangeobjectOptional

Change password while authenticated (requires current password).

passwordSetobjectOptional

Set an initial password (invited/OAuth users with none yet).

emailVerificationobjectOptional

Declared in the type (route, resendRoute, isPublic, enabled) but NOT consumed by the plugin — setting it has no effect. The verify + resend endpoints are generated from register.emailVerification at the fixed public paths GET /verify-email and POST /resend-verification.

refreshobjectOptional

Exchanges a refresh token for a new access token. Usually isPublic.

logoutobjectOptional

Revokes the session and clears auth cookies.

WebAuthn / passkeys#

Hardware-backed, phishing-resistant authentication. Configure the relying party so browsers bind credentials to your origin.

webauthnobjectOptional

Passkey configuration.

rpNamestringOptional

Human-readable relying-party name shown in the OS/browser prompt.

rpIDstringOptional

Relying-party ID — your registrable domain (e.g. vorion.ai).

expectedOriginsstring[]Optional

Allowed origins that may complete a ceremony.

challengeTtlstringOptional

How long a registration/authentication challenge stays valid (default 5m).

userVerification'preferred' | 'required' | 'discouraged'Optional

User-verification (PIN/biometric) policy. 'required' mandates and enforces UV (requireUserVerification on verify), excluding authenticators that cannot do it.

Default'preferred'

Current user (/auth/me)#

Returns the authenticated user. The include* flags let you eager-load related records in one round-trip instead of N follow-up calls. The response data object ALWAYS carries a claims array (in addition to user, profile, addresses, phones, files and roles) — it is initialized on every request and stays empty unless populated by includeClaims. The response never returns auth-secret columns from the user row — the password is replaced by a boolean hasPassword, and the email-verification token (+ expiry), password-reset token and refresh-token hash are stripped via the centralized output redactor (redactSensitiveOutput) before the row crosses the wire. Beyond that fixed auth-secret redaction, any column declared sensitive: true in config is ALSO withheld from the /auth/me body — regardless of the caller owning the row — via stripUnclaimedSensitive (which also scrubs the embedded profile, addresses, phones, files and roles). A caller who genuinely needs a sensitive value reads it through the entity API with the per-column read claim (scoped id=self:id for own-row access).

meobjectOptional

Profile endpoint configuration.

includeProfilebooleanOptional

Embed the linked profile row.

includeAddressesbooleanOptional

Embed the user's addresses.

includePhonesbooleanOptional

Embed the user's phone numbers.

includeFilesbooleanOptional

Embed associated files (e.g. avatar).

includeRolesbooleanOptional

Embed assigned roles.

includeClaimsbooleanOptional

Eager-load the user's assigned role-claims into the always-present claims array (deduplicated { action, scope } pairs resolved through userRoles → roleClaims → claims). Note: the handler reads this flag, but it is not yet declared on the public me config type or in config.nucleus.json — the other five include* flags are.

Captcha#

A self-hosted challenge generated and validated server-side, backed by Redis. No third-party script, no external network call — useful in front of login and register.

captchaobjectOptional

Challenge configuration.

enabledbooleanOptional

The captcha service is off unless this is true.

routestringOptional

Base path for the challenge generate/validate endpoints.

Default'/auth/captcha'
isPublicbooleanOptional

Exposes the challenge generate/validate endpoints without auth.

type'math' | 'image' | 'puzzle' | 'text'Optional

Challenge style: arithmetic, distorted image, drag-to-fit puzzle, or text transcription.

Default'math'
difficulty'easy' | 'medium' | 'hard'Optional

How hard the generated challenge is.

Default'medium'
expiresInstringOptional

How long a generated challenge stays solvable (e.g. 5m).

Default'5m'
maxAttemptsnumberOptional

Wrong answers allowed before a challenge is burned.

Default3
caseSensitivebooleanOptional

Whether text answers are compared case-sensitively.

Defaultfalse
rateLimitobjectOptional

Declared as maxGeneratePerMinute / maxGeneratePerHour, but currently NOT forwarded to the CaptchaService — generation limits stay at the hardcoded defaults (10/min, 100/hour).

OAuth & social login#

Sign in with external identity providers. Nucleus handles the full authorization-code dance, optional account linking, and auto-provisioning of users on first login.

oauthobjectOptional

Social-login configuration.

enabledbooleanOptional

OAuth is inert unless this is true.

basePathstringOptional

Prefix for the OAuth start/callback routes.

Default'/auth/oauth'
successRedirectUrlstringOptional

Where the browser lands after a successful OAuth flow.

Default'/'
errorRedirectUrlstringOptional

Where the browser lands after the OAuth flow fails.

Default'/login'
allowAccountLinkingbooleanOptional

Gates the EXPLICIT account-linking flow only: the GET /auth/oauth/link/:provider endpoint (returns 403 'Account linking is disabled' when false) and linkUserId-initiated linking to the currently signed-in user. Note: auto-merging an OAuth identity into an existing local account matched by a VERIFIED provider email on ordinary login always happens (guarded only by provider email-verification, never by this flag).

Defaulttrue
autoCreateUserbooleanOptional

Create a user automatically on first successful OAuth login.

Defaulttrue
sendInviteOnCreatebooleanOptional

Email an invite/set-password link when a user is auto-created.

Defaultfalse
stateTtlSecondsnumberOptional

Lifetime of the CSRF state token guarding the callback.

Default600
profileSyncobjectOptional

Copies claims from the OAuth identity onto your own users / profiles columns on login and account-link, so IDP attributes (Microsoft Graph fields, id_token claims) land in the database. Children: enabled (boolean — the whole sync is skipped when explicitly false); syncOnEveryLogin (boolean, default true — set false to sync only on the first login that creates the user, never on subsequent logins); createProfileIfMissing (boolean, default true — inserts a profiles row on first sync when the user has none); and mappings (REQUIRED array). Each mapping is { claim, target, source?, overwrite? }: claim is the source attribute name and supports dotted paths into nested Graph objects (e.g. 'onPremisesExtensionAttributes.extensionAttribute11'), with a literal key of that exact name winning over path traversal; target must be 'users.<column>' or 'profiles.<column>'; source is 'graph' | 'token' (default 'graph'); overwrite (default true) — when false the column is filled only if currently empty. Claims are read from the id_token merged with the Microsoft Graph body, and the Graph $select projection is derived from the mapping claim roots. A claim the provider omits or returns blank NEVER blanks a column (it is skipped). When two mappings target the SAME column, the FIRST that yields a non-empty value wins — that is how a fallback chain like 'use extensionAttribute11, else extensionAttribute10' is expressed.

providersobjectOptional

Per-provider credentials. Each takes clientId, clientSecret, redirectUri, optional scopes[] and extraAuthParams. microsoft adds tenantId; custom adds authorizationUrl, tokenUrl and userInfoUrl. Only google, github and microsoft have dedicated implementations. discord / facebook / twitter / apple have config slots but no built-in provider — they fall through to the generic OAuth path (which needs authorizationUrl / tokenUrl those slots don't expose) and fail at request time; use custom for any other provider. clientId, clientSecret, redirectUri and tenantId each accept an env-var NAME or a literal (all four resolve through the same helper; an UPPER_SNAKE name with no matching variable resolves to nothing). The difference is enforcement: clientId and clientSecret are REQUIRED — the app fails to boot with an env-validation error if they resolve to nothing (unless config.secrets.enabled lets the encrypted store / admin panel supply them at runtime) — while redirectUri and tenantId are optional and unvalidated.

API keys#

Programmatic credentials for server-to-server access, independent of user sessions. Keys can belong to a user or to an application.

apiKeysobjectOptional

API-key issuance policy.

enabledbooleanOptional

The API-key feature is off unless this is true.

routestringOptional

Base path for the API-key management endpoints.

Default'/auth/api-keys'
keyPrefix'nk_live' | 'nk_test'Optional

Prefix stamped on generated keys so live and test credentials are visually distinct.

Default'nk_live'
maxKeysPerUsernumberOptional

Cap on active keys a single user may hold.

Default10
defaultExpiresInstringOptional

Default key lifetime when the caller doesn't specify one.

allowApplicationKeysbooleanOptional

Permit application-owned keys (not tied to a user). Requests for application keys are rejected with 403 when false.

Defaulttrue
preventApiKeyManagementbooleanOptional

Blocks creating (POST) and updating (PATCH) keys, but NOT revoking them (DELETE) or listing (GET) — so a locked-down deployment can still rotate/retire existing keys.

Defaultfalse

Cohorts & email-exempt domains#

Two smaller switches that round out the auth surface — group-based user organisation and a verification bypass for trusted domains.

cohorts{ enabled?: boolean; basePath?: string }Optional

Cohort endpoints for grouping users into segments — all cohort endpoints are GODMIN-ONLY (every handler requires a godmin user via requireGodmin; non-godmin callers get 403 Forbidden). enabled defaults to TRUE — the cohort endpoints are auto-added (non-consumer mode with a DB) unless enabled is set false. basePath defaults to '/auth/admin/cohorts'.

emailExemptDomainsstring[]Optional

Domains (and their sub-domains, case-insensitive) exempted from SECURITY emails — registration verification, password-reset, and new-device approval — via isEmailExempt. Perfect for trusted internal domains where those mails are redundant.

Example: ["yourcompany.com", "internal.local"]

Under the hood — tokens & sessions#

A successful login mints the credentials directly: it signs the access token and then the refresh token, then creates a server-side session (SessionRecord in Redis) and persists the device row. A login-minted access token carries no sessionId (the silent-refresh path re-mints one that DOES embed sessionId, but it is never used for lookup) — its binding to the session is enforced at request time via sub === session.userId, not by an embedded id. Understanding the pieces explains why sessions — not individual refresh tokens — are the unit of revocation, and why sessions are multi-device.

session creationcreateSession → generateSessionOptional

Login writes a SessionRecord to Redis (opaque crypto.randomUUID() id) and, when the user_sessions table is present, a matching DB row. The session id is what the session_token cookie carries and what the middleware resolves per request. (There is an issueSession() orchestrator primitive in Services/Auth/Session, but the live login route does not use it — it calls signRefreshToken + createSession directly.)

access tokenHS256 JWT · ~15m defaultOptional

A hand-rolled HS256 JWT (sub = userId, plus iat/exp). Beyond those it embeds the user's roles as a custom claim (when they have any), and — only in JWT embed mode (jwtClaimsMode !== 'resolve', the default) — the user's claims plus per-action claimScopes (row-level ownership scope) so embed-mode consumers can enforce it; in resolve mode claims/claimScopes are omitted and re-resolved from Redis at request time. H9 (multi-tenant): the login path also stamps a `tenant` custom claim equal to the resolved x-tenant-schema whenever a tenant is present, and the request middleware rejects (401) an otherwise-valid token whose `tenant` does not match the request's tenant (verifyTenantBinding) — the default 'lenient' mode still rejects a present-but-mismatched claim (it only tolerates a token with no tenant claim at all; 'strict' rejects that too). iss/aud are stamped only when authentication.accessToken.issuer/audience are configured — both are undefined by default and are therefore omitted from the token; the live login path does not embed a sessionId either. SECURITY (M5): the LOGIN-minted token (signAccessToken) does NOT embed the refresh token — the refresh credential is returned separately so capturing this short-lived access token never yields the long-lived refresh token. (This applies to the login path only; the middleware's silent-refresh re-mint takes a different path that DOES carry the refresh token — see 'single-flight refresh'.) The middleware also asserts the access token's sub equals the resolved session's userId. Default TTL is 15 minutes; accessToken config tunes it.

refresh tokenstateless JWT (distinct refresh secret)Optional

A signed JWT verified statelessly at /auth/refresh — the route checks the signature with the refresh secret (which is DISTINCT from the access secret, and that is what actually stops an access token being replayed as a refresh token; an audience check only runs if authentication.refreshToken.audience is configured, and it asserts the token's aud equals whatever value the operator set there — not a hardcoded 'refresh'), with NO Redis/session lookup, so rememberMe works without server state. A consequence: a single refresh token is NOT individually revocable. Revocation is done at the SESSION layer — logout, logout-everywhere and per-session revoke delete the session (and revoke the DB row), and the access-token↔session subject binding makes that immediately effective. /auth/refresh additionally re-checks account state (isActive / isLocked / cohort-expiry) and re-fetches fresh roles/claims on every use.

single-flight refreshrefreshAccessTokenWithLockOptional

This lock guards the middleware's AUTOMATIC silent-refresh — the server-side branch that re-mints the ACCESS token — NOT the explicit /auth/refresh route (which uses no lock and rotates BOTH tokens). The silent-refresh branch is narrowly gated: it fires only when the access token is invalid/expired AND a still-valid refresh token accompanies the request AND the session is a rememberMe session (sessionData.rememberMe === true) — a non-rememberMe session, or a request without a valid refresh token, never triggers the silent re-mint. Before minting it also re-checks the account is active + unlocked via evaluateAccountState (a kill-switch that /auth/refresh performs too), so an admin deactivation/lock takes effect on this path. On this path only the access token is re-minted (the generateToken callback calls signNewAccessToken); the refresh token is untouched, so 'access-token re-minting' is more accurate than 'token rotation' here. NOTE — unlike the login-minted token, signNewAccessToken embeds the CURRENT refresh token as the refreshTokenId custom claim (the caller feeds it the raw tokens.refresh_token), so the silently re-minted access token DOES contain the long-lived refresh token; capturing this particular token would yield it, and the M5 'never' above holds for the login-minted token, not this one. Access-token re-minting is serialised with a Redis lock (acquireLock in refreshAccessTokenWithLock) so a burst of tabs hitting an expired access token at once re-mints exactly once instead of racing. The lock key is per-user (refresh_lock:userId) while the freshly-minted token is cached per user:session (access_token:userId:sessionId); lock losers waitForLock and read that cached token.

SessionRecordRedis — multi-deviceOptional

Each session stores id, userId, createdAt/expiresAt/lastActiveAt, optional clientMeta, deviceInfo (name, type desktop|mobile|tablet|unknown, browser, OS, IP, userAgent, and optional geo enrichment locationCountry/locationCity set from the request IP at login), loginMethod and rememberMe. fingerprintHash and refreshTokenHash are declared on the type but are NOT populated by the live login path (createSession forwards neither), so a real login session leaves them undefined. This per-device record is exactly what the Sessions/Devices endpoints (and the DevicesPage UI) list and revoke.

Under the hood — passwords & device binding#

Two more primitives protect the credential and the session: how passwords are stored, and how a session is bound to the device that created it.

password hashingbcrypt (Bun password, cost 10)Optional

The live auth path hashes passwords with bcrypt (cost 10) via Bun's built-in `password` module — hashPassword() in register/utils.ts calls password.hash(plain, { algorithm: 'bcrypt', cost: 10 }), and verifyPassword() in login/utils.ts calls password.verify(). The same bcrypt hashPassword is reused by the register, password-set, password-change and password-reset routes. (A separate scryptSync primitive exists under Services/Auth/Password/Generate + Validate, but it has no auth call sites — it is not wired into the live login/registration routes.) The password-policy config governs what is allowed before this point.

device fingerprintdeviceHint → deviceFingerprintOptional

The live runtime does NOT do a per-request fingerprint recompute-and-compare. Device identity comes from the client's deviceHint (deviceInfo.deviceHint → deviceFingerprint), stored on the session / DB row by saveSessionToDb and used to detect a new device for the approval flow (config.authentication.sessions / deviceTrust). The Services/Auth/Fingerprint Generate/Validate primitives and SessionRecord.fingerprintHash exist but are not populated by the login path (createSession forwards no fingerprintHash), so there is no fingerprintValid check on ordinary requests.

session validationinline middleware checkOptional

The request middleware runs its own inline validation rather than the ValidateSessionResult primitive: it verifies the access-token JWT signature, loads the live Redis session record, then asserts String(jwt.payload.sub) === String(session.userId), returning 401 on mismatch or a missing session. It never invokes validateSession and does no fingerprint comparison. The Services/Auth/SessionStore validateSession / ValidateSessionResult type is an available primitive with no live call sites.

Related sections