effect-auth

Recovery Codes

Generate, rotate, consume, inventory, and revoke single-use recovery codes with application-owned HTTP APIs.

Recovery codes are display-once backup factors. Core generates random user-friendly values, stores deterministic SHA-256 hashes, consumes a matching code once, and returns inventory projections without plaintext, hashes, or user IDs.

HTTP Operations

RecoveryCodesHttpOperations exposes generation, regeneration, settings verification, inventory, and revocation. Every settings operation derives the user from the current session and applies AuthRateLimit.

Configure the feature

RecoveryCodeManagement.layerNoDeps depends on RecoveryCodes, so provide RecoveryCodesLive explicitly instead of merging dependent layers as siblings:

recovery-code-services.live.ts
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { HttpAuthenticationCapabilitiesLive } from "./http-authentication-capabilities.live.js";
import {
  RecoveryCodeHttpConfigLive,
  RecoveryCodesHttpOperationsLive,
  strongFactorRemovalPolicyCapabilityLayerNoDeps,
  StrongFactorRemovalPolicyChoice,
} from "@effect-auth/core/HttpApi/RecoveryCodes";
import {
  RecoveryCodeManagement,
  RecoveryCodesLive,
} from "@effect-auth/core/RecoveryCode";
import { Layer } from "effect";

import {
  AppAuthRuntimeLive,
  AppRateLimitLive,
  AppSessionLive,
} from "./auth-runtime.live.js";

export const AppRecoveryCodeServicesLive =
  RecoveryCodeManagement.layerNoDeps.pipe(
    Layer.provideMerge(RecoveryCodesLive),
    Layer.provideMerge(AppAuthRuntimeLive)
  );

export const AppRecoveryCodeHttpConfigLive = RecoveryCodeHttpConfigLive({
  count: 10,
  length: 16,
  groupSize: 4,
});

export const AppRecoveryCodesHttpOperationsLive =
  RecoveryCodesHttpOperationsLive.pipe(
    Layer.provide(
      strongFactorRemovalPolicyCapabilityLayerNoDeps(
        StrongFactorRemovalPolicyChoice.Disabled()
      )
    ),
    Layer.provide(AuthRateLimitStandardLive()),
    Layer.provide(HttpAuthenticationCapabilitiesLive),
    Layer.provide(AppRecoveryCodeHttpConfigLive),
    Layer.provide(
      Layer.mergeAll(
        AppRecoveryCodeServicesLive,
        AppSessionLive,
        AppRateLimitLive
      )
    )
  );

AppAuthRuntimeLive supplies Crypto and a durable RecoveryCodeStore; AppSessionLive supplies Sessions and SessionCookie. SQLite creates recovery-code storage in migration 0007_auth_recovery_code; Postgres uses 0002_auth_security_oauth.

Define the contract

auth-api.ts
import {
  AuthOriginCheckMiddleware,
  AuthSchemaErrorMiddleware,
} from "@effect-auth/core/HttpApi";
import {
  recoveryCodeGenerateEndpoint,
  recoveryCodeListEndpoint,
  recoveryCodeRegenerateEndpoint,
  recoveryCodeRevokeEndpoint,
  recoveryCodeVerifyEndpoint,
} from "@effect-auth/core/HttpApi/RecoveryCodes";
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
) {}

All mutation payloads accept arbitrary metadata or reason strings. Allowlist keys, reject secrets and unnecessary PII, and enforce a serialized-size limit. Inventory projections may return that metadata, so they are hash-free rather than universally safe.

Bind the operations

recovery-code-api-group.live.ts
import { RecoveryCodesHttpOperations } from "@effect-auth/core/HttpApi/RecoveryCodes";
import { Effect, Layer } from "effect";
import { HttpApiBuilder } from "effect/unstable/httpapi";

import { AppAuthApi } from "./auth-api.js";
import { AppRecoveryCodesHttpOperationsLive } from "./recovery-code-services.live.js";

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(AppRecoveryCodesHttpOperationsLive));

StrongFactorRemovalPolicyChoice.Disabled() deliberately allows removal without last-factor enforcement and ignores any ambient StrongFactorRemovalPolicy. Use Enabled({ service: policy }) in the capability Layer to enforce the policy. The default inventory counts each unused recovery code as a separate strong factor; replace that policy if the product treats a batch as one recovery method.

Mount the application API

auth-http.live.ts
import {
  AuthHttpApiConfigLive,
  AuthOriginCheckMiddlewareLive,
  AuthSchemaErrorMiddlewareLive,
} from "@effect-auth/core/HttpApi";
import { Layer } from "effect";
import { HttpServer } from "effect/unstable/http";
import { HttpApiBuilder } from "effect/unstable/httpapi";

import { AppAuthApi } from "./auth-api.js";
import { AppRecoveryCodesHttpApiGroupLive } from "./recovery-code-api-group.live.js";

export const AppAuthHttpApiLive = HttpApiBuilder.layer(AppAuthApi).pipe(
  Layer.provide(AppRecoveryCodesHttpApiGroupLive),
  Layer.provide(
    AuthOriginCheckMiddlewareLive({
      mode: "secure",
      origins: ["https://app.example.com"],
    })
  ),
  Layer.provide(AuthSchemaErrorMiddlewareLive),
  Layer.provide(
    AuthHttpApiConfigLive({
      originPolicy: {
        mode: "secure",
        origins: ["https://app.example.com"],
      },
      requestMetadata: {
        ipSource: { _tag: "CloudflareConnectingIp" },
      },
    })
  ),
  Layer.provide(HttpServer.layerServices)
);

Enable proxy-header trust only behind a controlled reverse proxy. Add an application CSRF policy if strict origin checks and cookie policy do not meet the deployment boundary.

