---
title: "Recovery Codes"
url: "https://effect-auth.itsbroly.com/authentication/recovery-codes/"
description: "Generate, rotate, consume, inventory, and revoke single-use recovery codes safely."
---





Recovery codes are display-once backup factors. Core generates user-friendly codes, stores SHA-256 hashes, consumes a matching code once, and exposes safe inventory records without plaintext or hashes.

<CalloutContainer type="info">
  <CalloutTitle>
    Choose the highest useful level
  </CalloutTitle>

  <CalloutDescription>
    Use **Preset** for the built-in `/auth/recovery-codes/*` settings API. Use **HTTP Operations** for an application-owned contract, or **Primitives** for custom generation, storage, and authentication flows.
  </CalloutDescription>
</CalloutContainer>

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

    ### Mount the recovery-code API [#mount-the-recovery-code-api]

    `RecoveryCodesHttpApiLive` is a standalone feature preset. It is not included in `CoreAuthHttpApiLive`, so mount it explicitly.

    ```ts title="recovery-codes-api.live.ts"
    import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
    import {
      RecoveryCodeHttpConfigLive,
      RecoveryCodesHttpApiLive,
    } from "@effect-auth/core/HttpApi";
    import {
      RecoveryCodeManagementLive,
      RecoveryCodesLive,
    } from "@effect-auth/core/RecoveryCode";
    import { Layer } from "effect";
    import { HttpServer } from "effect/unstable/http";

    const RecoveryCodeDomainLive = Layer.mergeAll(
      RecoveryCodesLive,
      RecoveryCodeManagementLive
    ).pipe(Layer.provideMerge(AppAuthRuntimeLive));

    export const RecoveryCodesHttpApiGroupLive = RecoveryCodesHttpApiLive.pipe(
      Layer.provide(AuthRateLimitStandardLive()),
      Layer.provide(RecoveryCodeDomainLive),
      Layer.provide(AppSessionLive),
      Layer.provide(AppRateLimitLive),
      Layer.provide(
        RecoveryCodeHttpConfigLive({ count: 10, length: 16, groupSize: 4 })
      ),
      Layer.provide(HttpServer.layerServices)
    );
    ```

    `AppAuthRuntimeLive` supplies `Crypto` and a durable `RecoveryCodeStore`; `AppSessionLive` supplies `Sessions` and `SessionCookie`. Effect-QB storage supports recovery codes after migration `0007_auth_recovery_code`.

    ### Browser client [#browser-client]

    ```ts title="recovery-codes-client.ts"
    import { createRecoveryCodeClient } from "@effect-auth/core/Client";

    export const recoveryCodes = createRecoveryCodeClient({
      requestInit: { credentials: "include" },
    });

    const generated = await recoveryCodes.generation.generate({
      metadata: { source: "mfa-setup" },
    });

    const rotated = await recoveryCodes.generation.regenerate({
      metadata: { source: "settings" },
      revokeReason: "settings_rotation",
    });

    const { codes } = await recoveryCodes.codes.list();
    await recoveryCodes.codes.revoke({ codeId, reason: "user_request" });
    ```

    `generated.codes` and `rotated.codes` are plaintext. Display or offer a local download once, then remove them from UI state. `records` and list results contain IDs and timestamps but no plaintext or hashes.

    **The preset owns:** current-session lookup, generation and hashing, endpoint schemas, origin checks, standard security policy, ownership checks, and safe response projection.

    **Your application owns:** step-up before generation/rotation/removal, display-once UX, audit/notifications, download handling, and last-factor policy.
  </Tab>

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

    ### Own the contract [#own-the-contract]

    `RecoveryCodesHttpOperations` exposes the secured implementations used by the preset.

    ```ts title="recovery-codes-api.ts"
    import {
      AuthOriginCheckMiddleware,
      AuthSchemaErrorMiddleware,
      recoveryCodeGenerateEndpoint,
      recoveryCodeListEndpoint,
      recoveryCodeRegenerateEndpoint,
      recoveryCodeRevokeEndpoint,
      recoveryCodeVerifyEndpoint,
    } from "@effect-auth/core/HttpApi";
    import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi";

    class AppRecoveryCodesHttpApiGroup extends HttpApiGroup.make("recoveryCodes")
      .add(
        recoveryCodeGenerateEndpoint,
        recoveryCodeRegenerateEndpoint,
        recoveryCodeVerifyEndpoint,
        recoveryCodeListEndpoint,
        recoveryCodeRevokeEndpoint
      )
      .prefix("/auth/recovery-codes")
      .middleware(AuthSchemaErrorMiddleware)
      .middleware(AuthOriginCheckMiddleware) {}

    export class AppAuthApi extends HttpApi.make("AppAuthApi")
      .add(AppRecoveryCodesHttpApiGroup) {}
    ```

    ```ts title="recovery-codes-api.live.ts"
    import {
      RecoveryCodesHttpOperations,
      RecoveryCodesHttpOperationsLive,
    } from "@effect-auth/core/HttpApi";
    import { Effect, Layer } from "effect";
    import { HttpApiBuilder } from "effect/unstable/httpapi";

    export const AppRecoveryCodesHttpApiGroupLive = HttpApiBuilder.group(
      AppAuthApi,
      "recoveryCodes",
      Effect.fn("app.auth.recovery_codes")(function* (handlers) {
        const recoveryCodes = yield* RecoveryCodesHttpOperations;

        return handlers
          .handle("generateRecoveryCodes", recoveryCodes.generate)
          .handle("regenerateRecoveryCodes", recoveryCodes.regenerate)
          .handle("verifyRecoveryCode", recoveryCodes.verify)
          .handle("listRecoveryCodes", recoveryCodes.list)
          .handle("revokeRecoveryCode", recoveryCodes.revoke);
      })
    ).pipe(Layer.provide(RecoveryCodesHttpOperationsLive));
    ```

    These operations derive the user from the current session and apply `AuthRateLimit`. Revoke also consults `StrongFactorRemovalPolicy` when provided. Keep user IDs out of browser payloads.

    **The library owns:** code lifecycle orchestration, hashing, current-session scoping, standard operation security, and typed HTTP semantics.

    **Your application owns:** route shape, public metadata policy, middleware, fresh-authentication requirements, and session upgrades.
  </Tab>

  <Tab id="primitives" value="Primitives">
    ## Primitives [#primitives]

    ### Generate and store for the current user [#generate-and-store-for-the-current-user]

    `RecoveryCodeManagement` generates display codes and persists only their hashes through `RecoveryCodeStore`. Bind generation to `CurrentSession`, require fresh authentication, and unwrap returned `Redacted` codes only in the one-time response/download boundary.

    ```ts title="recovery-code-settings.ts"
    import { RecoveryCodeManagement } from "@effect-auth/core/RecoveryCode";
    import { CurrentSession } from "@effect-auth/core/Sessions";
    import { requireFresh } from "@effect-auth/core/StepUp";
    import { Duration, Effect } from "effect";

    export const generateRecoveryCodes = Effect.gen(function* () {
      yield* requireFresh(Duration.minutes(10));
      const session = yield* CurrentSession;
      const recoveryCodes = yield* RecoveryCodeManagement;

      return yield* recoveryCodes.generateForUser({
        userId: session.userId,
        count: 10,
        length: 16,
        groupSize: 4,
        metadata: {
          source: "mfa-setup",
          generatedFromSession: session.sessionId,
        },
      });
    });
    ```

    The returned `codes` are plaintext wrapped in `Redacted`; `records` are safe inventory metadata. A durable store receives `codeHash`, never plaintext. Do not persist the response for later redisplay. `generateForUser` adds another batch, so use regeneration when replacing existing codes.

    ### Consume once and enter remediation [#consume-once-and-enter-remediation]

    Use the factor-specific `MfaHttpOperations` or `StepUpHttpOperations` recovery path. Maintained storage consumes the code, appends server-produced `recoveryCodeEvidence`, creates or rotates the session, and invalidates the old token in one transaction. Generic custom stores intentionally make no cross-table atomicity claim.

    Recovery is not ordinary step-up and never grants unrestricted local `aal2`. It creates a constrained session with a `recovery_remediation` requirement and canonical AMR `recovery_code`; only explicitly allowed enrollment, verification, or code-replacement capabilities may proceed. Clear remediation only after a normal capability is successfully established, using the recovery-policy operation that atomically rotates the session. The options/UI factor identifier remains `backup-code`, which is not an AMR value.

    ### Inventory, regenerate, and revoke [#inventory-regenerate-and-revoke]

    ```ts title="recovery-code-management.ts"
    import { RecoveryCodeManagement } from "@effect-auth/core/RecoveryCode";
    import { CurrentSession } from "@effect-auth/core/Sessions";
    import { requireFresh } from "@effect-auth/core/StepUp";
    import {
      StrongFactorRemovalPolicy,
      strongFactor,
    } from "@effect-auth/core/StrongFactor";
    import { Duration, Effect } from "effect";

    export const listRecoveryCodes = Effect.gen(function* () {
      const session = yield* CurrentSession;
      return yield* RecoveryCodeManagement.use((codes) =>
        codes.listForUser({ userId: session.userId }),
      );
    });

    export const regenerateRecoveryCodes = Effect.gen(function* () {
      yield* requireFresh(Duration.minutes(10));
      const session = yield* CurrentSession;
      return yield* RecoveryCodeManagement.use((codes) =>
        codes.regenerateForUser({
          userId: session.userId,
          count: 10,
          length: 16,
          groupSize: 4,
          revokeReason: "settings_rotation",
          metadata: { source: "settings" },
        }),
      );
    });

    export const revokeRecoveryCode = (codeId: Parameters<
      RecoveryCodeManagement["revokeForUser"]
    >[0]["codeId"]) =>
      Effect.gen(function* () {
        yield* requireFresh(Duration.minutes(10));
        const session = yield* CurrentSession;
        const removal = yield* StrongFactorRemovalPolicy;
        const codes = yield* RecoveryCodeManagement;

        yield* removal.requireCanRemove({
          userId: session.userId,
          factor: strongFactor("recovery-code", codeId),
        });
        yield* codes.revokeForUser({
          userId: session.userId,
          codeId,
          reason: "user_request",
        });
      });
    ```

    Normal inventory excludes used and revoked records; reserve `includeUsed` and `includeRevoked` for authorized administration. `replaceActiveForUser` is the atomic maintained-storage regeneration boundary: replacement insertion and revocation of the old active batch commit together. Single-record revoke checks ownership, but last-factor protection is the separately composed `StrongFactorRemovalPolicy` shown above.

    ### Low-level code functions [#low-level-code-functions]

    `RecoveryCodes` exposes `generate`, `normalize`, `hash`, and timing-safe `verify`. Use it when your application owns persistence. `RecoveryCodesLive` requires `Crypto`; `RecoveryCodeManagementLive` additionally requires `RecoveryCodeStore`.

    <CalloutContainer type="error">
      <CalloutTitle>
        Primitives do not secure an HTTP boundary
      </CalloutTitle>

      <CalloutDescription>
        They do not authenticate requests, require step-up, rate-limit guesses, upgrade sessions, or deliver security notifications. Add those controls around every public operation.
      </CalloutDescription>
    </CalloutContainer>

    **The library owns:** display-safe generation, normalization, hashing, typed errors, ownership checks, and single-use state transitions.

    **Your application owns:** authorization, transaction boundaries, transport, policy, auditing, and authentication/session decisions.
  </Tab>
