Skip to content

Add TOTP-based 2FA to login

A password alone is one factor: something the user knows. TOTP-based two-factor authentication (2FA) adds a second — something the user has (an authenticator app that generates a 6-digit code every 30 seconds). The auth-mfa feature adds enrollment, a two-step login challenge, and recovery codes on top of auth-email-password, with almost no manual wiring.

Add auth-mfa to APP_FEATURES next to auth-email-password. Apps built via runDevApp/runProdApp (the dev-server bootstrap) wire everything else automatically — the login handler’s status check, the POST /auth/mfa/verify route with its own rate limiter, and (if sessions is mounted) signing out every other session on enable/disable/regenerate.

import { createAuthMfaFeature } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
export const APP_FEATURES = [
// ... other features
createAuthMfaFeature({
setupTokenSecret: config.mfaSetupSecret, // 32+ bytes, distinct from other token secrets
challengeTokenSecret: config.mfaChallengeSecret, // 32+ bytes, distinct from setupTokenSecret
issuer: "Your App", // shown in the authenticator app next to the account label
}),
];

Building buildServer directly instead of through the dev-server bootstrap? See the Auth MFA recipe for the raw wiring contract (mfaStatusChecker, auth.mfaVerifyHandler, the session-revoke bind) that the bootstrap implements for you.

On the client, mount the login-challenge screen so a two-step login has somewhere to render:

import { emailPasswordClient } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
import { authMfaClient, MfaVerifyScreen } from "@cosmicdrift/kumiko-bundled-features/auth-mfa/web";
createKumikoApp({
clientFeatures: [
emailPasswordClient({ mfaVerifyScreen: MfaVerifyScreen }),
authMfaClient(),
// ... other client features
],
});

auth-mfa requires user and config; it’s auth-email-password that must already be mounted for the login flow to make sense.

Enrollment is a two-call, stateless flow — nothing is persisted until the user proves they scanned the QR code.

Terminal window
curl -sX POST https://app.example.com/api/write \
-H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
-d '{ "type": "auth-mfa:write:enable-start", "payload": { "accountLabel": "[email protected]" } }'
{
"isSuccess": true,
"data": {
"setupToken": "…",
"otpauthUri": "otpauth://totp/Your%20App:[email protected]?secret=…&issuer=Your%20App",
"recoveryCodes": ["ABCD-1234", "…8 total…"]
}
}

Render otpauthUri as a QR code (the MfaEnableScreen component does this for you via qrcode) and show the 8 recoveryCodes once — they are never retrievable again. Confirm with a code from the now-scanned authenticator app:

Terminal window
curl -sX POST https://app.example.com/api/write \
-H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
-d '{ "type": "auth-mfa:write:enable-confirm", "payload": { "setupToken": "…", "code": "123456" } }'

Only enable-confirm persists the user-mfa row — an abandoned enable-start (user never scans, never confirms) leaves nothing behind.

Two-factor login challenge — enter the code from your authenticator app

Once enrolled, POST /auth/login no longer returns a JWT directly for that user — it returns a challenge:

{ "isSuccess": true, "mfaRequired": true, "challengeToken": "…" }

Complete it with a TOTP code (or a recovery code) against the framework route POST /auth/mfa/verify — not a dispatcher handler, since no JWT exists yet:

Terminal window
curl -sX POST https://app.example.com/api/auth/mfa/verify \
-H "Content-Type: application/json" \
-d '{ "challengeToken": "…", "code": "123456" }'
{ "isSuccess": true, "token": "<jwt>" }

The challenge token is single-use and short-lived (10 minutes); a wrong code counts against a 5-attempt lockout independent of the login password-lockout counter.

Each of the 8 codes generated at enrollment completes exactly one login challenge in place of a TOTP code, then it’s burned. Regenerate the set (invalidating all previous codes) via auth-mfa:write:regenerate-recovery — useful after a user suspects a code leaked, or has used most of them.

auth-mfa:write:disable requires a valid TOTP (or recovery) code — turning off 2FA is itself a security-sensitive action, not a plain toggle.

By default, enrollment is optional — users choose for themselves. A tenant admin can require it via the auth-mfa:config:required config key ("optional" | "admins" | "all"). Setting "admins" or "all" blocks login for a matching, unenrolled user — there is no in-band “enroll during login” step yet, so roll this out with users already enrolled, or expect support requests from anyone who isn’t.

  • Session revoke, not session kill. Enabling, disabling, or regenerating recovery codes signs out every other live session (stolen-session defense) — never the session that made the change.
  • Secrets are encrypted at rest. The TOTP secret is envelope-encrypted via the same MasterKeyProvider as secrets/config — no separate crypto story, and it rotates through the same KEK-rotation job (auth-mfa:reencrypt).
  • GDPR coverage is a separate feature. Mount auth-mfa-user-data alongside user-data-rights to include 2FA enrollment status in data exports and hard-delete it on an erasure request — kept separate so plain auth-mfa consumers don’t pull in the GDPR pipeline as a hard dependency.
  • auth-mfa reference — dependencies, config keys, and provided write commands.
  • Auth MFA recipe — the raw buildServer({ auth: ... }) wiring contract, runnable with integration tests.
  • sessions — the revocable-session feature auth-mfa hooks into for “sign out every other session.”