Protect a Sensitive Action with Step-up
Require recent AAL2 before one billing or admin mutation, then upgrade the session and retry safely.
Protect POST /api/billing/refunds/:id/approve with normal authorization and AAL2 verified in the last five minutes:
approve request
-> authenticate session -> authorize billing role -> require recent AAL2
|
v
403 { code: "step_up_required" }
|
options -> verify TOTP -> upgrade same session
|
v
retry approve request -> successStep-up is neither login MFA nor authorization. Login MFA completes a sign-in before a session exists and uses a flowId. Step-up proves more about the user who already owns the current session. Authorization still decides whether that user may approve this refund.
Guard the action
Mount StepUpHttpApiLive alongside your auth API as described in the Step-up feature. The protected application endpoint must authenticate its session and provide CurrentSession before running this operation.
import * as Guard from "@effect-auth/core/Guard";
import * as StepUp from "@effect-auth/core/StepUp";
import { Duration, Effect } from "effect";
const recentAal2 = StepUp.session({
require: StepUp.aal("aal2", { maxAge: Duration.minutes(5) }),
});
const approveRefund = (refundId: string) =>
Effect.gen(function* () {
yield* requireBillingApprover(refundId); // app-owned authorization
return yield* Refunds.approve(refundId); // app-owned transactional write
}).pipe(Guard.require(StepUp.toGuard(recentAal2)));StepUp.toGuard only evaluates the request's CurrentSession; it never verifies a factor. Keep authorization in the operation and make the write idempotent, because the browser will retry it.
Map typed failures only at the HTTP boundary. This response contract is app-owned:
const response = approveRefund(refundId).pipe(
Effect.catchTag("StepUpRequired", (required) =>
Effect.succeed({
status: 403 as const,
body: { code: "step_up_required" as const },
}).pipe(
Effect.annotateLogs({
stepUpReasons: required.failures.map((failure) => failure.reason),
}),
),
),
Effect.catchTag("BillingForbidden", () =>
Effect.succeed({ status: 403 as const, body: { code: "forbidden" as const } }),
),
);Do not return StepUpRequired.failures, session claims, or factor inventory from the billing endpoint. Map missing or invalid sessions to 401, authorization denial to sanitized 403, step-up to 403 step_up_required, rate limiting to 429, and unexpected failures to 500.
Challenge, upgrade, retry
Use the dedicated client for the minimum three step-up calls: discover current-user factors, verify one AAL2-capable factor, then retry the original request.
import { createStepUpClient } from "@effect-auth/core/Client";
const stepUp = createStepUpClient({
requestInit: { credentials: "include" },
});
async function approveRefund(refundId: string, getTotp: () => Promise<string>) {
const send = () => fetch(`/api/billing/refunds/${refundId}/approve`, {
method: "POST",
credentials: "include",
headers: { "Idempotency-Key": refundId },
});
const first = await send();
if (first.status !== 403) return first;
const error = await first.clone().json();
if (error.code !== "step_up_required") return first;
const { factors } = await stepUp.options();
if (!factors.some((factor) => factor.type === "totp")) {
throw new Error("No supported AAL2 step-up factor");
}
await stepUp.totp.verify({ code: await getTotp() });
return send();
}The TOTP operation creates evidence from the accepted counter, atomically advances the replay floor, rotates the session, invalidates the old token, and commits the replacement. Password verification similarly records fresh password evidence but does not raise AAL1 to AAL2. After verification, retry rather than trusting a client-side “verified” flag; the server re-authenticates, re-authorizes, and re-runs the event-specific freshness guard.
Test checklist
- AAL1 and stale AAL2 sessions receive only
403 step_up_required; no refund is written. - A fresh AAL2 session with the billing role succeeds; without the role it remains forbidden.
- Options and verification reject missing sessions and expose only the current user's factors.
- Wrong TOTP does not upgrade the session; valid TOTP raises AAL2 and the retry succeeds.
- Password reauthentication alone still fails the AAL2 guard.
- The five-minute boundary, replayed retry, CSRF/origin checks, rate limits, and sanitized logs are covered.
See Guards for composition and Sessions for session validation and claim updates.