effect-auth

MFA

Complete a pending login with TOTP or recovery-code evidence before creating an authenticated session.

Multi-factor authentication (MFA) is a login-flow continuation. A primary factor can return requires_mfa; the browser then proves another factor with the returned flowId. No authenticated session exists until the continuation reaches authentication.

MFA is not step-up

MFA completes a pending login and creates a session. Step-up authentication starts with an authenticated session and rotates that session before a sensitive action. Never send a login flowId to step-up routes or use MFA routes to elevate a current session.

HTTP Operations

MfaHttpOperations exposes options, TOTP, and recovery-code handlers independently of route assembly. Bind the operations your application publishes to an application-owned HttpApi.

Configure the feature

MfaHttpOperationsLive requires:

  • AuthRateLimit and TotpHttpConfig
  • AuthFlowState, AuthFlow, and AuthHttp
  • TotpFactorManagement and RecoveryCodeManagement
  • VerificationStore, UserStore, TotpLoginCommitStore, and RecoveryCodeLoginCommitStore
mfa-services.live.ts
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { HttpAuthenticationCapabilitiesLive } from "./http-authentication-capabilities.live.js";
import { TotpHttpConfigLive } from "@effect-auth/core/HttpApi";
import { MfaHttpOperationsLive } from "@effect-auth/core/HttpApi/Mfa";
import { Layer } from "effect";

import { AppMfaRuntimeLive } from "./auth-runtime.live.js";

const AppTotpHttpConfigLive = TotpHttpConfigLive({
  issuer: "Example",
  window: 1,
});
export const AppMfaHttpOperationsLive = MfaHttpOperationsLive.pipe(
  Layer.provide(AuthRateLimitStandardLive()),
  Layer.provide(HttpAuthenticationCapabilitiesLive),
  Layer.provide(AppTotpHttpConfigLive),
  Layer.provide(AppMfaRuntimeLive)
);

AppMfaRuntimeLive is an application aggregate for the remaining services above. Its AuthFlow must be constructed with AuthFlowState, enabled factor-management services, and any custom MfaRequirementPolicy visible when the flow layer is built; Core uses the default policy when no custom policy is present. Adding services only outside an already-built auth kernel does not retroactively enable MFA. Maintained SQL adapters supply both atomic login commit stores.

Define the contract

auth-api.ts
import {
  AuthOriginCheckMiddleware,
  AuthSchemaErrorMiddleware,
} from "@effect-auth/core/HttpApi";
import {
  mfaOptionsEndpoint,
  recoveryCodeMfaVerifyEndpoint,
  totpMfaVerifyEndpoint,
} from "@effect-auth/core/HttpApi/Mfa";
import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi";

class AppMfaHttpApiGroup extends HttpApiGroup.make("mfa")
  .add(mfaOptionsEndpoint, totpMfaVerifyEndpoint, recoveryCodeMfaVerifyEndpoint)
  .prefix("/auth/mfa")
  .middleware(AuthSchemaErrorMiddleware)
  .middleware(AuthOriginCheckMiddleware) {}

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

Both verification operations derive the user from flowId; client-supplied user identifiers are not part of the contract.

Bind the operations

mfa-api-group.live.ts
import { MfaHttpOperations } from "@effect-auth/core/HttpApi/Mfa";
import { Effect, Layer } from "effect";
import { HttpApiBuilder } from "effect/unstable/httpapi";

import { AppAuthApi } from "./auth-api.js";
import { AppMfaHttpOperationsLive } from "./mfa-services.live.js";

export const AppMfaHttpApiGroupLive = HttpApiBuilder.group(
  AppAuthApi,
  "mfa",
  Effect.fn("app.auth.mfa")(function* (handlers) {
    const mfa = yield* MfaHttpOperations;

    return handlers
      .handle("options", mfa.options)
      .handle("verifyTotp", mfa.verifyTotp)
      .handle("verifyRecoveryCode", mfa.verifyRecoveryCode);
  })
).pipe(Layer.provide(AppMfaHttpOperationsLive));

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 { AppMfaHttpApiGroupLive } from "./mfa-api-group.live.js";

