Step-up Authentication
Require stronger or fresher authentication before a sensitive action.
Step-up reauthenticates the current session user before a sensitive action. Successful verification keeps the logical session ID, compare-and-set rotates its opaque secret, invalidates the old token, and commits a replacement cookie.
Step-up is not login MFA
MFA uses a flowId before a session exists. Step-up requires a valid session cookie, derives the user from it, and has no login flowId.
HTTP Operations
HTTP Operations preserve built-in factor verification and rotation while letting the application choose routes and app guards.
The focused APIs are opt-in. CoreAuthHttpApiLive does not mount Step-up routes, although createAuthClient() exposes auth.stepUp as part of its client-only protocol superset.
Built-in contract
All routes authenticate from the session cookie and accept no user ID.
| Route | Requirement | Session effect |
|---|---|---|
GET /auth/step-up/options | Session | Lists active current-user factor families |
POST /auth/step-up/password/verify | Password | Refreshes pwd; not independently AAL2 |
POST /auth/step-up/totp/verify | TOTP | Advances counter, adds totp, can derive local AAL2 |
POST /auth/step-up/recovery-code/verify | Recovery code | Consumes code, adds recovery_code and remediation |
POST /auth/step-up/passkey/start | Session | Starts a current-user WebAuthn challenge |
POST /auth/step-up/passkey/verify | Verified-UV passkey | Updates credential and can derive local AAL2 |
Options report available factor families, not which ones satisfy an action. Intersect them with server-owned policy. The UI spelling is backup-code, adaptive capability is recovery-code, and AMR evidence is recovery_code.
Challenge and retry
Use one same-origin client in a Cloudflare frontend. Retry the protected operation with the same idempotency key; never trust a client-side “step-up succeeded” flag.
import { createAuthClient } from "@effect-auth/core/Client";
const auth = createAuthClient();
export async function confirmPayout(input: {
attemptId: string;
totpCode?: string;
}) {
const attempt = () =>
fetch("/api/payouts/confirm", {
method: "POST",
credentials: "include",
headers: { "Idempotency-Key": input.attemptId },
});
const first = await attempt();
if (first.status !== 403) return first;
const error = await first.clone().json();
if (error.code !== "step_up_required") return first;
const { factors } = await auth.stepUp.options();
if (factors.some((factor) => factor.type === "passkey")) {
await auth.stepUp.passkey.verify();
} else if (
input.totpCode !== undefined &&
factors.some((factor) => factor.type === "totp")
) {
await auth.stepUp.totp.verify({ code: input.totpCode });
} else {
throw new Error("No approved strong factor is available");
}
return attempt();
}The aggregate client performs passkey start, WebAuthn, and finish in one call. createStepUpClient exposes separate passkey start and verify operations.
Policy model
| Value | Meaning |
|---|---|
aal | Assurance derived from the complete trusted evidence set |
amr | Canonical method such as pwd, totp, recovery_code, passkey |
authTime | Latest authentication-event time |
mfaVerifiedAt | Latest ordinary stronger-factor verification; recovery does not set it |
sensitiveActionStepUpPolicy accepts password, passkey, or AAL2 evidence no older than 15 minutes and rejects recovery_remediation. Standard passkey management and revocation of other sessions use it.
import * as StepUp from "@effect-auth/core/StepUp";
import { Duration } from "effect";
export const payoutPolicy = StepUp.session({
require: StepUp.every(
StepUp.aal("aal2", { maxAge: Duration.minutes(5) }),
StepUp.oneOf(
StepUp.amr("totp", { maxAge: Duration.minutes(5) }),
StepUp.amr("passkey", { maxAge: Duration.minutes(5) })
)
),
noSessionRequirements: ["email_verification", "recovery_remediation"],
});Method freshness uses the latest event for that method; factor freshness uses a stable factor ID. Password refreshes evidence and can complete an existing independent factor set, but is not itself AAL2. Recovery constrains derived AAL2/AAL3 to AAL1 until remediation completes. These are local policy tiers, not certification labels.
Rotation boundaries
| Method | Atomic boundary |
|---|---|
| Password | Verify credential, then CAS session rotation |
| TOTP | Accept counter and rotate session atomically |
| Recovery | Consume code, add remediation, and rotate atomically |
| Passkey | Consume challenge/update sign count, then rotate session |
Passkey state can commit before a later session race. For every method, storage rotation precedes cookie delivery; if delivery fails, the old token is already invalid. Define a safe full-reauthentication path and never return the replacement token in JSON.
Recommended flow
step_up_required without exposing policy details.Apply action-specific limits independently of factor-route limits. Test exact freshness boundaries, concurrent rotation, replay, old-token rejection, cookie failure, and idempotent retry. Never log passwords, codes, WebAuthn payloads, cookies, action bodies, or hostile headers.
HTTP errors
| Error | Boundary behavior |
|---|---|
StepUpRequired | Sanitized 403 step_up_required; retain details server-side |
CustomEvidencePolicyError | Sanitized 500 internal_error; it is an integrity failure |
| Invalid factor | 401 invalid_credentials or endpoint-safe 400 |
| Missing/raced session | 401 unauthenticated |
| Rate limit | 429 rate_limited |