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 authentication pauses until a selected factor succeeds

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 or an active recovery code. 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 {
  AuthKernelFromPrimitivesLayer,
  AuthKernelPrimitivesLayer,
} from "@effect-auth/core/AuthKernel";
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { CoreAuthHttpApiLive, MfaHttpApiLive } from "@effect-auth/core/HttpApi";
import {
  HttpBotVerifierCapability,
  HttpLoginRiskEnricherCapability,
  HttpTrustedDeviceCookieCapability,
  layerNoDeps as httpAuthenticationCapabilitiesLayerNoDeps,
} from "@effect-auth/core/HttpApi/HttpAuthenticationCapabilities";
import {
  HttpLoginApprovalFinalizerCapability,
  HttpLoginApprovalStatusCapability,
  LoginNotificationReportCapability,
  PasswordEmailVerificationCapability,
  layerNoDeps as httpEndpointCapabilitiesLayerNoDeps,
} from "@effect-auth/core/HttpApi/HttpEndpointCapabilities";
import { Layer } from "effect";

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

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

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

const HttpAuthenticationCapabilitiesLive =
  httpAuthenticationCapabilitiesLayerNoDeps({
    botVerifier: HttpBotVerifierCapability.Disabled(),
    trustedDeviceCookie: HttpTrustedDeviceCookieCapability.Disabled(),
    loginRiskEnricher: HttpLoginRiskEnricherCapability.Disabled(),
  });

const HttpEndpointCapabilitiesLayer = httpEndpointCapabilitiesLayerNoDeps({
  passwordEmailVerification: PasswordEmailVerificationCapability.Disabled(),
  loginNotificationReport: LoginNotificationReportCapability.Disabled(),
  loginApprovalStatus: HttpLoginApprovalStatusCapability.Disabled(),
  loginApprovalFinalizer: HttpLoginApprovalFinalizerCapability.Disabled(),
});

export const AuthApiLive = Layer.mergeAll(
  CoreAuthHttpApiLive,
  MfaHttpApiLive
).pipe(
  Layer.provide(AuthRateLimitStandardLive()),
  Layer.provide(HttpAuthenticationCapabilitiesLive),
  Layer.provide(HttpEndpointCapabilitiesLayer),
  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. HttpAuthenticationCapabilitiesLive explicitly selects the immutable request metadata used by maintained MFA limits, while HttpEndpointCapabilitiesLayer deliberately disables the optional core endpoint authorities in this recipe. Origin authority remains separate AuthHttpApiConfig middleware configuration. See MFA, TOTP, and Recovery Codes for complete feature layers and enrollment APIs.

No first-party durable direct TOTP login/session commit adapter exists yet. A production TOTP continuation must provide that atomic port from application storage. Recovery-code factor and session-rotation stores do have focused direct implementations. Do not use memory storage for production MFA state.

The App* Layers are app-owned composition boundaries. A Cloudflare Alchemy v2 deployment reuses the existing D1.Database for pending flows and factors, the Worker's DB binding for focused and application-owned stores, and the Durable Object rate limiter for password and continuation attempts; MFA adds no new binding.

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 selectedFactor: "totp" | "backup-code" = await selectMfaFactor(factors); // app-owned UI
  if (!factors.some(({ type }) => type === selectedFactor)) {
    throw new Error("Selected MFA factor is unavailable");
  }

  const completed =
    selectedFactor === "totp"
      ? await auth.mfa.totp.verify({ flowId: login.flowId, code: totpCode })
      : await auth.mfa.recoveryCode.verify({
          flowId: login.flowId,
          code: recoveryCode,
        });

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

selectMfaFactor is an app-owned prompt and must return only a factor listed by the server; expose recovery as an explicit user action. Recovery codes must be generated while authenticated, displayed once, stored safely, and consumed once. TOTP must be enrolled and confirmed before it is detectable. With no factor enrolled, when-factors-present permits password-only login; enforcing enrollment is a separate product prerequisite. Protect factor changes with authenticated settings, fresh step-up, and a last-factor-removal policy.

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