</IntegrationLevelTabs>

## Built-in routes [#built-in-routes]

All recovery-code settings routes require a session.

| Route                                    | Result                                  |
| ---------------------------------------- | --------------------------------------- |
| `POST /auth/recovery-codes/generate`     | Plaintext codes plus safe records       |
| `POST /auth/recovery-codes/regenerate`   | New plaintext codes plus safe records   |
| `POST /auth/recovery-codes/verify`       | `{ valid, code? }` and consumes a match |
| `GET /auth/recovery-codes/codes`         | Active unused code inventory            |
| `POST /auth/recovery-codes/codes/revoke` | `204 No Content`                        |

## Generation lifecycle [#generation-lifecycle]

<Steps>
  1. Require an authenticated settings session and application-owned fresh
     step-up. 2. Generate a batch. Core creates random display codes, hashes each
     normalized value, and persists only hashes. 3. Show plaintext values once.
     Keep them out of logs, traces, analytics, URLs, support tools, and
     server-rendered caches. 4. Later verification normalizes input, compares
     hashes with timing-safe equality, and marks the matching active code used. 5.
     If codes are lost or exposed, regenerate the entire batch and display the
     replacements once.
</Steps>

The default format is ten 16-character codes grouped as `XXXX-XXXX-XXXX-XXXX`. The alphabet omits ambiguous characters `0`, `1`, `I`, and `O`; input is trimmed, uppercased, and ignores spaces and hyphens.

