MFA
@modularityjs/mfa is a multi-factor authentication contract for adding a second factor (TOTP, SMS, WebAuthn) on top of password or other primary auth. Apps wire MfaService into their existing login flow and use @RequireMfa() to gate sensitive operations.
Contract
abstract class MfaFactor {
readonly type: string; // 'totp', 'sms', 'webauthn', ...
enroll(identity: MfaIdentity): Promise<MfaEnrollment>;
challenge(identityId, challengeId): Promise<MfaChallengePayload>;
verify(challenge: MfaChallenge, response: unknown): Promise<boolean>;
}
abstract class MfaSecretStore {
// app-implemented
get(identityId, factorType): Promise<string | undefined>;
set(identityId, factorType, secret): Promise<void>;
delete(identityId, factorType): Promise<void>;
listEnrolled(identityId): Promise<string[]>;
}
class MfaService {
// concrete dispatcher
enroll(identity, factorType): Promise<MfaEnrollment>;
challenge(identity, factorType): Promise<MfaChallengePayload>;
verify(challengeId, response): Promise<MfaVerifyResult>;
enrolledFactors(identity): Promise<string[]>;
}
interface MfaVerifyResult {
ok: boolean;
identityId?: string;
factorType?: string;
}Three structural pieces:
MfaFactorlives in aMfaFactorPool. Drivers (mfa-totp,mfa-sms,mfa-email,mfa-backup-codes,mfa-webauthn) contribute themselves;MfaServicedispatches by type. A user can be enrolled in multiple factors at once.MfaFactor.verifyreceives the fullMfaChallengeso stateful factors (SMS, email) can key per-challenge.idwhile stateless ones (TOTP) read onlychallenge.identityId.MfaSecretStoreis an app-implemented contract — boot fails if no provider is bound (same posture asApiKeyStoreforauth-api-key). The contract is opaque: the store exchanges a string blob with the factor; TOTP stores a base32 secret, WebAuthn stores serialized credential JSON. Apps adapt to their existing user store (database row, JSON column, separate table).MfaChallengeStoreis built in, backed byCacheService. Challenges are short-lived (default 5 minutes) — the cache contract handles TTLs and gives multi-instance challenge state for free when the app usescache-redis.
MfaIdentity is structurally compatible with AuthIdentity from @modularityjs/auth — apps wiring both pass the auth identity directly.
TOTP driver (@modularityjs/mfa-totp)
RFC 6238 — pure stdlib node:crypto (HMAC-SHA1 with dynamic truncation). Zero SDK dependency.
import { MfaModule, MfaSecretStore } from '@modularityjs/mfa';
import { MfaTotpModule } from '@modularityjs/mfa-totp';
import { CacheModule } from '@modularityjs/cache';
import { CacheMemoryModule } from '@modularityjs/cache-memory';
const modules = [
CacheModule,
CacheMemoryModule,
MfaModule.forRoot({ challengeTtlMs: 5 * 60_000 }),
MfaTotpModule.forRoot({
issuer: 'My App', // shown in the user's authenticator
digits: 6, // 6-10 allowed; higher = higher entropy
period: 30, // step size in seconds
algorithm: 'SHA1', // SHA1 / SHA256 / SHA512
windowSteps: 1, // ±1 step = 30s drift tolerance
}),
MyMfaSecretStoreModule, // app provides this
];The MfaSecretStoreModule is a tiny app-side adapter:
@Injectable()
class DatabaseMfaSecretStore extends MfaSecretStore {
constructor(
@Inject(repositoryOf(User)) private readonly users: Repository<User>,
) {
super();
}
async get(identityId: string, factorType: string) {
const user = await this.users.findOneByOrFail({ id: identityId });
return user.mfaSecrets?.[factorType];
}
async set(identityId: string, factorType: string, secret: string) {
await this.users.update(identityId, {
mfaSecrets: {
...(await this.currentSecrets(identityId)),
[factorType]: secret,
},
});
}
// delete, listEnrolled similarly
}Login flow
The framework deliberately doesn't ship a controller — apps wire MFA into their existing login route. The typical shape:
@Post('/login')
async login(@Body() body: { email: string; password: string }) {
const identity = await this.localAuth.authenticate(body.email, body.password);
if (!identity) throw new AuthenticationException();
const factors = await this.mfa.enrolledFactors(identity);
if (factors.length === 0) {
return this.session.create(identity); // no MFA enrolled — straight in
}
const challenge = await this.mfa.challenge(identity, factors[0]);
return {
mfaRequired: true,
challengeId: challenge.challengeId,
factor: challenge.factorType,
};
}
@Post('/mfa/verify')
async verifyMfa(@Body() body: { challengeId: string; code: string }) {
const result = await this.mfa.verify(body.challengeId, { code: body.code });
if (!result.ok) throw new AuthenticationException('invalid code');
const identity = await this.users.findById(result.identityId!);
return this.session.create(identity, { mfa: true }); // stamp the claim
}For TOTP enrollment:
@Post('/mfa/enroll/totp')
@RequireAuth()
async enrollTotp(@Auth() identity: AuthIdentity) {
const enrollment = await this.mfa.enroll(identity, 'totp');
return {
secret: enrollment.secret, // for manual entry fallback
otpauthUri: enrollment.otpauthUri, // render as QR with any QR library
};
}The user scans the QR, enters a code, and the app verifies it with MfaService.verify (after creating a challenge). On success, the app marks TOTP as "active" for this user in their own data model — the secret was already persisted by TotpFactor.enroll.
SMS driver (@modularityjs/mfa-sms)
Sends a one-time code via SmsService. Reads the destination from identity.attributes.phone (configurable via phoneAttribute) and caches the generated code under the challenge id with the same TTL as MfaConfig.challengeTtlMs.
import { MfaSmsModule } from '@modularityjs/mfa-sms';
import { SmsModule } from '@modularityjs/sms';
import { SmsTwilioModule } from '@modularityjs/sms-twilio';
const modules = [
CacheModule,
CacheMemoryModule,
SmsModule.forRoot({ from: '+15555550000' }),
SmsTwilioModule.forRoot({ accountSid: '…', authToken: '…' }),
MfaModule,
MfaSmsModule.forRoot({
codeLength: 6,
phoneAttribute: 'phone',
messageTemplate: ({ code }) => `Your code: ${code}`,
}),
MyMfaSecretStoreModule,
];enroll(identity) requires identity.attributes.phone to be a string. The factor stores it as the secret and returns { options: { destination: '*****1234' } } so the app can show the masked phone. challenge returns the same masked destination in meta.
Email driver (@modularityjs/mfa-email)
Same pattern via MailService. Reads identity.attributes.email. The default messageTemplate returns { subject, text } — override to send HTML or localise.
MfaEmailModule.forRoot({
codeLength: 6,
messageTemplate: ({ code }) => ({
subject: `Your code: ${code}`,
html: `<p>Your verification code is <strong>${code}</strong>.</p>`,
}),
});Backup codes (@modularityjs/mfa-backup-codes)
Single-use recovery codes for when the primary factor (TOTP, phone) is lost. enroll(identity) generates N readable codes (default 10, format 7m3k-pq8x), hashes each with SHA-256, and stores { hashes: string[] } in MfaSecretStore. The plaintext codes are returned once via enrollment.options.codes — apps display them so the user can print or save them. Verify normalises the input (lowercases, strips dashes/whitespace), matches the hash, and removes it from the list.
@Post('/mfa/enroll/backup-codes')
@RequireAuth()
async enrollBackupCodes(@Auth() identity: AuthIdentity) {
const enrollment = await this.mfa.enroll(identity, 'backup-codes');
// codes: ['7m3k-pq8x', 'jh2n-rb4d', …]
return { codes: (enrollment.options as { codes: string[] }).codes };
}BackupCodesFactor.remaining(identityId) returns the unused-code count for an account-settings UI. Re-enrolling regenerates the full set, invalidating prior codes.
WebAuthn / passkeys (@modularityjs/mfa-webauthn)
Wraps @simplewebauthn/server v13 to support passkeys, platform authenticators (Face ID, Windows Hello), and hardware keys (YubiKey, Titan). The factor is two-phase because WebAuthn requires the browser to call navigator.credentials.create() between options-generation and verification. Apps inject WebauthnFactor directly to drive the second phase.
import { MfaWebauthnModule, WebauthnFactor } from '@modularityjs/mfa-webauthn';
const modules = [
CacheModule,
CacheMemoryModule,
MfaModule,
MfaWebauthnModule.forRoot({
rpName: 'Acme', // shown by the authenticator
rpID: 'example.com', // registrable domain
origin: 'https://example.com', // or array for multiple origins
userVerification: 'preferred',
attestationType: 'none',
}),
MyMfaSecretStoreModule,
];Phase 1 — start registration: MfaService.enroll(identity, 'webauthn') returns the registration options and caches the challenge for MfaConfig.challengeTtlMs. Send the options to the browser as-is — they are already JSON-serialisable.
@Post('/mfa/enroll/webauthn/start')
@RequireAuth()
async startWebauthnEnroll(@Auth() identity: AuthIdentity) {
const enrollment = await this.mfa.enroll(identity, 'webauthn');
return enrollment.options; // pass to startRegistration() in the browser
}Phase 2 — complete registration: the browser POSTs the attestation response back; inject WebauthnFactor directly to verify and persist the credential. Until this call succeeds, enrolledFactors does not include 'webauthn' and the credential is not usable.
@Post('/mfa/enroll/webauthn/complete')
@RequireAuth()
async completeWebauthnEnroll(
@Auth() identity: AuthIdentity,
@Body() body: RegistrationResponseJSON,
) {
const credential = await this.webauthn.completeRegistration(identity, body);
return { id: credential.id, transports: credential.transports };
}Authentication flows through the standard MfaService.challenge / MfaService.verify pair. challenge.meta.options contains the PublicKeyCredentialRequestOptionsJSON to pass to navigator.credentials.get(); the verification response goes to MfaService.verify(challengeId, response).
A user can register multiple credentials (a YubiKey + their phone, etc.). The factor stores all of them, includes them in excludeCredentials on subsequent enroll, and matches the submitted credential id during verify. WebauthnFactor.listCredentials(identityId) and WebauthnFactor.removeCredential(identityId, credentialId) power an account-settings "manage your security keys" UI. Counters are bumped on each verify; the SDK rejects responses where the counter regresses.
Step-up: @RequireMfa()
@modularityjs/http-mfa provides a guard for routes that require recent MFA verification:
import { RequireMfa } from '@modularityjs/http-mfa';
@Controller('/admin')
class AdminController {
@Get('/billing')
@RequireMfa()
billing() {
return this.billingService.summary();
}
}The guard reads request.auth?.mfa === true. Apps set that flag after MfaService.verify succeeds — typically the session or JWT carries an mfa: boolean claim that http-auth materialises into request.auth.mfa. The extension has no peer-dep on http-auth; it reads the structural shape directly so apps with custom auth wiring still work.
Returns 403 with { code: 'ACCESS_DENIED', message: 'Access denied' } when missing.
Failure Modes and Tuning
| Concern | Behaviour |
|---|---|
| Challenge expired before user submitted code | verify() returns { ok: false }. Apps surface "code expired, restart login" and create a fresh challenge. |
| User enters wrong code | verify() returns { ok: false } and leaves the challenge alive for retries within the TTL. |
| Successful verify replayed | verify() deletes the challenge on success — replay returns { ok: false }. |
| Cache loss (memory restart) wipes outstanding challenges | Users have to restart login. For multi-instance, use cache-redis so challenges survive instance restarts. |
| Clock drift between server and authenticator app | TOTP windowSteps defaults to ±1 (30s tolerance). Bump to 2 if users complain; 0 disables drift tolerance. |
| Lost authenticator (no recovery factor) | Enroll a backup factor (mfa-backup-codes or mfa-email) at signup; otherwise the app needs a manual recovery flow over email / KYC. |
Choosing a Factor
| Use case | Factor |
|---|---|
| Strongest primary factor — passkeys, hardware keys, biometrics | mfa-webauthn |
| Primary factor — broad authenticator-app compatibility | mfa-totp |
| Primary factor for users without an authenticator app | mfa-sms |
| Low-friction primary or secondary factor | mfa-email |
| Recovery factor (combine with another primary factor) | mfa-backup-codes |
A user can enroll in multiple factors — login picks one (typically by user preference) and mfa-backup-codes covers the lost-device case. The pool-based design means new factor packages plug in without changes to the contract or existing code.