export const AppAuthHttpApiLive = HttpApiBuilder.layer(AppAuthApi).pipe(
  Layer.provide(AppMfaHttpApiGroupLive),
  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. Factor payloads can contain arbitrary metadata, which is merged into pending metadata with request values taking precedence. Allowlist keys, reject security-sensitive values and secrets, and enforce a serialized-size limit before policy code consumes it.

The library owns: factor and flow orchestration, standard operation security calls, typed success/error semantics, terminal TOTP/recovery transaction integration, and cookie commitment.

Your application owns: endpoint selection, runtime composition, strict browser policy, metadata policy, MFA requirement policy, enrollment, continuation UI, auditing, recovery UX, and post-login authorization.

Built-in contract

The focused preset exposes exactly three operations:

RouteIdentity sourceResult
POST /auth/mfa/optionsPending flowIdCurrently available factor types
POST /auth/mfa/totp/verifyPending flowIdAuthenticated or login-approval continuation
POST /auth/mfa/recovery-code/verifyPending flowIdAuthenticated or login-approval continuation

The initial requires_mfa result contains factors selected by MfaRequirementPolicy, and pending flow state retains that allowed list. /options intersects the retained list with factors currently available for the pending user. Maintained verification rejects factors that were not retained for that flow.

The UI/options discriminator is backup-code; successful evidence and AMR use canonical recovery_code. Never persist the UI spelling as AMR.

Login lifecycle

Complete the primary factor. AuthFlow derives primary evidence and evaluates the login pipeline without creating a session.

Apply MFA policy. MfaRequirementPolicy receives the sign-in method, risk context, and currently detected factors.

Start pending state. A required continuation returns requires_mfa with a single-use flowId; no authenticated session exists.

Inspect options and prove a factor. The server derives the pending user, verifies TOTP or recovery-code evidence, and rejects invalid state.

Derive the next result. Core derives AMR, assurance, freshness, and any recovery-remediation requirement from server-created evidence.

Commit or continue. Terminal TOTP/recovery verification paths commit factor state, pending verification, and session storage atomically; later login approval remains a continuation and no session cookie is issued yet.

The default MFA requirement mode is when-factors-present for password, email OTP, magic-link, and OAuth sign-in. It skips sign-up, other methods, and users with no detected factor. Configure MfaRequirementPolicyLive({ mode: "disabled" }) or provide a custom MfaRequirementPolicy when product or risk rules differ.

Assurance and transaction semantics

  • Primary password login normally starts with aal1 and amr: ["pwd"].
  • TOTP can establish local aal2; callers never choose an AAL.
  • Recovery-code evidence adds canonical recovery_code and a recovery_remediation requirement. It is neither a primary nor an independent second factor, does not set ordinary MFA freshness, and constrains any otherwise derived aal2 or aal3 result to aal1.
  • The terminal authenticated TOTP/recovery path uses the corresponding login commit store. Cookie delivery happens after that database commit.
  • A result that requires login approval does not use the same terminal factor/flow/session transaction. Treat it as a new continuation rather than claiming final consumption.
  • Primitive AuthFlow.completeMfa consumes pending flow state before later policy/session work. It is single-use, but not a transaction with independently verified factor state or session storage.

The recovery-remediation claim is not a global firewall. On ordinary protected routes, require remediation to be satisfied and enforce appropriate assurance. On an approved remediation endpoint, allow the required remediation capability while the claim is still present instead of also requiring it to be absent.

Security defaults

AuthRateLimitStandardLive() limits MFA options to 30 requests per IP per 10 minutes and TOTP/recovery-code verification to 20 per IP per 10 minutes. IP limits depend on trusted request metadata.

Origin middleware rejects disallowed or missing Origin/Referer evidence for unsafe methods. Fetch Metadata never establishes authority. Treat flowId, TOTP codes, recovery codes, credentials, and metadata as sensitive. Never log raw values and keep credential failures generic.

HTTP errors

CodeStatusTypical cause
bad_request400Invalid payload or flow
invalid_credentials401Invalid factor or failed atomic terminal commit
policy_denied403Flow/user mismatch or authentication policy denial
step_up_required403An application guard requires stronger current assurance
request_rejected403Origin validation rejected the request
rate_limited429Standard or application security limit exceeded
internal_error500Flow, factor, crypto, user, or session dependency failed

Testing checklist

  • Primary login with enrolled factors returns requires_mfa and creates no session.
  • Policy-selected factors and the current /options projection are tested separately.
  • Public TOTP and recovery-code routes derive identity from flowId.
  • Invalid factors preserve pending state where promised; expired and replayed flows fail safely.
  • Terminal TOTP/recovery tests cover concurrent attempts and transaction rollback.
  • Login approval after MFA remains a continuation and does not issue an authenticated cookie.
  • TOTP evidence derives expected aal2; recovery evidence derives canonical AMR and remediation constraints.
  • Product routes enforce recovery remediation rather than trusting assurance alone.
  • Metadata allowlisting, origin policy, rate limits, and errors never disclose codes, credentials, users, or hostile headers.
  • Cookie-delivery failure after a database commit has an explicit retry and user-recovery strategy.

On this page