Protect generation, rotation, and removal

The stock operations authenticate the session, scope the user, and invoke standard rate limits. They do not require recent strong authentication, enforce recovery-remediation capabilities, emit audit events, or send notifications.

Require both appropriate assurance and freshness before exposing or replacing plaintext codes. A session containing recovery evidence is constrained to aal1 and carries remediation state; freshness alone is not sufficient.

The library owns: current-session lookup, standard rate-limit calls, generation and hashing, settings consumption, ownership checks, safe field projection, and optional last-factor enforcement.

Your application owns: public schemas, metadata policy, strict browser boundary, display-once UX, remediation authorization, recent strong authentication, auditing, notification, and retention.

Built-in contract

All settings routes require a session.

RouteRequestSuccess
POST /auth/recovery-codes/generateOptional metadataPlaintext codes plus hash-free records
POST /auth/recovery-codes/regenerateOptional metadata, revokeReasonReplacement plaintext codes and records
POST /auth/recovery-codes/verifycode; optional metadata{ valid, code? } and consumes a match
GET /auth/recovery-codes/codesNoneActive unused code inventory
POST /auth/recovery-codes/codes/revokecodeId; optional reason204 No Content

Generation adds another active batch. Regeneration atomically revokes active unused codes and inserts replacements through the store contract. Used and revoked history remains.

Unknown, malformed-string, used, revoked, and replayed values return HTTP 200 with { valid: false }. A malformed HTTP schema, such as a non-string code, returns bad_request.

Generation lifecycle

Authorize the settings action. Derive the current user and require application-owned recent strong authentication and remediation capability.

Generate the batch. Core creates random display codes, normalizes them, and hashes each ungrouped value.

Persist records. Store code IDs, deterministic hashes, timestamps, and allowlisted metadata, never plaintext.

Present once. Render or locally download plaintext values, then remove them from browser state, caches, and telemetry.

Verify and consume. Settings verification or an authentication commit marks exactly one matching active code used.

Rotate after loss or exposure. Regenerate the batch, atomically revoke prior active codes, and present replacements once.

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

Recovery codes do not expire automatically. Define retention for active, used, and revoked records and avoid unbounded additive generation.

MFA and step-up

POST /auth/recovery-codes/verify is a settings operation. It consumes a code but does not complete MFA, add evidence, or rotate the session.

  • /auth/mfa/recovery-code/verify derives the user from pending flow state and atomically consumes the flow and code while inserting the authenticated session.
  • /auth/step-up/recovery-code/verify atomically consumes the code and rotates the current session secret, but cookie delivery occurs after the database commit.

Recovery evidence uses canonical AMR recovery_code, is neither a primary nor an independent second factor, does not set mfaVerifiedAt, and adds the recovery_remediation requirement. Its presence constrains an otherwise derived aal2 or aal3 result to aal1; remediation enforcement remains a separate required boundary. backup-code is only the AuthFlow/UI factor identifier.

The remediation claim does not automatically block product routes. Enforce requireRecoveryRemediationSatisfied, requireAal, or an allowed remediation capability at each protected boundary. Complete remediation only after normal non-recovery evidence is established; completeRecoveryRemediation rotates the current session and can optionally revoke other sessions afterward.

The public HTTP claims schema currently exposes the recovery_remediation requirement but not its nested allowed-capability list. Do not depend on typed browser access to that internal list.

Security defaults

SettingDefault
Codes per batch10
Ungrouped code length16 characters
Display group size4 characters
AlphabetABCDEFGHJKLMNPQRSTUVWXYZ23456789
Entropy per code80 bits at the default length
Stored digestSHA-256 with recovery-code: domain prefix
Settings operationStandard rate limit
Generate10/user/hour
Regenerate10/user/hour
Verify20/user/10 minutes
List60/user/minute
Revoke20/user/10 minutes

MFA verification is separately limited to 20/IP/10 minutes; recovery step-up is 20/user/10 minutes. IP limits require trusted request metadata.

Hashes are deterministic, unsalted, and unpeppered. The default random 80-bit format resists ordinary offline guessing, but the permitted minimum eight-character format has only 40 bits and is unsuitable for production. Do not reuse this hash design for human-chosen recovery values.

HTTP errors

CodeStatusTypical cause
bad_request400Malformed payload or inaccessible code ID/state
unauthenticated401Missing, invalid, expired, or revoked session
policy_denied403Configured last-factor or application policy denied removal
step_up_required403Application boundary guard requires stronger authentication
request_rejected403HTTP origin validation rejected the request
rate_limited429A configured security rule was exceeded
internal_error500Invalid server generation config, crypto, storage, or runtime error

The stock removal policy can emit policy_denied, not step_up_required. Cross-user and missing code IDs share public errors.

Testing checklist

  • Generated codes use the configured count, length, grouping, and unambiguous alphabet.
  • Plaintext never enters logs, traces, snapshots, analytics, URLs, server-rendered caches, or audit events.
  • Storage contains hashes only; HTTP records never expose hashes or user IDs.
  • Metadata and reason fields are allowlisted, size-limited, and contain no secrets.
  • Lowercase, spaces, and hyphens normalize correctly; malformed string values return { valid: false }.
  • A code succeeds once and loses concurrent races safely.
  • Generation adds a batch; regeneration atomically replaces active unused codes and rolls back on store failure.
  • Cross-user code IDs cannot be revoked or disclosed.
  • Settings verification does not change assurance and cannot substitute for MFA.
  • MFA verification and recovery step-up assert atomic code/session behavior, expected assurance for configured evidence, canonical AMR, and remediation requirements.
  • Product routes enforce remediation requirements or explicit capabilities.
  • Cookie-delivery failure, concurrent regeneration, other-session handling, and row retention have application tests.

On this page