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 } })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, 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.
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.
- 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.
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,// 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.export { AuthMfaHandlers, base32Decode, bindMfaRevokeAllOtherSessionsFromFeature, createAuthMfaFeature, currentTotpCode, mfaStatusCheckerFromFeature, userMfaEntity,} from "@cosmicdrift/kumiko-bundled-features/auth-mfa";📄 On GitHub: samples/recipes/auth-mfa/src/feature.ts