effect-auth

Recovery Codes

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.

Choose the highest useful level

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.

Preset

Mount the recovery-code API

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

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

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.

HTTP Operations

Own the contract

RecoveryCodesHttpOperations exposes the secured implementations used by the preset.

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) {}
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.

Primitives

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.

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

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

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

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

Primitives do not secure an HTTP boundary

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

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.

Built-in routes

All recovery-code settings routes require a session.

RouteResult
POST /auth/recovery-codes/generatePlaintext codes plus safe records
POST /auth/recovery-codes/regenerateNew plaintext codes plus safe records
POST /auth/recovery-codes/verify{ valid, code? } and consumes a match
GET /auth/recovery-codes/codesActive unused code inventory
POST /auth/recovery-codes/codes/revoke204 No Content

Generation lifecycle

  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.

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.

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

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

SettingDefault
Codes per batch10
Ungrouped code length16 characters
Display group size4 characters
Stored digestSHA-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

CodeStatusTypical cause
bad_request400Invalid generation options, payload, code ID, or ownership/state transition
unauthenticated401Missing or invalid session
policy_denied403Security or last-strong-factor policy rejected the action
step_up_required403Configured removal policy requires fresher assurance
request_rejected403Origin validation rejected the request
rate_limited429A standard or application rule was exceeded
internal_error500Crypto, 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

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

On this page