effect-auth

MFA

Complete login with TOTP, recovery codes, or passkeys 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 one of the offered factors with the returned flowId. No authenticated session exists until the continuation completes.

MFA is not step-up

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

Preset

Mount the dedicated MFA API

MfaHttpApiLive mounts the reference /auth/mfa/* contract, including origin checks, schema-error sanitization, factor verification, flow completion, and session-cookie commitment.

Not included in CoreAuthHttpApiLive

CoreAuthHttpApiLive includes primary login routes but does not mount MfaHttpApiGroup. Mount MfaHttpApiLive separately. Factor enrollment and management are also separate APIs: TotpHttpApiLive, RecoveryCodesHttpApiLive, and PasskeyHttpApiLive.

auth.ts
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { AuthKernelLive } from "@effect-auth/core/AuthKernel";
import {
  AuthHttpApiConfigLive,
  CoreAuthHttpApiLive,
  MfaHttpApiLive,
} 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 FeaturesLive = Layer.mergeAll(
  PasswordDefaultLive(),
  PasskeyOptionsLive
);

const ServicesLive = FeaturesLive.pipe(
  Layer.provideMerge(AuthKernelLive),
  Layer.provideMerge(AppAuthRuntimeLive)
);

const HttpConfigLive = AuthHttpApiConfigLive({
  originCheck: { allowedOrigins: ["https://app.example.com"] },
});

export const AuthLive = Layer.mergeAll(
  CoreAuthHttpApiLive,
  MfaHttpApiLive
).pipe(
  Layer.provide(AuthRateLimitStandardLive()),
  Layer.provide(Layer.mergeAll(ServicesLive, AppRateLimitLive)),
  Layer.provide(HttpConfigLive),
  Layer.provide(HttpServer.layerServices)
);

AppAuthRuntimeLive must provide storage, crypto, secrets, session and flow-state storage, and the factor services you enable. The MFA operations layer requires TOTP, recovery-code, and passkey services; provide all three even if policy only offers a subset.

Continue in the browser

login.ts
import { createAuthClient } from "@effect-auth/core/Client";

const auth = createAuthClient({
  requestInit: { credentials: "include" },
});

const result = await auth.password.signIn({
  identity: { scope: { type: "global" }, kind: "email", value: email },
  password,
});

if (result.type === "requires_mfa") {
  const options = await auth.mfa.options({ flowId: result.flowId });

  if (options.factors.some((factor) => factor.type === "totp")) {
    await auth.mfa.totp.verify({ flowId: result.flowId, code });
  }
}

The standard client uses the safer verify-flow TOTP and recovery-code routes, which derive the user from server-side pending state. auth.mfa.passkey.verify({ flowId }) performs both passkey start and browser verification.

The preset owns: flow inspection and consumption, factor verification, auth-flow continuation, typed HTTP errors, security-policy execution, and cookie commitment.

Your application owns: factor enrollment, MFA policy, continuation UI, runtime dependencies, recovery UX, and post-login authorization.

HTTP Operations

MfaHttpOperations exposes the implementations used by the dedicated preset. Bind them to an application-owned HttpApi to select factors or customize the public contract.

mfa-api.ts
import {
  AuthOriginCheckMiddleware,
  AuthSchemaErrorMiddleware,
  mfaOptionsEndpoint,
  passkeyMfaStartEndpoint,
  passkeyMfaVerifyEndpoint,
  recoveryCodeMfaVerifyForFlowEndpoint,
  totpMfaVerifyForFlowEndpoint,
} from "@effect-auth/core/HttpApi";
import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi";

class AppMfaHttpApiGroup extends HttpApiGroup.make("mfa")
  .add(
    mfaOptionsEndpoint,
    totpMfaVerifyForFlowEndpoint,
    recoveryCodeMfaVerifyForFlowEndpoint,
    passkeyMfaStartEndpoint,
    passkeyMfaVerifyEndpoint
  )
  .prefix("/auth/mfa")
  .middleware(AuthSchemaErrorMiddleware)
  .middleware(AuthOriginCheckMiddleware) {}

export class AppAuthApi extends HttpApi.make("AppAuthApi")
  .add(AppMfaHttpApiGroup) {}
mfa-api.live.ts
import {
  MfaHttpOperations,
  MfaHttpOperationsLive,
} from "@effect-auth/core/HttpApi";
import { Effect, Layer } from "effect";
import { HttpApiBuilder } from "effect/unstable/httpapi";

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("verifyTotpForFlow", mfa.verifyTotpForFlow)
      .handle("verifyRecoveryCodeForFlow", mfa.verifyRecoveryCodeForFlow)
      .handle("startPasskey", mfa.startPasskey)
      .handle("verifyPasskey", mfa.verifyPasskey);
  })
).pipe(Layer.provide(MfaHttpOperationsLive));

Prefer verifyTotpForFlow and verifyRecoveryCodeForFlow for public clients. The lower-level verifyTotp and verifyRecoveryCode operations accept a client-supplied userId; they still verify flow/user consistency, but expose an unnecessary identifier when server-side flow state can derive it.

The library owns: domain orchestration, flow safety, standard operation security, endpoint success/error semantics, and cookie commitment.

Your application owns: endpoint selection, route names, public schemas, middleware, and additional boundary policy.

Primitives

Use AuthFlowState, AuthFlow, and factor services directly when your application owns transport and orchestration. AuthFlow.requireMfa stores pending primary-factor state; the completion route inspects that state without consuming it, verifies a factor for the pending user, and only then lets AuthFlow.completeMfa consume it and resume the configured login pipeline.

complete-totp-mfa.ts
import { AuthFlow, AuthFlowState } from "@effect-auth/core/AuthFlow";
import type { AuthFlowId } from "@effect-auth/core/Identifiers";
import { TotpFactorManagement } from "@effect-auth/core/Totp";
import { Data, Effect, Redacted } from "effect";

class InvalidMfaFactor extends Data.TaggedError("InvalidMfaFactor")<{}> {}

export const completeTotpMfa = (input: {
  readonly flowId: AuthFlowId;
  readonly code: string;
}) =>
  Effect.gen(function* () {
    const state = yield* AuthFlowState;
    const pending = yield* state.inspect(input.flowId);
    const totp = yield* TotpFactorManagement;
    const verified = yield* totp.verifyForUser({
      userId: pending.userId,
      code: Redacted.make(input.code),
      window: 1,
      metadata: { purpose: "login-mfa" },
    });

    if (!verified.valid) {
      return yield* new InvalidMfaFactor();
    }

    const authFlow = yield* AuthFlow;
    return yield* authFlow.completeMfa({
      flowId: input.flowId,
      userId: pending.userId,
      method: "totp",
      metadata: { purpose: "login-mfa" },
    });
  });

inspect is deliberately non-consuming, so an invalid code does not destroy the login attempt. completeMfa performs the single-use consume after successful verification and compares userId with the consumed state, closing the race between inspection and completion. Treat an AuthFlowStateError as an invalid or expired continuation and map InvalidMfaFactor to a generic credential error.

The result still needs transport orchestration:

mfa-route.ts
const result = yield* completeTotpMfa({ flowId, code });

switch (result._tag) {
  case "Authenticated":
    // Commit result.session with your SessionCookie service, then return success.
    return yield* commitAuthenticated(result.session);
  case "RequiresLoginApproval":
    // Return the next continuation. Do not commit an authenticated session yet.
    return yield* returnLoginApproval(result);
  case "PolicyDenied":
    return yield* returnSafePolicyDenial();
}

commitAuthenticated, returnLoginApproval, and returnSafePolicyDenial are application transport functions, not core APIs. If you use the preset or MfaHttpOperations, AuthHttp.commitMfaResult performs this result mapping and cookie commitment. Do not call completeMfa merely because a client says verification succeeded; also rate-limit attempts, audit without secrets, and never expose the pending userId.

The library owns: pending-flow state, single-use consumption, AAL/AMR merging, and continuation through later login policies.

Your application owns: factor verification orchestration, transport, rate limits, error mapping, cookie handling, auditing, and secret hygiene.

Login lifecycle

  1. A primary factor succeeds and AuthFlow detects enrolled factors allowed by MfaRequirementPolicy. 2. The login result is requires_mfa with a flowId and factor types; no session is created. 3. The browser selects an offered factor and proves it against the pending flow. 4. AuthFlow.completeMfa atomically consumes the flow, appends the factor AMR, raises assurance to at least aal2, and records mfaVerifiedAt. 5. The flow resumes later policies. Success creates and commits a session; login approval may still be returned as a continuation.

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

Built-in routes

RouteIdentity sourceResult
POST /auth/mfa/optionsPending flowIdOffered factors
POST /auth/mfa/totp/verify-flowPending flowIdAuthenticated or login-approval continuation
POST /auth/mfa/recovery-code/verify-flowPending flowIdAuthenticated or login-approval continuation
POST /auth/mfa/passkey/startPending flowIdWebAuthn challenge
POST /auth/mfa/passkey/verifyPending flow plus challengeAuthenticated or login-approval continuation
POST /auth/mfa/totp/verifyflowId plus userIdSame as above
POST /auth/mfa/recovery-code/verifyflowId plus userIdSame as above

Only use factors returned for that flow. The UI/options discriminator is backup-code; successful use is represented by canonical AMR and evidence value recovery_code. Do not persist the UI spelling as AMR.

Assurance and session semantics

  • Primary password login normally starts with aal1 and amr: ["pwd"].
  • Successful MFA appends verified TOTP or server-verified passkey evidence and derives canonical AMR without duplicates. Callers cannot request an AAL.
  • TOTP can establish local aal2; passkeys do so only with server-verified UV. Recovery-code use creates a constrained remediation session with recovery_code, not unrestricted aal2.
  • Freshness is derived per method and, where available, per stable factor from authentication-event timestamps.
  • Pending flow state is consumed once. Replays and expired, invalid, or mismatched flows fail.
  • A successful factor can still return requires_login_approval; factor success is not always final session issuance.

Security defaults

AuthRateLimitStandardLive() rate-limits MFA options to 30 requests per IP per 10 minutes; TOTP and recovery-code verification to 20 per IP per 10 minutes; and passkey start/verify to 30 per IP per 10 minutes. Origin middleware rejects disallowed browser origins when configured.

Treat flowId, passkey challenges, TOTP codes, recovery codes, and metadata as sensitive. Never log raw codes or WebAuthn credentials. Keep responses generic: invalid codes are invalid_credentials; invalid or expired flow/challenge data is bad_request.

HTTP errors

CodeStatusTypical cause
bad_request400Invalid payload, flow, or passkey challenge
invalid_credentials401Invalid TOTP or recovery code
policy_denied403Flow/user mismatch or auth policy denial
request_rejected403Origin validation rejected the request
rate_limited429Standard or application security limit exceeded
internal_error500Flow state, factor storage, crypto, or session failure

Testing checklist

  • Primary login with enrolled factors returns requires_mfa and creates no session.
  • Options disclose only factor types for the pending user.
  • Public TOTP and recovery-code routes derive identity from flowId.
  • Invalid factors do not consume the pending flow; successful completion does.
  • Expired, replayed, and user-mismatched flows fail safely.
  • TOTP and UV-passkey completion produce derived aal2; recovery completion produces constrained remediation and canonical recovery_code.
  • Recovery codes are single-use and passkeys are bound to the pending user.
  • Login approval after MFA remains a continuation rather than being treated as failure.
  • Origin, schema, and rate-limit errors never echo codes, credentials, or hostile headers.

On this page