effect-auth

Require MFA After Password

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.

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

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.

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, TOTP, and Recovery Codes for complete feature layers and enrollment APIs.

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.

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, 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

  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 and Security Policies.

On this page