TOTP
Enroll, verify, inventory, and revoke authenticator-app factors at the integration level your application needs.
TOTP support covers RFC-compatible secret generation and verification, durable factor management, a typed HTTP API, and integration with MFA and step-up flows.
Choose the highest useful level
Use Preset for the built-in /auth/totp/* settings API. Use HTTP Operations for an application-owned contract, or Primitives when your application owns factor storage or authentication orchestration.
Preset
Mount the TOTP API
TotpHttpApiLive mounts the complete TOTP settings API. It is a feature preset, not part of CoreAuthHttpApiLive, so mount it explicitly.
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import {
TotpHttpApiLive,
TotpHttpConfigLive,
} from "@effect-auth/core/HttpApi";
import {
TotpFactorManagementLive,
TotpLive,
} from "@effect-auth/core/Totp";
import { Layer } from "effect";
import { HttpServer } from "effect/unstable/http";
const TotpDomainLive = Layer.mergeAll(
TotpLive,
TotpFactorManagementLive
).pipe(Layer.provideMerge(AppAuthRuntimeLive));
export const TotpHttpApiGroupLive = TotpHttpApiLive.pipe(
Layer.provide(AuthRateLimitStandardLive()),
Layer.provide(TotpDomainLive),
Layer.provide(AppSessionLive),
Layer.provide(AppRateLimitLive),
Layer.provide(
TotpHttpConfigLive({
issuer: "Example",
window: 1,
})
),
Layer.provide(HttpServer.layerServices)
);AppAuthRuntimeLive supplies Crypto and a durable TotpFactorStore. AppSessionLive supplies Sessions and SessionCookie. The Effect-QB storage adapters include TOTP support after migration 0006_auth_totp_factor.
Browser client
import { createTotpClient } from "@effect-auth/core/Client";
export const totp = createTotpClient({
requestInit: { credentials: "include" },
});
const started = await totp.enrollment.start({
accountName: "reader@example.com",
metadata: { label: "Authenticator app" },
});
await totp.enrollment.confirm({
factorId: started.factor.factorId,
code,
});
const { factors } = await totp.factors.list();
await totp.factors.revoke({ factorId, reason: "user_request" });The start response contains plaintext secret and uri. Render the URI as a QR code or show the secret for manual entry, then discard both after confirmation.
The preset owns: current-session lookup, endpoint orchestration, schemas, origin checks, standard security-policy execution, ownership checks, and safe response projection.
Your application owns: durable secret protection, QR UI, step-up before enrollment or removal, audit/notification delivery, and whether a user may remove their last strong factor.
HTTP Operations
Own the contract
TotpHttpOperations contains the same secured operations used by the preset. Reuse the endpoint contracts when only grouping, middleware, or mounting differs.
import {
AuthOriginCheckMiddleware,
AuthSchemaErrorMiddleware,
totpEnrollmentConfirmEndpoint,
totpEnrollmentStartEndpoint,
totpFactorListEndpoint,
totpFactorRevokeEndpoint,
totpVerifyEndpoint,
} from "@effect-auth/core/HttpApi";
import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi";
class AppTotpHttpApiGroup extends HttpApiGroup.make("totp")
.add(
totpEnrollmentStartEndpoint,
totpEnrollmentConfirmEndpoint,
totpVerifyEndpoint,
totpFactorListEndpoint,
totpFactorRevokeEndpoint
)
.prefix("/auth/totp")
.middleware(AuthSchemaErrorMiddleware)
.middleware(AuthOriginCheckMiddleware) {}
export class AppAuthApi extends HttpApi.make("AppAuthApi")
.add(AppTotpHttpApiGroup) {}import {
TotpHttpOperations,
TotpHttpOperationsLive,
} from "@effect-auth/core/HttpApi";
import { Effect, Layer } from "effect";
import { HttpApiBuilder } from "effect/unstable/httpapi";
export const AppTotpHttpApiGroupLive = HttpApiBuilder.group(
AppAuthApi,
"totp",
Effect.fn("app.auth.totp")(function* (handlers) {
const totp = yield* TotpHttpOperations;
return handlers
.handle("startEnrollment", totp.startEnrollment)
.handle("confirmEnrollment", totp.confirmEnrollment)
.handle("verify", totp.verify)
.handle("listFactors", totp.listFactors)
.handle("revokeFactor", totp.revokeFactor);
})
).pipe(Layer.provide(TotpHttpOperationsLive));Every operation derives userId from the session. Do not add a browser-controlled user ID. TotpHttpOperationsLive also applies AuthRateLimit and uses StrongFactorRemovalPolicy on revoke when that optional service is provided.
The library owns: TOTP orchestration, current-session scoping, standard operation security, typed HTTP results, and optional last-factor enforcement.
Your application owns: public schemas, routes, middleware, extra step-up requirements, and session-upgrade behavior.
Primitives
Enroll and confirm for the current user
TotpFactorManagement is the normal primitive. Bind every operation to the authenticated CurrentSession; never accept userId from a form or API payload. Require your settings policy before exposing a new shared secret.
import { CurrentSession } from "@effect-auth/core/Sessions";
import { requireFresh } from "@effect-auth/core/StepUp";
import { TotpFactorManagement } from "@effect-auth/core/Totp";
import { Duration, Effect, Redacted } from "effect";
export const startTotpEnrollment = (
accountName: string,
label: string,
) =>
Effect.gen(function* () {
yield* requireFresh(Duration.minutes(10));
const session = yield* CurrentSession;
const factors = yield* TotpFactorManagement;
return yield* factors.startEnrollment({
userId: session.userId,
issuer: "Example",
accountName,
metadata: { label },
});
});
export const confirmTotpEnrollment = (
factorId: Parameters<
TotpFactorManagement["confirmEnrollment"]
>[0]["factorId"],
code: string,
) =>
Effect.gen(function* () {
yield* requireFresh(Duration.minutes(10));
const session = yield* CurrentSession;
const factors = yield* TotpFactorManagement;
return yield* factors.confirmEnrollment({
userId: session.userId,
factorId,
code: Redacted.make(code),
window: 1,
metadata: { confirmedFromSession: session.sessionId },
});
});startEnrollment persists a pending factor and returns a redacted plaintext secret and uri. Unwrap them only at the final presentation boundary, never in logs or telemetry. confirmEnrollment verifies ownership and the code before setting confirmedAt; retain the server-side factor ID between these two requests rather than trusting an unrelated user's ID.
Verify and make an authentication decision
Use MfaHttpOperations for pending login or StepUpHttpOperations for a current session. Their factor-specific TOTP paths verify the code, obtain server-produced totpEvidence with the accepted absolute counter, atomically advance the factor replay floor, append evidence, rotate the session token, and commit the replacement cookie. Do not compose verifyForUser and a generic session update and call it atomic.
The default replay policy accepts only a counter greater than lastAcceptedCounter. Consequently the same counter cannot succeed twice, including concurrently, and an older delayed window is rejected after a newer counter succeeds. This monotonic floor deliberately favors replay safety over out-of-order acceptance. For custom server-only orchestration, construct evidence with totpEvidence({ factorId, acceptedCounter, verifiedAt }) only from the trusted verification result and preserve the same transactional boundary.
Inventory and revoke
import { CurrentSession } from "@effect-auth/core/Sessions";
import { requireFresh } from "@effect-auth/core/StepUp";
import {
StrongFactorRemovalPolicy,
strongFactor,
} from "@effect-auth/core/StrongFactor";
import { TotpFactorManagement } from "@effect-auth/core/Totp";
import { Duration, Effect } from "effect";
export const listTotpFactors = Effect.gen(function* () {
const session = yield* CurrentSession;
return yield* TotpFactorManagement.use((factors) =>
factors.listForUser({ userId: session.userId }),
);
});
export const revokeTotpFactor = (factorId: Parameters<
TotpFactorManagement["revokeForUser"]
>[0]["factorId"]) =>
Effect.gen(function* () {
yield* requireFresh(Duration.minutes(10));
const session = yield* CurrentSession;
const removal = yield* StrongFactorRemovalPolicy;
const factors = yield* TotpFactorManagement;
yield* removal.requireCanRemove({
userId: session.userId,
factor: strongFactor("totp", factorId),
});
yield* factors.revokeForUser({
userId: session.userId,
factorId,
reason: "user_request",
});
});Normal listing excludes pending and revoked factors. Administrative diagnostics can opt into includeUnconfirmed or includeRevoked, but do not expose those switches directly without an authorization policy. The primitive revoke checks ownership, while StrongFactorRemovalPolicy is a separate application-composed guard that protects the last strong factor.
Low-level TOTP
Use Totp directly only when your application owns factor storage or secret retrieval.
import { Totp } from "@effect-auth/core/Totp";
const result = yield* Totp.use((totp) =>
totp.verify({ secret, code: Redacted.make(code), window: 1 })
);TotpLive provides generation, base32 normalization, otpauth:// URI construction, code generation, and timing-safe verification. TotpFactorManagementLive additionally requires TotpFactorStore and Crypto.
Primitives do not secure an HTTP boundary
They do not authenticate a request, rate-limit attempts, require step-up, upgrade a session, or emit audit events. Those controls become your responsibility.
The library owns: RFC-compatible TOTP calculation, typed domain errors, factor state transitions, ownership checks, and safe factor projections.
Your application owns: authorization, transport, policy, secret-at-rest protection, auditing, and authentication/session decisions.
Built-in routes
All TOTP settings routes require a session.
| Route | Result |
|---|---|
POST /auth/totp/enroll/start | Pending factor plus plaintext secret and URI |
POST /auth/totp/enroll/confirm | Confirmed factor metadata |
POST /auth/totp/verify | { valid, factor?, delta? } |
GET /auth/totp/factors | Safe active factor inventory |
POST /auth/totp/factors/revoke | 204 No Content |
Enrollment lifecycle
- Require an authenticated settings session and any application-owned fresh
step-up. 2. Start enrollment. Core generates a secret, stores a pending
factor, and returns the secret and
otpauth://URI. 3. Render the URI or manual secret without logging, tracing, or persisting it in browser analytics. - Confirm with a code from the authenticator. An invalid code leaves the
factor pending. 5. On success, core sets
confirmedAt; only then does the factor appear in normal lists and verification.
Pending enrollment has no built-in expiry or cleanup job. Define an application retention policy for abandoned pending factors.
MFA and step-up
POST /auth/totp/verify verifies a factor for an already authenticated settings session; it does not complete an MFA continuation or raise the session assurance level.
- MFA uses
/auth/mfa/totp/verifyor/auth/mfa/totp/verify-flowthroughMfaHttpOperations. - Step-up uses
/auth/step-up/totp/verifythroughStepUpHttpOperations. - At the primitive level, call the appropriate
AuthFlowoperation or session upgrade only afterverifyForUsersucceeds.
Do not treat { valid: false } as an infrastructure error, and never upgrade a session unless valid is true.
Security defaults
| Setting | Default |
|---|---|
| Algorithm | SHA-1 |
| Digits | 6 |
| Period | 30 seconds |
| Generated secret | 20 bytes |
| Verification window | 1 step before and after |
The standard security profile limits enrollment start to 10 per user per hour, confirmation and verification to 20 per user per 10 minutes, listing to 60 per user per minute, and revoke to 20 per user per 10 minutes. MFA TOTP verification is separately limited to 20 per IP per 10 minutes.
Protect the shared secret
Redacted prevents accidental string rendering, not storage compromise. The reference store persists the secret value it receives. Encrypt TOTP secrets at rest or wrap TotpFactorStore with runtime-appropriate secret protection. Never expose a secret after confirmation.
Keep window: 1 unless clock behavior requires otherwise. Larger windows accept more candidate codes and increase brute-force opportunity.
HTTP errors
| Code | Status | Typical cause |
|---|---|---|
bad_request | 400 | Invalid enrollment, code configuration, factor ID, or factor state |
unauthenticated | 401 | Missing or invalid session |
policy_denied | 403 | Security or last-strong-factor policy rejected the action |
step_up_required | 403 | Configured removal policy requires fresher assurance |
request_rejected | 403 | Origin validation rejected the request |
rate_limited | 429 | A standard or application rule was exceeded |
internal_error | 500 | Crypto, storage, session, or runtime failure |
Wrong verification codes are normally successful HTTP responses with valid: false. Confirmation instead maps an invalid code to bad_request because the requested state transition failed.
Testing checklist
- RFC 6238 vectors pass for every enabled algorithm and digit policy.
- Enrollment secrets and URIs never appear in logs, traces, snapshots, or analytics.
- A factor remains pending after a wrong confirmation code and becomes active after a valid one.
- Pending and revoked factors cannot verify; successful verification updates
lastUsedAt. - Factor list responses never contain secrets.
- Cross-user factor IDs cannot be confirmed or revoked.
- Verification, confirmation, and MFA routes exercise their separate rate-limit keys.
- Revoke tests cover step-up and last-strong-factor policy when enabled.
- MFA continuation and step-up tests assert the resulting session assurance and AMR.