Step-up Authentication
Reauthenticate an existing session before sensitive operations with explicit AAL, AMR, and freshness policy.
Step-up authentication protects a sensitive action by requiring stronger, specific, or fresher authentication from the currently authenticated user. Verification updates the existing session; it does not complete a pending login.
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 that session, and has no login flowId. Keep the protocols and UI states separate.
Preset
Mount the dedicated step-up API
StepUpHttpApiLive mounts /auth/step-up/* with session authentication, origin checks, schema-error sanitization, standard security policy, factor verification, and session updates.
Not included in CoreAuthHttpApiLive
CoreAuthHttpApiLive does not mount StepUpHttpApiGroup. Add StepUpHttpApiLive explicitly. Enrollment and credential-management APIs are separate.
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { AuthKernelLive } from "@effect-auth/core/AuthKernel";
import {
AuthHttpApiConfigLive,
CoreAuthHttpApiLive,
StepUpHttpApiLive,
} from "@effect-auth/core/HttpApi";
import { PasskeyOptionsLive } from "@effect-auth/core/Passkey";
import { PasswordDefaultLive } from "@effect-auth/core/Password";
import { Layer } from "effect";
import { HttpServer } from "effect/unstable/http";
const ServicesLive = Layer.mergeAll(
PasswordDefaultLive(),
PasskeyOptionsLive
).pipe(
Layer.provideMerge(AuthKernelLive),
Layer.provideMerge(AppAuthRuntimeLive)
);
export const AuthLive = Layer.mergeAll(
CoreAuthHttpApiLive,
StepUpHttpApiLive
).pipe(
Layer.provide(AuthRateLimitStandardLive()),
Layer.provide(Layer.mergeAll(ServicesLive, AppRateLimitLive)),
Layer.provide(
AuthHttpApiConfigLive({
originCheck: { allowedOrigins: ["https://app.example.com"] },
})
),
Layer.provide(HttpServer.layerServices)
);StepUpHttpOperationsLive requires password, TOTP, recovery-code, and passkey services. Options only returns factors actually available to the current user, but all operation dependencies must be provided to construct the complete layer.
Challenge and retry
import { createAuthClient } from "@effect-auth/core/Client";
const auth = createAuthClient({
requestInit: { credentials: "include" },
});
async function confirmPayout() {
const response = await fetch("/api/payouts/confirm", {
method: "POST",
credentials: "include",
});
if (response.status !== 403) return response;
const error = await response.json();
if (error.code !== "step_up_required") return response;
const { factors } = await auth.stepUp.options();
if (factors.some((factor) => factor.type === "passkey")) {
await auth.stepUp.passkey.verify();
} else {
await auth.stepUp.password.verify({ password });
}
return fetch("/api/payouts/confirm", {
method: "POST",
credentials: "include",
});
}The sensitive endpoint must check policy on every attempt. A successful step-up endpoint is not authorization to perform an arbitrary action; retrying the guarded action proves that the updated session now satisfies its policy.
The preset owns: current-session identity, factor verification, session mutation, typed errors, cookie/session handling, and standard operation security.
Your application owns: which actions require step-up, the exact policy, challenge UI, authorization, auditing, and retry/idempotency behavior.
HTTP Operations
StepUpHttpOperations preserves the dedicated preset's behavior while allowing an application-owned API contract.
import {
AuthOriginCheckMiddleware,
AuthSchemaErrorMiddleware,
stepUpOptionsEndpoint,
stepUpPasskeyStartEndpoint,
stepUpPasskeyVerifyEndpoint,
stepUpPasswordVerifyEndpoint,
stepUpRecoveryCodeVerifyEndpoint,
stepUpTotpVerifyEndpoint,
} from "@effect-auth/core/HttpApi";
import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi";
class AppStepUpHttpApiGroup extends HttpApiGroup.make("stepUp")
.add(
stepUpOptionsEndpoint,
stepUpTotpVerifyEndpoint,
stepUpPasswordVerifyEndpoint,
stepUpRecoveryCodeVerifyEndpoint,
stepUpPasskeyStartEndpoint,
stepUpPasskeyVerifyEndpoint
)
.prefix("/auth/step-up")
.middleware(AuthSchemaErrorMiddleware)
.middleware(AuthOriginCheckMiddleware) {}
export class AppAuthApi extends HttpApi.make("AppAuthApi")
.add(AppStepUpHttpApiGroup) {}import {
StepUpHttpOperations,
StepUpHttpOperationsLive,
} from "@effect-auth/core/HttpApi";
import { Effect, Layer } from "effect";
import { HttpApiBuilder } from "effect/unstable/httpapi";
export const AppStepUpHttpApiGroupLive = HttpApiBuilder.group(
AppAuthApi,
"stepUp",
Effect.fn("app.auth.step_up")(function* (handlers) {
const stepUp = yield* StepUpHttpOperations;
return handlers
.handle("options", stepUp.options)
.handle("verifyTotp", stepUp.verifyTotp)
.handle("verifyPassword", stepUp.verifyPassword)
.handle("verifyRecoveryCode", stepUp.verifyRecoveryCode)
.handle("startPasskey", stepUp.startPasskey)
.handle("verifyPasskey", stepUp.verifyPasskey);
})
).pipe(Layer.provide(StepUpHttpOperationsLive));Operations authenticate the request from its session cookie and never accept a user ID. Keep that invariant if you wrap them. Do not expose a handler that allows a caller to choose which user's session is upgraded.
The library owns: current-session validation, factor verification, standard session updates, operation security, and HTTP semantics.
Your application owns: endpoint selection, schemas, routes, middleware, policy around the protected action, and additional abuse controls.
Primitives
The StepUp module evaluates session policy independently of HTTP. This policy requires recent AAL2, a recent TOTP or passkey AMR, and no outstanding email-verification requirement:
import * as StepUp from "@effect-auth/core/StepUp";
import { Duration, Effect } from "effect";
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"],
});
export const confirmPayout = Effect.gen(function* () {
yield* StepUp.requirePolicy(payoutPolicy);
return yield* payouts.confirm(payoutId);
});checkPolicy returns Satisfied or Required; requirePolicy fails with StepUpRequired. At the HTTP boundary, map that error to a safe 403 step_up_required, while preserving its failure details only for trusted diagnostics:
import * as StepUp from "@effect-auth/core/StepUp";
import { Effect } from "effect";
export const confirmPayoutHandler = confirmPayout.pipe(
Effect.catchTag("StepUpRequired", (required) =>
Effect.succeed({
status: 403 as const,
body: {
code: "step_up_required" as const,
// Prefer the options endpoint for factor discovery.
},
}).pipe(
Effect.annotateLogs({
stepUpFailures: required.failures.map((failure) => failure.reason),
})
)
)
);The standard HTTP error mapper already maps StepUpRequired this way. toGuard exposes the same Effect for app-owned guard composition, while toPolicy maps failure to AuthzError with reason step-up-required.
Use StepUpHttpOperations for factor verification. Its TOTP, passkey, recovery, and password operations derive identity from CurrentSession, create evidence only after trusted server verification, atomically rotate the session, invalidate the old token, and commit the one replacement cookie. Retry the sensitive operation afterward so it re-authenticates, re-authorizes, and evaluates policy against the replacement session. Do not expose a generic evidence/session-upgrade endpoint.
const policy = StepUp.adaptive([
StepUp.available(
"passkey",
StepUp.session({ require: StepUp.amr("passkey") })
),
StepUp.available(
"aal2-capable",
StepUp.session({ require: StepUp.aal("aal2") })
),
StepUp.deny("no-acceptable-step-up-factor"),
]);
const guard = StepUp.requireAdaptivePolicy(policy);Adaptive rules are first-match. StepUpCapabilitiesLive derives password, passkey, totp, recovery-code, and AAL capability labels from available services and active credentials. Handle StepUpDenied and StepUpCapabilityError separately from StepUpRequired.
Primitive policy checks do not verify a factor. A fully custom server integration may construct passwordEvidence, totpEvidence, passkeyEvidence, or registered customEvidence from a trusted result and call the evidence-based session service, but it owns equivalent replay, compare-and-set rotation, and replacement-token commitment.
The library owns: policy evaluation, failure detail, capability discovery, monotonic assurance updates, AMR merging, and freshness timestamps.
Your application owns: request authentication, factor verification, session/service provisioning, transport errors, authorization, auditing, and atomic action semantics.
Recommended flow
- Authenticate the request and provide
CurrentSessionto the protected operation. 2. Evaluate the operation's explicit step-up policy. 3. If required, returnstep_up_required; the browser requests current-user factor options. 4. Verify one factor. The step-up endpoint atomically rotates the session and commits the replacement token. 5. Retry the protected operation, which evaluates policy again and performs normal authorization.
Avoid “step-up succeeded” booleans in client state or standalone bearer flags. The server-side session claims and timestamps are the source of truth.
Built-in routes
| Route | Requirement | Session effect |
|---|---|---|
GET /auth/step-up/options | Session | Lists available current-user factors |
POST /auth/step-up/password/verify | Session plus password | Refreshes authTime, appends pwd |
POST /auth/step-up/totp/verify | Session plus TOTP | Raises to at least aal2, appends totp, sets mfaVerifiedAt |
POST /auth/step-up/recovery-code/verify | Session plus code | Enters constrained recovery remediation with recovery_code; does not grant ordinary aal2 |
POST /auth/step-up/passkey/start | Session | Starts a challenge for the current user |
POST /auth/step-up/passkey/verify | Session plus challenge | Raises to at least aal2, appends passkey, sets mfaVerifiedAt |
Passkey verification rejects a credential belonging to another user. Recovery codes are consumed according to recovery-code service semantics.
AAL, AMR, and freshness
| Value | Meaning in policy |
|---|---|
aal | Minimum assurance strength; updates never lower it |
amr | Canonical methods derived from events, such as pwd, totp, recovery_code, or passkey |
authTime | Primary authentication or password reauthentication time |
mfaVerifiedAt | Most recent stronger-factor verification time |
Freshness gates select the matching authentication event. Method-specific checks use that method's latest verifiedAt; factor-specific checks use the latest event for that stable factor ID; assurance checks use the latest qualifying event. Summary timestamps remain available for efficient output, but are not a fallback that makes an unrelated factor fresh. Choose maxAge per operation rather than treating aal2 as permanently fresh.
These are local policy tiers, not certification labels. Effect Auth does not automatically provide or claim NIST SP 800-63 conformance. Built-ins establish at most local aal2; aal3 is reserved for explicitly registered custom hardware-bound policy.
StepUp.every(...) requires every predicate. StepUp.oneOf(...) succeeds if any alternative is satisfied. Empty oneOf fails closed. noSessionRequirements can block sessions still carrying requirements such as email_verification.
Password does not raise AAL
Password step-up reauthenticates and refreshes authTime; it does not become a second factor or automatically raise aal1 to aal2. Require amr("pwd") with freshness when password reauthentication is sufficient, and require aal2 when a stronger factor is required.
Security defaults
AuthRateLimitStandardLive() limits options to 60 requests per user per minute; password, TOTP, and recovery-code verification to 20 per user per 10 minutes; and passkey start/verify to 30 per user per 10 minutes. These protect the built-in operations, not your sensitive business endpoint.
Also apply authorization, CSRF/origin protection appropriate to your transport, idempotency for retried mutations, application-specific rate limits, and sanitized audit events. Never include passwords, TOTP/recovery codes, WebAuthn credentials, cookie values, or hostile headers in logs or error metadata.
HTTP errors
| Code | Status | Typical cause |
|---|---|---|
bad_request | 400 | Invalid payload or passkey challenge |
unauthenticated | 401 | Missing, invalid, or expired current session |
invalid_credentials | 401 | Wrong password, TOTP, or recovery code |
policy_denied | 403 | Passkey belongs to another user or policy denies verification |
step_up_required | 403 | Protected action's session policy is unsatisfied |
request_rejected | 403 | Origin validation rejected the request |
rate_limited | 429 | Standard or application security limit exceeded |
internal_error | 500 | Credential, crypto, capability, or session update failure |
step_up_required normally comes from the protected application endpoint, not from factor verification. Preserve failure details server-side for diagnostics, but expose only the minimum information needed to choose a safe factor.
Testing checklist
- Every protected action checks step-up policy server-side on every attempt.
- Missing sessions fail before options or factor verification.
- Options are derived only from the current user's active credentials.
- Password refreshes password evidence without raising AAL; TOTP and server-verified-UV passkeys can derive
aal2; recovery enters remediation. - AMR entries are appended without duplicates.
- Freshness tests cover boundary time, stale primary auth, and stale MFA.
- Passkeys cannot upgrade another user's session; recovery codes are single-use.
- Adaptive policy tests cover first-match, fallback, explicit deny, and capability-load failure.
- Retried sensitive mutations are authorized and idempotent where required.
- Origin, schema, and rate-limit failures do not leak secrets or request headers.