effect-auth

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:

Authorize first, then require fresh AAL2

Step-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.

approve-refund.ts
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
    yield* StepUp.toGuard(recentAal2);
    return yield* Refunds.approve(refundId); // app-owned transactional write
  });

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.

The app-owned authorization is deliberately sequenced before step-up so an unauthorized caller is not prompted for a factor. On Cloudflare, this endpoint reuses the existing Alchemy v2 Worker, D1-backed session store, and Durable Object rate limiter; step-up requires no new binding.

Map typed failures only at the HTTP boundary. This response contract is app-owned:

approve-refund-handler.ts
import { Effect } from "effect";

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:

FailureResponse
Missing or invalid session401
App authorization denialSanitized 403 forbidden
Unsatisfied freshness or assurance403 step_up_required
Rate limit429
Custom evidence policy or unexpected infrastructure failureSanitized 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.

approve-refund-client.ts
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.

On this page