Skip to content

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

ts
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:

  • MfaFactor lives in a MfaFactorPool. Drivers (mfa-totp, mfa-sms, mfa-email, mfa-backup-codes, mfa-webauthn) contribute themselves; MfaService dispatches by type. A user can be enrolled in multiple factors at once. MfaFactor.verify receives the full MfaChallenge so stateful factors (SMS, email) can key per-challenge.id while stateless ones (TOTP) read only challenge.identityId.
  • MfaSecretStore is an app-implemented contract — boot fails if no provider is bound (same posture as ApiKeyStore for auth-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).
  • MfaChallengeStore is built in, backed by CacheService. Challenges are short-lived (default 5 minutes) — the cache contract handles TTLs and gives multi-instance challenge state for free when the app uses cache-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.

ts
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:

ts
@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:

ts
@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:

ts
@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.

ts
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.

ts
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.

ts
@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.

ts
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.

ts
@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.

ts
@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:

ts
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

ConcernBehaviour
Challenge expired before user submitted codeverify() returns { ok: false }. Apps surface "code expired, restart login" and create a fresh challenge.
User enters wrong codeverify() returns { ok: false } and leaves the challenge alive for retries within the TTL.
Successful verify replayedverify() deletes the challenge on success — replay returns { ok: false }.
Cache loss (memory restart) wipes outstanding challengesUsers have to restart login. For multi-instance, use cache-redis so challenges survive instance restarts.
Clock drift between server and authenticator appTOTP 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 caseFactor
Strongest primary factor — passkeys, hardware keys, biometricsmfa-webauthn
Primary factor — broad authenticator-app compatibilitymfa-totp
Primary factor for users without an authenticator appmfa-sms
Low-friction primary or secondary factormfa-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.