:::caution[Generation does not replace an existing batch]
`generateForUser` and `/generate` add codes. Use `regenerateForUser` or `/regenerate` to create replacements and revoke the old active batch.
:::

## MFA and step-up [#mfa-and-step-up]

`POST /auth/recovery-codes/verify` is a current-session settings operation. It consumes a code but does not complete an MFA continuation or raise assurance.

* MFA uses `/auth/mfa/recovery-code/verify` or `/auth/mfa/recovery-code/verify-flow` through `MfaHttpOperations`.
* Step-up uses `/auth/step-up/recovery-code/verify` through `StepUpHttpOperations`.
* Recovery-code MFA completion records the backup-code authentication method; do not substitute the settings verification route.

Because verification consumes a code, retries and concurrent submissions must be tested against the production store's `markUsed` semantics.

## Security defaults [#security-defaults]

| Setting               |                                     Default |
| --------------------- | ------------------------------------------: |
| Codes per batch       |                                          10 |
| Ungrouped code length |                               16 characters |
| Display group size    |                                4 characters |
| Stored digest         | SHA-256 with `recovery-code:` domain prefix |

Counts must be 1-100, lengths 8-64, and group sizes 1-16. The standard security profile limits generate and regenerate to 10 per user per hour, verify and revoke to 20 per user per 10 minutes, and list to 60 per user per minute. MFA recovery-code verification is separately limited to 20 per IP per 10 minutes.

