MFA
Complete a pending login with TOTP or recovery-code evidence 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 another factor with the returned flowId. No authenticated session exists until the continuation reaches authentication.
MFA is not step-up
MFA completes a pending login and creates a session. Step-up authentication starts with an authenticated session and rotates that session before a sensitive action. Never send a login flowId to step-up routes or use MFA routes to elevate a current session.
HTTP Operations
MfaHttpOperations exposes options, TOTP, and recovery-code handlers independently of route assembly. Bind the operations your application publishes to an application-owned HttpApi.
Configure the feature
MfaHttpOperationsLive requires:
AuthRateLimitandTotpHttpConfigAuthFlowState,AuthFlow, andAuthHttpTotpFactorManagementandRecoveryCodeManagementVerificationStore,UserStore,TotpLoginCommitStore, andRecoveryCodeLoginCommitStore
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { HttpAuthenticationCapabilitiesLive } from "./http-authentication-capabilities.live.js";
import { TotpHttpConfigLive } from "@effect-auth/core/HttpApi";
import { MfaHttpOperationsLive } from "@effect-auth/core/HttpApi/Mfa";
import { Layer } from "effect";
import { AppMfaRuntimeLive } from "./auth-runtime.live.js";
const AppTotpHttpConfigLive = TotpHttpConfigLive({
issuer: "Example",
window: 1,
});
export const AppMfaHttpOperationsLive = MfaHttpOperationsLive.pipe(
Layer.provide(AuthRateLimitStandardLive()),
Layer.provide(HttpAuthenticationCapabilitiesLive),
Layer.provide(AppTotpHttpConfigLive),
Layer.provide(AppMfaRuntimeLive)
);AppMfaRuntimeLive is an application aggregate for the remaining services above. Its AuthFlow must be constructed with AuthFlowState, enabled factor-management services, and any custom MfaRequirementPolicy visible when the flow layer is built; Core uses the default policy when no custom policy is present. Adding services only outside an already-built auth kernel does not retroactively enable MFA. Maintained SQL adapters supply both atomic login commit stores.
Define the contract
import {
AuthOriginCheckMiddleware,
AuthSchemaErrorMiddleware,
} from "@effect-auth/core/HttpApi";
import {
mfaOptionsEndpoint,
recoveryCodeMfaVerifyEndpoint,
totpMfaVerifyEndpoint,
} from "@effect-auth/core/HttpApi/Mfa";
import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi";
class AppMfaHttpApiGroup extends HttpApiGroup.make("mfa")
.add(mfaOptionsEndpoint, totpMfaVerifyEndpoint, recoveryCodeMfaVerifyEndpoint)
.prefix("/auth/mfa")
.middleware(AuthSchemaErrorMiddleware)
.middleware(AuthOriginCheckMiddleware) {}
export class AppAuthApi extends HttpApi.make("AppAuthApi").add(
AppMfaHttpApiGroup
) {}Both verification operations derive the user from flowId; client-supplied user identifiers are not part of the contract.
Bind the operations
import { MfaHttpOperations } from "@effect-auth/core/HttpApi/Mfa";
import { Effect, Layer } from "effect";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { AppAuthApi } from "./auth-api.js";
import { AppMfaHttpOperationsLive } from "./mfa-services.live.js";
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("verifyTotp", mfa.verifyTotp)
.handle("verifyRecoveryCode", mfa.verifyRecoveryCode);
})
).pipe(Layer.provide(AppMfaHttpOperationsLive));Mount the application API
import {
AuthHttpApiConfigLive,
AuthOriginCheckMiddlewareLive,
AuthSchemaErrorMiddlewareLive,
} from "@effect-auth/core/HttpApi";
import { Layer } from "effect";
import { HttpServer } from "effect/unstable/http";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { AppAuthApi } from "./auth-api.js";
import { AppMfaHttpApiGroupLive } from "./mfa-api-group.live.js";
export const AppAuthHttpApiLive = HttpApiBuilder.layer(AppAuthApi).pipe(
Layer.provide(AppMfaHttpApiGroupLive),
Layer.provide(
AuthOriginCheckMiddlewareLive({
mode: "secure",
origins: ["https://app.example.com"],
})
),
Layer.provide(AuthSchemaErrorMiddlewareLive),
Layer.provide(
AuthHttpApiConfigLive({
originPolicy: {
mode: "secure",
origins: ["https://app.example.com"],
},
requestMetadata: {
ipSource: { _tag: "CloudflareConnectingIp" },
},
})
),
Layer.provide(HttpServer.layerServices)
);Enable proxy-header trust only behind a controlled reverse proxy. Factor payloads can contain arbitrary metadata, which is merged into pending metadata with request values taking precedence. Allowlist keys, reject security-sensitive values and secrets, and enforce a serialized-size limit before policy code consumes it.
The library owns: factor and flow orchestration, standard operation security calls, typed success/error semantics, terminal TOTP/recovery transaction integration, and cookie commitment.
Your application owns: endpoint selection, runtime composition, strict browser policy, metadata policy, MFA requirement policy, enrollment, continuation UI, auditing, recovery UX, and post-login authorization.
Built-in contract
The focused preset exposes exactly three operations:
| Route | Identity source | Result |
|---|---|---|
POST /auth/mfa/options | Pending flowId | Currently available factor types |
POST /auth/mfa/totp/verify | Pending flowId | Authenticated or login-approval continuation |
POST /auth/mfa/recovery-code/verify | Pending flowId | Authenticated or login-approval continuation |
The initial requires_mfa result contains factors selected by MfaRequirementPolicy, and pending flow state retains that allowed list. /options intersects the retained list with factors currently available for the pending user. Maintained verification rejects factors that were not retained for that flow.
The UI/options discriminator is backup-code; successful evidence and AMR use canonical recovery_code. Never persist the UI spelling as AMR.
Login lifecycle
Complete the primary factor. AuthFlow derives primary evidence and
evaluates the login pipeline without creating a session.
Apply MFA policy. MfaRequirementPolicy receives the sign-in method,
risk context, and currently detected factors.
Start pending state. A required continuation returns requires_mfa with
a single-use flowId; no authenticated session exists.
Inspect options and prove a factor. The server derives the pending user, verifies TOTP or recovery-code evidence, and rejects invalid state.
Derive the next result. Core derives AMR, assurance, freshness, and any recovery-remediation requirement from server-created evidence.
Commit or continue. Terminal TOTP/recovery verification paths commit factor state, pending verification, and session storage atomically; later login approval remains a continuation and no session cookie is issued yet.
The default MFA requirement mode is when-factors-present for password, email OTP, magic-link, and OAuth sign-in. It skips sign-up, other methods, and users with no detected factor. Configure MfaRequirementPolicyLive({ mode: "disabled" }) or provide a custom MfaRequirementPolicy when product or risk rules differ.
Assurance and transaction semantics
- Primary password login normally starts with
aal1andamr: ["pwd"]. - TOTP can establish local
aal2; callers never choose an AAL. - Recovery-code evidence adds canonical
recovery_codeand arecovery_remediationrequirement. It is neither a primary nor an independent second factor, does not set ordinary MFA freshness, and constrains any otherwise derivedaal2oraal3result toaal1. - The terminal authenticated TOTP/recovery path uses the corresponding login commit store. Cookie delivery happens after that database commit.
- A result that requires login approval does not use the same terminal factor/flow/session transaction. Treat it as a new continuation rather than claiming final consumption.
- Primitive
AuthFlow.completeMfaconsumes pending flow state before later policy/session work. It is single-use, but not a transaction with independently verified factor state or session storage.
The recovery-remediation claim is not a global firewall. On ordinary protected routes, require remediation to be satisfied and enforce appropriate assurance. On an approved remediation endpoint, allow the required remediation capability while the claim is still present instead of also requiring it to be absent.
Security defaults
AuthRateLimitStandardLive() limits MFA options to 30 requests per IP per 10 minutes and TOTP/recovery-code verification to 20 per IP per 10 minutes. IP limits depend on trusted request metadata.
Origin middleware rejects disallowed or missing Origin/Referer evidence for unsafe methods. Fetch Metadata never establishes authority. Treat flowId, TOTP codes, recovery codes, credentials, and metadata as sensitive. Never log raw values and keep credential failures generic.
HTTP errors
| Code | Status | Typical cause |
|---|---|---|
bad_request | 400 | Invalid payload or flow |
invalid_credentials | 401 | Invalid factor or failed atomic terminal commit |
policy_denied | 403 | Flow/user mismatch or authentication policy denial |
step_up_required | 403 | An application guard requires stronger current assurance |
request_rejected | 403 | Origin validation rejected the request |
rate_limited | 429 | Standard or application security limit exceeded |
internal_error | 500 | Flow, factor, crypto, user, or session dependency failed |
Testing checklist
- Primary login with enrolled factors returns
requires_mfaand creates no session. - Policy-selected factors and the current
/optionsprojection are tested separately. - Public TOTP and recovery-code routes derive identity from
flowId. - Invalid factors preserve pending state where promised; expired and replayed flows fail safely.
- Terminal TOTP/recovery tests cover concurrent attempts and transaction rollback.
- Login approval after MFA remains a continuation and does not issue an authenticated cookie.
- TOTP evidence derives expected
aal2; recovery evidence derives canonical AMR and remediation constraints. - Product routes enforce recovery remediation rather than trusting assurance alone.
- Metadata allowlisting, origin policy, rate limits, and errors never disclose codes, credentials, users, or hostile headers.
- Cookie-delivery failure after a database commit has an explicit retry and user-recovery strategy.