Step-up Authentication
Sensitive operations — account deletion, MFA disable, OAuth client secret rotation — require recent authentication, not just any valid session. Aero2 implements this via the OIDC auth_time claim and a server-side freshness gate (RFC 9470).
How it works
-
Every fresh authentication ceremony (email-code login, MFA verify, passkey login, MFA-setup completion) stamps
auth_time= now (Unix seconds) on the issued session JWT. -
Sensitive endpoints are wrapped in
requireRecentAuth(maxAge)middleware (default 5 minutes). -
If
now - auth_time > maxAge, the endpoint rejects with:HTTP/1.1 403 Forbidden Content-Type: application/json { "error": "step_up_required", "max_age": 300, "server_time": 1735686000 } -
The frontend prompts the user to re-verify a second factor and POSTs to
/api/auth/step-up. On success the session is re-minted with a freshauth_time, after which the original gated request can be retried.
Sessions issued before this feature shipped have no
auth_timeclaim and fail closed — they're treated as stale and require step-up.
Endpoints
POST /api/auth/step-up
Authenticated. Accepts exactly one of:
| Field | Format | Notes |
|---|---|---|
totp_code | 6-digit code | The user must have TOTP MFA enrolled. |
recovery_code | string | One-time code issued during MFA setup. Marked used on success. |
webauthn_assertion | WebAuthn object | Return value of navigator.credentials.get via @simplewebauthn/browser's startAuthentication, after obtaining request options from POST /api/auth/step-up/webauthn/options. Requires at least one registered passkey. |
email_code | 6-digit code | Only when the user has no verified TOTP, no unused recovery codes, and no passkeys. Send first via POST /api/auth/step-up/email-code/send. |
The new session inherits the application's configured session_ttl (not a hardcoded 1h).
On success the response shape mirrors /api/auth/mfa/verify:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600
}The new access_token is also set as the access_token cookie. Subsequent requests (with the new cookie/token) carry a fresh auth_time claim and pass the gate.
Failures are recorded with a step_up_failed audit event; successes with step_up_succeeded. The step_up_succeeded event's method is totp, recovery_code, email_code, or passkey.
POST /api/auth/step-up/webauthn/options
Authenticated. Returns PublicKeyCredentialRequestOptionsJSON for the browser (startAuthentication). Inserts a challenge bound to the current user (unlike discoverable login challenges, so it cannot be replayed from the unauthenticated login flow).
Requires passkey in the application auth_methods. Responds 400 with No passkeys registered when the user has no credentials.
RP ID and origin match passkey registration and login (derived from the request URL the client used — shared helpers with /api/auth/webauthn/* and /api/users/me/mfa/webauthn/*).
POST /api/auth/step-up/email-code/send
Authenticated. Sends a fresh 6-digit code to the user's email. Rate-limited per IP via authRateLimit (10/min). Returns 400 step_up_email_code_unavailable when the user has verified TOTP, unused recovery codes, or any passkey enrolled for the current app (webauthn_credentials rows are scoped by app_id, so passkeys in another application do not block email-code here). Same policy as email_code on the verify endpoint.
Step-up codes use a dedicated type = "step_up_code" row in email_verification_codes (added to the CHECK constraint in migration 0002) so they don't collide with login email codes.
HTTP/1.1 200 OK
{ "message": "Verification code sent", "expires_in": 600 }GET /api/users/me and step-up UI
The current user payload includes step_up_factors: { totp, recovery, passkey, email } booleans indicating which factors the step-up modal should offer, aligned with server-side policy for email-code (factors are evaluated for the same app_id as the session).
Endpoints currently gated
The default freshness window is 5 minutes (requireRecentAuth(300)).
DELETE /api/users/me— account deletion requestPOST /api/users/me/mfa/totp/setup— start TOTP enrollmentPOST /api/users/me/mfa/totp/verify— finalize TOTP enrollmentPOST /api/users/me/mfa/totp/disable— disable TOTP MFAPOST /api/users/me/mfa/webauthn/register-options— start passkey enrollmentPOST /api/users/me/mfa/webauthn/register— finalize passkey enrollmentDELETE /api/users/me/mfa/webauthn/:credentialId— delete passkey
POST /api/applications/clients— create client (returns freshclient_secret)POST /api/applications/clients/:clientId/rotate-secret— rotate OAuth client secretDELETE /api/applications/clients/:clientId— disable OAuth client
POST /api/applications/api-keys— create key (returns fresh API key)POST /api/applications/api-keys/:id/rotate— rotate API keyDELETE /api/applications/api-keys/:id— revoke API key
DELETE /api/applications/current— delete application (frontend UI not yet built)
MFA enrollment + the mfa_setup_required interaction
Gating MFA enrollment closes the highest-risk window: the very first MFA setup ceremony, when no second factor exists yet, is exactly when an attacker with a stolen session would install their own factor as a persistent backdoor. With this gate, an attacker can't enroll without re-proving access.
For the gate to be usable by legitimate users in the mfa_setup_required restricted-session state (mfa_policy=required, no MFA enrolled yet), isMfaSetupRouteAllowed permits POST /api/auth/step-up, POST /api/auth/step-up/webauthn/options, and POST /api/auth/step-up/email-code/send even though the user can't reach the rest of the dashboard. Only factors that are actually available (e.g. email-code when no stronger factors exist yet) apply.
When adding a new sensitive endpoint, drop a requireRecentAuth() middleware in the chain and mirror the passkey gate-fires regression test in tests/integration/step-up.test.ts.
Frontend integration
Apps using the dashboard frontend can wrap any API call in the useStepUp() hook:
import { useStepUp } from "@/contexts/StepUpContext";
import { api } from "@/utils/api";
const { runWithStepUp } = useStepUp();
await runWithStepUp(() => api.del("/api/users/me"));If the underlying call rejects with 403 step_up_required, the modal opens, the user verifies a factor, and runWithStepUp retries the call automatically. If the user cancels, the original ApiError is re-thrown so existing error handling still applies.
Audit events
| Event | When |
|---|---|
step_up_succeeded | A factor verified successfully and the session was re-minted |
step_up_failed | A factor was rejected (invalid code, missing setup, etc.) |