---
title: "Require MFA After Password"
url: "https://effect-auth.itsbroly.com/recipes/require-mfa-after-password/"
description: "Continue password sign-in with TOTP or a one-time recovery code before issuing a session."
---



A password should prove only the first factor. Effect Auth can pause a successful password sign-in, return a typed `requires_mfa` result, and issue the session only after the same pending flow verifies an enrolled factor.

```text
password sign-in
      |
      v
requires_mfa { flowId, factors }   (no session)
      |
      +---- TOTP ------------------+
      |                            |
      +---- recovery code ---------+
                                   v
                     complete pending AuthFlow
                                   |
                    authenticated + session cookie
```

## Wire the policy and API [#wire-the-policy-and-api]

The built-in `when-factors-present` policy already covers password sign-in: it requires MFA when the user has an enrolled TOTP factor, an active recovery code, or another detected strong factor. Providing it explicitly makes the intended policy visible. `MfaHttpApiLive` is separate from `CoreAuthHttpApiLive`; mount both.

```ts title="auth-live.ts"
import { MfaRequirementPolicyLive } from "@effect-auth/core/AuthFlow";
import { AuthKernelLive } from "@effect-auth/core/AuthKernel";
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { CoreAuthHttpApiLive, MfaHttpApiLive } from "@effect-auth/core/HttpApi";
import { Layer } from "effect";

const MfaPolicyLive = MfaRequirementPolicyLive({
  mode: "when-factors-present",
  methods: ["password"],
});

// Optional AuthFlow capabilities are captured when the kernel is built.
const AppAuthKernelWithMfaLive = AuthKernelLive.pipe(
  Layer.provideMerge(MfaPolicyLive),
  Layer.provideMerge(AppAuthRuntimeLive)
);

const AppAuthServicesWithMfaLive = AppAuthFeaturesLive.pipe(
  Layer.provideMerge(AppAuthKernelWithMfaLive)
);

export const AuthApiLive = Layer.mergeAll(
  CoreAuthHttpApiLive,
  MfaHttpApiLive
).pipe(
  Layer.provide(AuthRateLimitStandardLive()),
  Layer.provide(AppAuthServicesWithMfaLive)
);
```

`AppAuthFeaturesLive` must include durable `AuthFlowState`, `TotpFactorManagement`, `RecoveryCodeManagement`, and the feature services required by the MFA preset; `AppAuthRuntimeLive` supplies their stores, crypto, sessions, cookies, and configuration. The MFA preset currently requires TOTP, recovery-code, and passkey services even if this UI offers only two. See [MFA](/authentication/mfa/), [TOTP](/authentication/totp/), and [Recovery Codes](/authentication/recovery-codes/) for complete feature layers and enrollment APIs.

## Continue the login [#continue-the-login]

Use the unified browser client. Treat continuations as successful protocol states, not exceptions. Query options from server-held flow state rather than trusting the factors rendered by an earlier page.

```ts title="sign-in.ts"
import { createAuthClient } from "@effect-auth/core/Client";

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

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

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

  const completed = factors.some(({ type }) => type === "totp")
    ? await auth.mfa.totp.verify({ flowId: login.flowId, code: totpCode })
    : factors.some(({ type }) => type === "backup-code")
      ? await auth.mfa.recoveryCode.verify({
          flowId: login.flowId,
          code: recoveryCode,
        })
      : undefined;

  if (completed?.type === "authenticated") {
    // MfaHttpApiLive has committed the session cookie; enter the application.
    location.assign("/app");
  }
}
```

Offer an explicit “use a recovery code” action instead of selecting it automatically. Recovery codes must be generated while authenticated, displayed once, stored safely by the user, and consumed once. TOTP must be enrolled and confirmed before it is detectable. If no factor is enrolled, `when-factors-present` allows password-only login; enforcing enrollment is a separate product prerequisite. Require authenticated account settings, fresh [step-up](/authentication/step-up/), and a last-factor-removal policy around enrollment, regeneration, and revocation.

MFA login is **not step-up**. MFA has a `flowId`, starts before any session exists, and creates a session. Step-up starts with an authenticated session, uses `/auth/step-up/*`, and strengthens that same session for a sensitive action.

## Verify and ship [#verify-and-ship]

1. Assert password success returns `requires_mfa`, lists only enrolled factor types, and creates no session.
2. Assert valid TOTP yields `aal2`, `amr: ["pwd", "totp"]`, `mfaVerifiedAt`, and a committed cookie.
3. Assert recovery fallback yields canonical `recovery_code`, enters constrained remediation, consumes the code once, and rejects replay. The options UI may still identify this factor as `backup-code`.
4. Assert invalid codes do not consume the flow; expired, replayed, or mismatched flows fail generically.
5. Keep `AuthRateLimitStandardLive()` and origin checks enabled; never log passwords, codes, flow IDs, or raw secrets. Test rate limits and concurrent recovery-code use against the production store.

Continue with [Sessions](/concepts/sessions/) and [Security Policies](/concepts/security-policies/).

