---
title: "Step-up Authentication"
url: "https://effect-auth.itsbroly.com/authentication/step-up/"
description: "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.

:::caution[Step-up is not login MFA]
[MFA](/authentication/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.
:::

<IntegrationLevelTabs>
  <Tab id="preset" value="Preset">
    ## Preset [#preset]

    ### Mount the dedicated step-up API [#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.

    <CalloutContainer type="info">
      <CalloutTitle>
        Not included in 

        `CoreAuthHttpApiLive`
      </CalloutTitle>

      <CalloutDescription>
        `CoreAuthHttpApiLive` does **not** mount `StepUpHttpApiGroup`. Add `StepUpHttpApiLive` explicitly. Enrollment and credential-management APIs are separate.
      </CalloutDescription>
    </CalloutContainer>

    ```ts title="auth.ts"
    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 [#challenge-and-retry]

    ```ts title="billing-action.ts"
    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.
  </Tab>

  <Tab id="http-operations" value="HTTP Operations">
    ## HTTP Operations [#http-operations]

    `StepUpHttpOperations` preserves the dedicated preset's behavior while allowing an application-owned API contract.

    ```ts title="step-up-api.ts"
    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) {}
    ```

    ```ts title="step-up-api.live.ts"
    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.
  </Tab>

  <Tab id="primitives" value="Primitives">
    ## Primitives [#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:

    ```ts title="payout-policy.ts"
    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:

    ```ts title="payout-handler.ts"
    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.

    ```ts title="adaptive-policy.ts"
    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.
  </Tab>
</IntegrationLevelTabs>

## Recommended flow [#recommended-flow]

<Steps>
  1. Authenticate the request and provide `CurrentSession` to the protected
     operation. 2. Evaluate the operation's explicit step-up policy. 3. If
     required, return `step_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.
</Steps>

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 [#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 [#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. &#x2A;*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`.

:::caution[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 [#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 [#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 [#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.

## Related documentation [#related-documentation]

* [MFA](/authentication/mfa/)
* [Sessions](/concepts/sessions/)
* [Security Policies](/concepts/security-policies/)
* [App-owned Guards](/guides/app-owned-guards/)
* [Passkeys](/authentication/passkeys/)
* [Testing](/guides/testing/)

