Sample: Auth MFA (TOTP/2FA)
I want users to be able to enable TOTP-based 2FA, and login to become a two-step challenge once they have.
What this sample shows
Section titled “What this sample shows”The production wiring shape: mount createAuthMfaFeature() next to createAuthEmailPasswordFeature(), thread its status-checker into the login handler, and give auth: { mfaVerifyHandler: ... } to buildServer. From there the framework handles the rest — a password-only login for an MFA-enrolled user returns a challenge token instead of a JWT, and POST /auth/mfa/verify completes it with a TOTP or recovery code.
The wiring in 4 sentences
Section titled “The wiring in 4 sentences”createAuthMfaFeature({ setupTokenSecret, challengeTokenSecret, issuer })builds the feature;mfaStatusCheckerFromFeature(mfaFeature)reads its login-status callback back off.- That callback goes into
createAuthEmailPasswordFeature({ mfaStatusChecker: ... })—login.write.tscalls it after the password check to decide whether to mint a JWT or a challenge. buildServer({ auth: { mfaVerifyHandler: AuthMfaHandlers.verify, switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate } })(the gate is required withmfaVerifyHandler, so a tenant switch asks for the second factor too) mountsPOST /auth/mfa/verify, a framework route (not a dispatcher write-handler) with its own rate limiter, since it runs before a JWT exists.- If
sessionsis mounted too,bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(sessionCallbacks.sessionRevokeAllOthers)wires “enable/disable/regenerate signs out every other session.”
What to copy into your app
Section titled “What to copy into your app”const mfaFeature = createAuthMfaFeature({ setupTokenSecret: config.mfaSetupSecret, challengeTokenSecret: config.mfaChallengeSecret, issuer: "Your App",});const sessionCallbacks = createSessionCallbacks({ db });bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(sessionCallbacks.sessionRevokeAllOthers);
const server = buildServer({ registry: buildRegistry([ createAuthEmailPasswordFeature({ mfaStatusChecker: mfaStatusCheckerFromFeature(mfaFeature), }), createSessionsFeature({ autoRevokeOnPasswordChange: sessionCallbacks.sessionMassRevoker, }), mfaFeature, // ... other features ]), context: { db /* ... */ }, jwtSecret: process.env.JWT_SECRET, auth: { membershipQuery: "tenant:query:memberships", loginHandler: "auth-email-password:[REDACTED:API key param]", mfaVerifyHandler: AuthMfaHandlers.verify, switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate, sessionCreator: sessionCallbacks.sessionCreator, sessionRevoker: sessionCallbacks.sessionRevoker, sessionChecker: sessionCallbacks.sessionChecker, },});Apps built via runDevApp/runProdApp (the dev-server bootstrap used by create-kumiko-app) get all of this automatically — just add createAuthMfaFeature(...) to APP_FEATURES. composeFeatures threads the status-checker and the bootstrap wires the session-revoke bind and the verify route for you. This sample shows the raw contract those helpers implement.
Self-service screen
Section titled “Self-service screen”createAccountSecurityFeature({ nav?, access? }) adds one screen,
account-security:screen:account-security, that embeds the MFA enable,
regenerate and disable screens plus the own-sessions list. It owns no handlers
and needs no composeAuthStack: mount it next to auth-mfa and sessions.
Without nav the screen stays dormant (reachable by its route, no sidebar
entry); access defaults to every signed-in user.
features: [..., createSessionsFeature(), mfaFeature, createAccountSecurityFeature({ nav: { order: 90 } })]Design rules
Section titled “Design rules”- Enrollment is stateless until confirmed.
enable-startreturns a signed setup token (QR + secret + 8 recovery codes) — nothing is persisted untilenable-confirmproves the user scanned it with a valid TOTP code. - The TOTP secret is envelope-encrypted at rest, same
MasterKeyProviderassecrets/config— no separate crypto story to maintain. /auth/mfa/verifyis a framework route, not a dispatcher handler. It runs before a JWT exists, so it gets its own IP-keyed rate limiter (mfaVerifyRateLimit), independent of the login rate limiter.- Recovery codes are single-use. Each of the 8 generated codes completes exactly one login challenge, then it’s burned.
- MFA state changes revoke every OTHER live session (stolen-session defense) — never the session that made the change.
- Enforcement is opt-in. Without a tenant enabling
auth-mfa:config:required, users choose for themselves whether to enroll.
Confirmation code input
Section titled “Confirmation code input”src/web/confirmation-code-step.tsx shows the Input kind="code" renderer primitive outside the MFA screen, for example an e-mail or invite confirmation code. Pass length (here 6), autoFocus and onComplete. The input accepts digits only, so pasting “123 456” yields 123456, and onComplete fires once when the value reaches length. autoComplete defaults to one-time-code, so mobile keyboards offer the code from the SMS or mail. After a wrong code the step clears the value and bumps a key, which remounts the input and returns focus to it.
- Baseline: a user without MFA enrolled still gets a JWT straight from
/auth/login. - End-to-end: enroll via
enable-start/enable-confirm, log in (gets a challenge, no JWT), complete via/auth/mfa/verify(gets a JWT) — copies thebuildServer({ auth: ... })block from the sample comment verbatim. - Account security: the composed screen boot-validates next to
auth-mfaandsessionsand shows a sidebar entry oncenavis set. - Confirmation code step: typing or pasting six digits calls
onConfirmonce; a rejected code shows the error, clears the field and refocuses it.
Source code
Section titled “Source code”The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
// Auth MFA (TOTP/2FA) Sample//// Shows the production-wiring shape: mount `createAuthMfaFeature()` next to// `createAuthEmailPasswordFeature()`, thread its status-checker into the// login handler, and (if `sessions` is mounted) bind its revoke-all-other-// sessions callback. From there the framework handles the rest — login// returns a challenge instead of a JWT when MFA is enabled, POST// /auth/mfa/verify completes it, and every OTHER live session gets signed// out whenever the account's MFA state changes (enable/disable/regenerate).//// What to copy into your own app://// const mfaFeature = createAuthMfaFeature({// setupTokenSecret: config.mfaSetupSecret,// challengeTokenSecret: config.mfaChallengeSecret,// issuer: "Your App",// });// const sessionCallbacks = createSessionCallbacks({ db });// bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(// sessionCallbacks.sessionRevokeAllOthers,// );//// const server = buildServer({// registry: buildRegistry([// createAuthEmailPasswordFeature({// mfaStatusChecker: mfaStatusCheckerFromFeature(mfaFeature),// }),// createSessionsFeature({// autoRevokeOnPasswordChange: sessionCallbacks.sessionMassRevoker,// }),// mfaFeature,// // ... other features// ]),// context: { db, ... },// jwtSecret: config.jwtSecret,// auth: {// membershipQuery: "tenant:query:memberships",// loginHandler: "auth-email-password:[REDACTED:API key param]",// mfaVerifyHandler: AuthMfaHandlers.verify,// switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,// sessionCreator: sessionCallbacks.sessionCreator,// sessionRevoker: sessionCallbacks.sessionRevoker,// sessionChecker: sessionCallbacks.sessionChecker,// },// });//// Apps built via runDevApp/runProdApp (the dev-server bootstrap) get all of// this for free just by adding `createAuthMfaFeature(...)` to APP_FEATURES —// composeFeatures threads the status-checker and the bootstrap wraps the// session-revoke bind and the /auth/mfa/verify route automatically. This// sample shows the raw wiring contract those helpers implement.//// Design rules the sample demonstrates://// 1. Login becomes two-step once MFA is enabled. `POST /auth/login` returns// `{ mfaRequired: true, challengeToken }` instead of a JWT; the client// completes with `POST /auth/mfa/verify`.// 2. Enrollment is stateless until confirmed. `enable-start` returns a// signed setup token (QR + recovery codes) — nothing is persisted until// `enable-confirm` proves the user scanned it with a valid TOTP code.// 3. Recovery codes are single-use. Each of the 8 generated codes can// complete exactly one login challenge, then it's burned.// 4. Every MFA state change signs out every OTHER live session (stolen-// session defense) — but never the session that made the change.
// One self-service screen for MFA and own sessions. Dormant until `nav` is// set, so apps that mount it only for deep links get no sidebar entry.export { ACCOUNT_SECURITY_SCREEN_QN, createAccountSecurityFeature,} from "@cosmicdrift/kumiko-bundled-features/account-security";export { AuthMfaHandlers, base32Decode, bindMfaRevokeAllOtherSessionsFromFeature, createAuthMfaFeature, mfaStatusCheckerFromFeature, userMfaEntity,} from "@cosmicdrift/kumiko-bundled-features/auth-mfa";export { currentTotpCode } from "@cosmicdrift/kumiko-bundled-features/auth-mfa/testing";📄 On GitHub: samples/recipes/auth-mfa/src/feature.ts