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.
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.
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.
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
- Assert password success returns
requires_mfa, lists only enrolled factor types, and creates no session. - Assert valid TOTP yields
aal2,amr: ["pwd", "totp"],mfaVerifiedAt, and a committed cookie. - 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 asbackup-code. - Assert invalid codes do not consume the flow; expired, replayed, or mismatched flows fail generically.
- 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.