Hashes are less sensitive than plaintext but should not be returned, logged, or included in audit metadata. Recovery codes have limited entropy and must not be treated as ordinary unsalted password hashes outside this bounded, randomly generated format.

## HTTP errors [#http-errors]

| Code               | Status | Typical cause                                                               |
| ------------------ | -----: | --------------------------------------------------------------------------- |
| `bad_request`      |    400 | Invalid generation options, payload, code ID, or ownership/state transition |
| `unauthenticated`  |    401 | Missing or invalid session                                                  |
| `policy_denied`    |    403 | Security or last-strong-factor policy rejected the action                   |
| `step_up_required` |    403 | Configured removal policy requires fresher assurance                        |
| `request_rejected` |    403 | Origin validation rejected the request                                      |
| `rate_limited`     |    429 | A standard or application rule was exceeded                                 |
| `internal_error`   |    500 | Crypto, storage, session, or runtime failure                                |

Unknown, malformed, already-used, and revoked values normally produce a successful response with `valid: false`. Keep client messaging generic.

## Testing checklist [#testing-checklist]

* Generated codes use the configured count, length, grouping, and unambiguous alphabet.
* Plaintext codes never enter logs, traces, snapshots, analytics, URLs, or audit events.
* Storage contains hashes only; list and generation records never expose hashes.
* Lowercase, spaces, and hyphens normalize correctly; malformed values return `valid: false`.
* A code succeeds once and fails on reuse, including concurrent verification attempts.
* Regeneration returns a new display-once batch and revokes previous active codes.
* Cross-user code IDs cannot be revoked or disclosed.
* Generate, rotate, revoke, MFA, and step-up paths enforce their intended fresh-auth and rate-limit policy.
* Failure-injection tests cover partial regeneration if the production store is not transactional.

## Related documentation [#related-documentation]

* [MFA](/authentication/mfa/)
* [TOTP](/authentication/totp/)
* [Step-up Authentication](/authentication/step-up/)
* [Security Policies](/concepts/security-policies/)
* [Sessions](/concepts/sessions/)
* [Testing](/guides/testing/)

