MFA
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.
MFA is not step-up
MFA completes a pending login and creates a session. Step-up authentication 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.
Preset
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.
Not included in CoreAuthHttpApiLive
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.
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
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.
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.
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) {}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.
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.
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:
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.
Login lifecycle
- A primary factor succeeds and
AuthFlowdetects enrolled factors allowed byMfaRequirementPolicy. 2. The login result isrequires_mfawith aflowIdand factor types; no session is created. 3. The browser selects an offered factor and proves it against the pending flow. 4.AuthFlow.completeMfaatomically consumes the flow, appends the factor AMR, raises assurance to at leastaal2, and recordsmfaVerifiedAt. 5. The flow resumes later policies. Success creates and commits a session; login approval may still be returned as a continuation.
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
| 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
- Primary password login normally starts with
aal1andamr: ["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 withrecovery_code, not unrestrictedaal2. - 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
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
| 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
- Primary login with enrolled factors returns
requires_mfaand 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 canonicalrecovery_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.