TOTP
Enroll, verify, inventory, and revoke authenticator-app factors with application-owned HTTP APIs.
TOTP support covers RFC-compatible shared-secret codes, durable factor management, an authenticated settings API, login MFA, and current-session step-up. The recommended HTTP Operations layer preserves standard settings orchestration while your application owns its public API and sensitive-action policy.
HTTP Operations
TotpHttpOperations exposes enrollment, settings verification, inventory, and revocation. Every operation derives the user from the current session; never add a browser-controlled user ID.
Configure the feature
TotpFactorManagementLive depends on Totp and TotpSecretCipher, so provide both explicitly rather than placing dependent layers as independent Layer.mergeAll siblings:
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { HttpAuthenticationCapabilitiesLive } from "./http-authentication-capabilities.live.js";
import {
TotpHttpConfigLive,
TotpHttpOperationsLive,
strongFactorRemovalPolicyCapabilityLayerNoDeps,
StrongFactorRemovalPolicyChoice,
} from "@effect-auth/core/HttpApi/Totp";
import {
TotpFactorManagementLive,
TotpLive,
totpSecretCipherAes256GcmKeyringLayer,
} from "@effect-auth/core/Totp";
import { Layer, Redacted } from "effect";
import {
AppAuthRuntimeLive,
AppRateLimitLive,
AppSessionLive,
} from "./auth-runtime.live.js";
const decodeKey = (base64: string): Uint8Array<ArrayBuffer> =>
Uint8Array.from(
atob(base64),
(character) => character.codePointAt(0)!
) as Uint8Array<ArrayBuffer>;
const AppTotpSecretCipherLive = totpSecretCipherAes256GcmKeyringLayer({
currentKeyId: "2026-07",
keys: {
"2026-07": Redacted.make(decodeKey(process.env.TOTP_KEY_2026_07!)),
"2026-01": Redacted.make(decodeKey(process.env.TOTP_KEY_2026_01!)),
},
});
export const AppTotpServicesLive = TotpFactorManagementLive.pipe(
Layer.provide(TotpLive),
Layer.provide(AppTotpSecretCipherLive),
Layer.provide(AppAuthRuntimeLive)
);
export const AppTotpHttpConfigLive = TotpHttpConfigLive({
issuer: "Example",
algorithm: "SHA1",
digits: 6,
period: 30,
secretBytes: 20,
window: 1,
});
export const AppTotpHttpOperationsLive = TotpHttpOperationsLive.pipe(
Layer.provide(
strongFactorRemovalPolicyCapabilityLayerNoDeps(
StrongFactorRemovalPolicyChoice.Disabled()
)
),
Layer.provide(AuthRateLimitStandardLive()),
Layer.provide(HttpAuthenticationCapabilitiesLive),
Layer.provide(AppTotpHttpConfigLive),
Layer.provide(
Layer.mergeAll(AppTotpServicesLive, AppSessionLive, AppRateLimitLive)
)
);AppAuthRuntimeLive supplies Crypto and a durable TotpFactorStore; AppSessionLive supplies Sessions and SessionCookie. The keyring uses AES-256-GCM. Every key must decode to exactly 32 bytes, currentKeyId encrypts new values, and old key IDs must remain in keys while their ciphertext exists. Load and validate keys from deployment secret configuration instead of relying on the compact non-null assertions above.
SQLite creates TOTP storage in migration 0006_auth_totp_factor; Postgres uses 0002_auth_security_oauth. Preserve last_accepted_counter and the compare-and-swap replaceSecret operation when implementing a custom store.
TotpSecretCipherUnavailableLive is fail-closed: managed encryption and decryption fail rather than storing or interpreting plaintext. TotpSecretCipherTestLive has a fixed test key and is test-only; CoreTestingLive includes it. Neither is a production key configuration.
Define the contract
import {
AuthOriginCheckMiddleware,
AuthSchemaErrorMiddleware,
} from "@effect-auth/core/HttpApi";
import {
totpEnrollmentConfirmEndpoint,
totpEnrollmentStartEndpoint,
totpFactorListEndpoint,
totpFactorRevokeEndpoint,
totpVerifyEndpoint,
} from "@effect-auth/core/HttpApi/Totp";
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
) {}The built-in start, confirm, and verify schemas accept arbitrary metadata. Remove or allowlist it before it reaches factor state, and derive or validate accountName server-side when exposing it would reveal sensitive identity data.
Bind the operations
import { TotpHttpOperations } from "@effect-auth/core/HttpApi/Totp";
import { Effect, Layer } from "effect";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { AppAuthApi } from "./auth-api.js";
import { AppTotpHttpOperationsLive } from "./totp-services.live.js";
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(AppTotpHttpOperationsLive));StrongFactorRemovalPolicyChoice.Disabled() deliberately allows removal without last-factor enforcement and ignores any ambient StrongFactorRemovalPolicy. Use Enabled({ service: policy }) in the capability Layer to enforce the policy.
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 { AppTotpHttpApiGroupLive } from "./totp-api-group.live.js";
export const AppAuthHttpApiLive = HttpApiBuilder.layer(AppAuthApi).pipe(
Layer.provide(AppTotpHttpApiGroupLive),
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)
);Select CloudflareConnectingIp only behind a controlled Cloudflare boundary with no direct backend exposure.
Protect enrollment and removal
All five stock operations authenticate the session, scope the user, and apply AuthRateLimit. They do not require recent strong authentication before exposing a new secret or revoking a factor, and they do not emit audit events or notifications. Add application guards where account takeover risk warrants them.
An explicitly enabled removal policy protects the last inventoried strong factor; the required disabled choice does not. Neither choice enforces session freshness or AAL. Use an explicit step-up requirement for fresh strong authentication.
The library owns: current-session lookup, standard rate-limit calls, enrollment and verification orchestration, ownership checks, replay-floor updates, safe factor projection, and optional last-factor enforcement.
Your application owns: public schemas, metadata policy, origin policy, production cipher keys and rotation, recent-step-up rules, auditing, notification, cleanup, and recovery policy.
Built-in contract
All settings routes require a session.
| Route | Request | Success |
|---|---|---|
POST /auth/totp/enroll/start | accountName; optional metadata | Pending factor, plaintext secret, and uri |
POST /auth/totp/enroll/confirm | factorId, code; optional metadata | Confirmed factor |
POST /auth/totp/verify | code; optional metadata | { valid, factor?, delta?, acceptedCounter? } |
GET /auth/totp/factors | None | Confirmed, non-revoked factors |
POST /auth/totp/factors/revoke | factorId; optional reason | 204 No Content |
A wrong settings verification code returns HTTP 200 with { valid: false }. Wrong confirmation is bad_request because confirmation requested a state transition. Successful settings verification advances the replay floor but does not add evidence, raise assurance, or rotate the session.
Enrollment lifecycle
Authorize enrollment. Resolve the current user and apply application-owned recent-step-up and eligibility policy.
Issue a pending factor. Core generates the secret, encrypts it into an
unconfirmed record, and returns its factor ID, plaintext secret, and
otpauth:// URI once.
Present the credential. Render a QR code or manual secret without logs, analytics, snapshots, or persistent browser storage.
Submit confirmation. Retain the factor ID server-side or bind it to the settings flow, then accept a code generated by the authenticator.
Verify ownership and code. Core checks session ownership and the configured drift window. A wrong code leaves the factor pending.
Activate the factor. Core sets confirmedAt; only then does the factor
appear in normal inventory and verification.
Pending enrollment has no built-in expiry, cleanup job, or one-pending-factor limit. Define retention and replacement rules for abandoned records. Confirmation does not advance the replay floor, so the same time-step code can still satisfy a later verification.
MFA and step-up
TOTP is maintained as a settings factor, login MFA, and step-up method, not as a standalone primary sign-in method.
/auth/mfa/totp/verifyderives the user from pending flow state and atomically accepts the counter, consumes the flow, and inserts the authenticated session for the terminal authenticated path./auth/step-up/totp/verifyderives the current user and atomically accepts the counter while rotating the session token and assurance./auth/totp/verifyis only a settings check. A successful call can consume the code that a user immediately tries to reuse for MFA or step-up.
Do not compose verifyForUser followed by an unrelated session update and describe it as atomic. Use the maintained MFA/step-up operations or an application store transaction with the same counter-and-session boundary.
Security defaults
| Setting | Default |
|---|---|
| Algorithm | SHA-1 |
| Digits | 6 |
| Period | 30 seconds |
| Generated secret | 20 bytes |
| Verification window | 1 step before and after |
| Settings operation | Standard rate limit |
|---|---|
| Enrollment start | 10/user/hour |
| Enrollment confirm | 20/user/10 minutes |
| Verification | 20/user/10 minutes |
| Factor list | 60/user/minute |
| Revoke | 20/user/10 minutes |
MFA TOTP verification is separately limited to 20/IP/10 minutes. IP limits require trusted request metadata; trust forwarded headers only behind a controlled proxy.
Protect the shared secret
Redacted prevents accidental string rendering; it does not encrypt or zeroize transient values. Managed enrollment encrypts before storage, but the browser enrollment response intentionally exposes plaintext secret and uri once. Render and discard them; never log enrollment responses, QR payloads, codes, or sensitive account names.
The historical SQL column remains named secret; do not rename it. Normal production rows contain opaque TotpSecretCiphertext in the ea-totp.v1... envelope. AES-GCM associated data binds ciphertext to its serialized key ID, factor ID, user ID, algorithm, digits, and period. The strict production layer has no silent plaintext fallback.
For key rotation, change currentKeyId and retain old keys. When confirmation or verification decrypts with an old key, TotpFactorManagement re-encrypts with the current key and lazily calls TotpFactorStore.replaceSecret using the prior ciphertext as a CAS guard. Keep each old key until no stored envelope names it.
Existing plaintext rows
Before strict deployment, temporarily use the explicitly named totpSecretCipherAes256GcmKeyringLegacyPlaintextMigrationLayer. It reads only canonical uppercase, unpadded Base32 legacy values with no whitespace or separators, always encrypts as v1, marks every plaintext read for rotation, and lets normal confirmation or verification lazily rewrite the row through replaceSecret CAS. Concurrent rewrites have one winner.
Scan the physical secret column and switch back to totpSecretCipherAes256GcmKeyringLayer as soon as no Base32-only rows remain. Do not keep the migration layer as a compatibility fallback. Malformed legacy values require controlled repair or user re-enrollment.
Keep window: 1 unless measured clock behavior requires otherwise. A larger window accepts more candidate codes and increases brute-force opportunity. The accepted absolute counter must increase monotonically, so the same or an older time-step code cannot succeed again.
HTTP errors
| Code | Status | Typical cause |
|---|---|---|
bad_request | 400 | Invalid enrollment state, wrong confirmation, or inaccessible factor |
unauthenticated | 401 | Missing, invalid, expired, or revoked session |
policy_denied | 403 | Configured last-factor or application policy denied removal |
step_up_required | 403 | Application boundary guard requires stronger authentication |
request_rejected | 403 | HTTP origin validation rejected the request |
rate_limited | 429 | A configured security rule was exceeded |
internal_error | 500 | Crypto, storage, session, or runtime failure |
The stock removal policy can emit policy_denied, not step_up_required. The latter belongs to an application-added boundary guard. Cross-user and missing factors share public errors.
Testing checklist
- RFC 6238 vectors pass for every enabled algorithm and digit policy.
- Enrollment secrets, URIs, codes, and sensitive account names never enter logs, traces, snapshots, or analytics.
- Wrong confirmation leaves a factor pending; valid confirmation activates it.
- Pending and revoked factors cannot verify; normal lists expose neither.
- Successful verification advances
lastAcceptedCounterand rejects replay, including concurrent attempts. - Settings verification does not change session assurance and its consumed code cannot be reused immediately.
- Cross-user factor IDs cannot be confirmed, listed, verified, or revoked.
- Confirmation, settings, MFA, and step-up routes exercise their separate rate-limit keys.
- Revoke tests cover recent step-up and last-strong-factor policy when enabled.
- MFA verification and step-up tests assert atomic counter/session behavior, assurance, and AMR.
- Pending-factor cleanup, ciphertext-only storage, missing/old-key behavior, and lazy key rotation are tested in the application runtime.