Why this exists
MFA enforcement protects against credential-leak risk on admin accounts. Even if an attacker steals a session cookie or API password, they cannot mutate decisioning configuration (offers, contact policies, decision flows, MCP playbooks, models, approvals, etc.) without the second factor. The enforcement runs in edge middleware for low latency — typically <5ms additional overhead per request — and applies to every state-changing HTTP method (POST / PUT / PATCH / DELETE) on/api/* routes.
How the step-up proof works (server-issued HMAC cookie)
The freshness proof is a server-issued HMAC-SHA256 signed cookie (kaireon_stepup),
not a client-asserted timestamp. Previous versions trusted a mfaVerifiedAt timestamp
pushed through NextAuth session.update() — any client could mint its own freshness
without completing a real challenge. That path is no longer trusted.
The new flow:
- Admin signs in with email + password (or Google OAuth).
-
If the user has MFA enabled, the JWT carries
mfaPending = true. -
Admin attempts a state-changing API call. Middleware validates the
kaireon_stepupcookie. If the cookie is absent, expired, tampered, or signed for a different user, middleware returns403 MFA_REQUIRED: -
Client calls
POST /api/v1/auth/mfawith{ action: "verify", token: "<6-digit TOTP>" }(or completes a WebAuthn verify-finish ceremony). On success the server mints akaireon_stepupcookie:- Format:
base64url(JSON{sub, iat}).<hex HMAC-SHA256>— signed withNEXTAUTH_SECRET - The cookie is
httpOnly,sameSite=strict,securein production - TTL: 15 minutes (
STEP_UP_TTL_MS = 15 * 60 * 1000)
- Format:
-
Subsequent admin writes pass as long as the cookie is fresh. No
session.update()call is needed — the cookie is the sole freshness proof.
Step-up TTL
Default is 15 minutes (hardcoded insrc/lib/auth/step-up-edge.ts as
STEP_UP_TTL_MS = 15 * 60 * 1000). The TTL is intentionally short to limit blast
radius if a session is hijacked. Every successful verify resets the timer.
There is no per-tenant configuration today; changing the TTL requires a code change
and redeploy.
What’s bypassed
- GET / HEAD / OPTIONS requests — no enforcement (read-only)
- Non-admin users — no enforcement (the gate is admin-only)
- Users with MFA not enabled — no enforcement (
mfaPendingisfalse) /api/v1/auth/mfaitself — must be reachable to do the verify/api/auth/*(NextAuth handlers) — needed for sign-in flow- API-key-authenticated server-to-server calls — these don’t carry a JWT and are gated separately by API-key scope
Kill switch (incident only)
SetMFA_ENFORCEMENT_DISABLED=true in the deployment environment to
bypass enforcement for all requests. This is only for incident
recovery — for example, if the TOTP server’s clock is drifting and
legitimate codes are being rejected.
When set, requests that would have been blocked are passed through with
no logging change. Set the env var back to false (or unset) and redeploy
to re-enable.
Tenant-wide mandatory MFA enrollment (a separate control)
Everything above is the admin step-up gate — time-windowed, admin-only, keyed on whether this session verified recently. There is a second, independent gate: a tenant admin can require every user, regardless of role, to have MFA enrolled at all before they can write anything.How it’s configured
The tenant setting istenant.settings.mfaRequired (a boolean in the
tenant’s JSONB settings, read via getTenantMfaRequired() in
src/lib/security/tenant-mfa.ts), set through PUT /api/v1/tenant-settings.
It defaults to off — an unset tenant behaves exactly as it did before
this feature existed.
What it gates
WhenmfaRequired is on, any user (viewer, editor, or admin) who has never
enrolled MFA at all — no TOTP, no passkey — is blocked from state-changing
API writes (POST/PUT/PATCH/DELETE under /api/) with:
action: 'enroll' is shorthand for the real flow — TOTP is
action: "setup" then action: "enable"; see the enrollment UI below.)
Unlike the step-up gate above, this is keyed on enrollment status
(binary — has the user ever set up MFA at all), not freshness — there’s
nothing to “step up” to for someone who’s never enrolled. It also applies
to every role, not just admins. Reads (GET) and page navigation are
never blocked, so a newly-required-but-not-yet-enrolled user can always
reach the enrollment page — only writes are gated
(src/lib/security/mfa-enrollment-gate.ts).
Enrollment UI
/settings/security is the browser enrollment surface for this gate:
- Status overview — whether MFA is enrolled, an “Authenticator app” badge, a passkey count badge, and (once TOTP is enabled) a remaining backup-codes count. When the tenant requires MFA, an inline note tells an unenrolled user they need to set it up before they can make changes.
- Authenticator app (TOTP) — scan a QR code (or enter the manual
secret) from
action: "setup", then confirm with a 6-digit code viaaction: "enable". A fresh set of backup codes is generated and shown once at that point (the codes returned bysetupare never persisted, so they’re never shown).action: "disable"requires a valid current TOTP code; disabling doesn’t touch WebAuthn credentials. - Passkeys / security keys (WebAuthn) — register via the standard
register/begin→register/finishceremony. There’s no delete route for a registered passkey yet.
components/shell/shell.tsx, so it shows
on every page under the app shell) polls the same status endpoint every 60
seconds and, when the tenant requires MFA and the user hasn’t enrolled,
shows a dismissless but non-blocking prompt linking to
/settings/security. It hides itself once the user is already on that
page.
Enrolling clears the gate quickly — flipping the tenant flag doesn’t
Completing enrollment (TOTPenable or WebAuthn register/finish) bumps
the user’s tokenVersion. That invalidates their current JWT (the
session-liveness check rejects the stale token on the next request — see
Authentication),
forcing a re-auth whose fresh JWT carries mfaEnabled: true. So enrollment
clears the block within one request/re-auth cycle, not the JWT’s full
lifetime.
The reverse direction is slower: tenantMfaRequired is read into the JWT
only at sign-in. If a tenant admin flips mfaRequired on, a user with an
already-issued JWT won’t see the requirement until their token naturally
expires and they re-authenticate — up to the full JWT lifetime (~30
minutes) later. A brand-new login after the flag flips is gated
immediately.
Kill switch
This gate shares the sameMFA_ENFORCEMENT_DISABLED kill switch described
above — when engaged, a request that would have been blocked by the
enrollment gate falls through instead, and the bypass is logged the same
way (throttled structured warning, plus the startup killswitch_active
audit-log entry).
Operator runbook
What’s in scope
- ✅ Edge middleware enforcement on all state-changing requests to
/api/* - ✅ Server-issued HMAC-SHA256
kaireon_stepupcookie (minted by TOTP/WebAuthn verify) - ✅ 15-minute sliding-window TTL validated in the Edge runtime via Web Crypto
- ✅
MFA_ENFORCEMENT_DISABLEDenv kill switch - ✅ Source-level regression tests covering the middleware path
- ✅ MFA verify endpoint at
POST /api/v1/auth/mfa - ✅ WebAuthn verify-finish also mints the step-up cookie on success
Out of scope (later)
- Per-tenant TTL configuration
- Audit log entry on every verify (currently logged at info via
logger) - IP-based step-up (require fresh verify when source IP changes mid-session)
What’s already shipped
- ✅ WebAuthn / Passkey support — registration is a two-step ceremony at
POST /api/v1/auth/webauthn/register/begin(server issues a challenge) andPOST /api/v1/auth/webauthn/register/finish(client posts the attestation). Verification follows the same pattern:POST /api/v1/auth/webauthn/verify/beginthenPOST /api/v1/auth/webauthn/verify/finish. Implements the samemfaVerifiedAtJWT claim as TOTP, so once a passkey is registered the middleware treats both factors equivalently. - ✅ TOTP + backup codes
- ✅ Step-up verify endpoint at
POST /api/v1/auth/mfa
How it’s wired
- The edge middleware (
src/middleware.ts) is the single enforcement point for state-changing requests on/api/*. It importsverifyStepUpTokenAsyncfromsrc/lib/auth/step-up-edge.ts(Web Crypto only — nonode:cryptoin the Edge bundle). POST /api/v1/auth/mfa(src/app/api/v1/auth/mfa/route.ts) callsmintStepUpTokenfromsrc/lib/auth/step-up.tson a valid TOTP/backup-code verify and sets thekaireon_stepupcookie on the response. Nosession.update()is called or required.- The WebAuthn verify-finish route likewise calls
mintStepUpTokenon a valid assertion. - A regression test covers the middleware enforcement path end-to-end.