---
title: "Protect a Sensitive Action with Step-up"
url: "https://effect-auth.itsbroly.com/recipes/protect-sensitive-action-with-step-up/"
description: "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:

```text
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 -> success
```

Step-up is neither login MFA nor authorization. [Login MFA](/authentication/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 [#guard-the-action]

Mount `StepUpHttpApiLive` alongside your auth API as described in the [Step-up feature](/authentication/step-up/). The protected application endpoint must authenticate its session and provide `CurrentSession` before running this operation.

```ts title="approve-refund.ts"
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:

```ts title="approve-refund-handler.ts"
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 [#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.

```ts title="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 [#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](/guides/app-owned-guards/) for composition and [Sessions](/concepts/sessions/) for session validation and claim updates.

