---
title: "MFA"
url: "https://effect-auth.itsbroly.com/authentication/mfa/"
description: "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.

:::caution[MFA is not step-up]
MFA completes a pending login and creates a session. [Step-up authentication](/authentication/step-up/) 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.
:::

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

    ### Mount the dedicated MFA API [#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.

    <CalloutContainer type="info">
      <CalloutTitle>
        Not included in 

        `CoreAuthHttpApiLive`
      </CalloutTitle>

      <CalloutDescription>
        `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`.
      </CalloutDescription>
    </CalloutContainer>

    ```ts title="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 [#continue-in-the-browser]

    ```ts title="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.
  </Tab>

  <Tab id="http-operations" value="HTTP Operations">
    ## HTTP Operations [#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.

    ```ts title="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) {}
    ```

    ```ts title="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.
  </Tab>

  <Tab id="primitives" value="Primitives">
    ## Primitives [#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.

    ```ts title="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:

    ```ts title="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.
  </Tab>
</IntegrationLevelTabs>

## Login lifecycle [#login-lifecycle]

<Steps>
  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.
</Steps>

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 [#built-in-routes]

| Route                                      | Identity source             | Result                                       |
| ------------------------------------------ | --------------------------- | -------------------------------------------- |
| `POST /auth/mfa/options`                   | Pending `flowId`            | Offered factors                              |
| `POST /auth/mfa/totp/verify-flow`          | Pending `flowId`            | Authenticated or login-approval continuation |
| `POST /auth/mfa/recovery-code/verify-flow` | Pending `flowId`            | Authenticated or login-approval continuation |
| `POST /auth/mfa/passkey/start`             | Pending `flowId`            | WebAuthn challenge                           |
| `POST /auth/mfa/passkey/verify`            | Pending flow plus challenge | Authenticated or login-approval continuation |
| `POST /auth/mfa/totp/verify`               | `flowId` plus `userId`      | Same as above                                |
| `POST /auth/mfa/recovery-code/verify`      | `flowId` plus `userId`      | Same 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 [#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 [#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 [#http-errors]

| Code                  | Status | Typical cause                                          |
| --------------------- | -----: | ------------------------------------------------------ |
| `bad_request`         |    400 | Invalid payload, flow, or passkey challenge            |
| `invalid_credentials` |    401 | Invalid TOTP or recovery code                          |
| `policy_denied`       |    403 | Flow/user mismatch or auth policy denial               |
| `request_rejected`    |    403 | Origin validation rejected the request                 |
| `rate_limited`        |    429 | Standard or application security limit exceeded        |
| `internal_error`      |    500 | Flow state, factor storage, crypto, or session failure |

## Testing checklist [#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.

## Related documentation [#related-documentation]

* [TOTP](/authentication/totp/)
* [Recovery Codes](/authentication/recovery-codes/)
* [Passkeys](/authentication/passkeys/)
* [Step-up Authentication](/authentication/step-up/)
* [Sessions](/concepts/sessions/)
* [Security Policies](/concepts/security-policies/)
* [Testing](/guides/testing/)

