Multi-Factor Authentication
TOTP, Email OTP, SMS OTP, recovery codes, and trusted devices.
Nest Auth supports four MFA factors out of the box. You can offer one, several, or all four.
The factors
| Factor | Enum | How it works |
|---|---|---|
| TOTP | NestAuthMFAMethodEnum.TOTP | Authenticator app (Google Authenticator, Authy, 1Password, …). Shared secret, 30-second windows. |
| Email OTP | NestAuthMFAMethodEnum.EMAIL | Numeric code emailed to the user. Best fallback when TOTP is unavailable. |
| SMS OTP | NestAuthMFAMethodEnum.SMS | Numeric code texted to the user's verified phone. |
| Recovery codes | (built-in) | Single-use codes generated when the user enables MFA. Use only when the primary factor is lost. |
Configuration
methods replaces the default [EMAIL, TOTP] — it is not merged with it. Set methods: ['totp'] for a TOTP-only app, or ['email'] for email-only. The default (both) applies only when you don't set methods at all.
The MFA challenge flow
- User submits username + password to
/auth/login. - Server validates the credentials. If MFA is enabled for the user, the response carries
isRequiresMfa: trueplus a one-shot challenge token (in the access-token slot). This token is deliberately limited: it can reach only the challenge/recovery routes (mfa/status,mfa/devices,mfa/challenge,mfa/verify,mfa/reset-totp) and cannot change MFA config —setup-totp,verify-totp-setup,generate-recovery-code,toggle, and device deletion all return401until the second factor is completed. (This closes a password-only bypass where an attacker with just the password could enrol their own authenticator.) - Client calls
POST /auth/mfa/challengewith the chosen method to send the code (or skips this step for TOTP). - User enters the code. Client calls
POST /auth/mfa/verifywith{ otp, method, trustDevice? }. - Server returns the final access + refresh tokens. If
trustDevice: true, also atrustToken.
The JS client wraps all of this:
Recovery codes
POST /auth/mfa/generate-recovery-code returns a set of single-use codes (default 10, configurable via mfa.recoveryCodeCount) — shown once, never returned again. The response is { codes: string[], code } (code = codes[0], kept for backward compatibility). Show them immediately and tell the user to save them. Regenerating replaces the outstanding set. (This route requires a fully MFA-verified session — see the security note.)
A recovery code is a backup authenticator — there are two ways to use one:
POST /auth/mfa/verify-recovery-code(recommended) — redeem a code to complete the sign-in, exactly likemfa/verify. MFA stays enabled and your enrolled factors are untouched; you're returned a full session. The now-verified session can enrol a fresh authenticator viasetup-totpinline — no second sign-in. This is what GitHub/Google do. The client SDKs exposeverifyRecoveryCode({ code, trustDevice? }). EmitsMFA_RECOVERY_CODE_USED(alert the owner). Codes are single-use.POST /auth/mfa/reset-totp— the destructive path: deletes every TOTP secret and consumes the code. If no other verified method remains (a TOTP-only user with no email/SMS), MFA is turned off so they can sign in with just their password and re-enrol — never left "enabled with zero methods" (which would lock them out). If a verified email/SMS method survives, MFA stays on.
Set
mfa.requireVerifiedContactForEnrollment: trueto only allow enrolling a new authenticator when the user has a verified email or phone, so an abandoned enrolment can't strand the account.
The MFA recovery codes recipe covers the UX in detail.
Trusted devices
When the user verifies MFA with trustDevice: true, the server returns a trustToken. The client persists it (default header name nest_auth_device_trust, configurable via mfa.trustDeviceStorageName). On subsequent logins, the client sends the token in the configured header — if it's valid and not expired, the MFA challenge is skipped for that session.
The duration is set by mfa.trustedDeviceDuration (any ms string). Each trusted device row lives in nest_auth_trusted_devices with userAgent and ipAddress for audit.
TOTP enrollment
Users enroll TOTP independently of the login flow:
The server verifies the code matches the secret, then marks the device verified. From then on, the user can log in with TOTP codes.
A user can enroll multiple TOTP devices (listTotpDevices, removeTotpDevice) — useful for users who want a phone and a hardware token like a YubiKey.
What the authenticator app shows
The QR encodes an otpauth:// URI with two display fields:
- issuer — the bold app/service heading. Comes from
mfa.totp.issuer(falling back toappName). Set it so the entry reads "My App", not the app's package id or a blank. - account label — who the entry is for. Defaults to the user's email (then phone, then id).
setupTotp returns these so your UI can show them, plus the raw URI if you want to render your own QR:
If your authenticator shows "SecretKey", you're on ≤ 2.8.0 — that was a bug where the URI carried no issuer/label. Fixed in 2.8.1; set
mfa.totp.issuerand upgrade.
Multi-tenant / multi-account labels
When one person has several accounts under the same issuer (e.g. one email across tenants), every entry would otherwise show the same account label and be indistinguishable. Pass a custom label to disambiguate — the app knows the tenant, so qualify it:
POST /auth/mfa/setup-totp accepts an optional body { label?, deviceName? }; with an empty body the label defaults to the email. The issuer stays common (your app), only the account label varies per account.
MFA scope across tenants
In multi-tenant deployments (tenant.enabled = true), MFA enrolment is user-global, not per-tenant. The nest_auth_mfa_secrets and nest_auth_trusted_devices tables key off userId only — there's no tenantId column.
Practical consequences in SHARED mode (one user → many tenants):
- A user enrols TOTP once. The same TOTP code works to sign into every tenant they belong to.
- A trusted-device token earned in tenant A also lets the user skip MFA in tenant B on the same device.
toggleMfa/resetMfaoperate on the user's global MFA state — flipping MFA off in one tenant flips it off everywhere.
This is intentional: in SHARED mode the user really is a single identity, so a single MFA configuration matches the model. The trade-off: if an attacker compromises the user's authenticator, they can sign into every tenant the user belongs to with the same code.
If your security policy requires per-tenant MFA challenges (e.g. tenant Acme stores high-sensitivity data and wants a fresh TOTP prompt on every tenant switch even if MFA is satisfied for the current session), enforce it via loginHooks.onLogin or guards.beforeAuth — the library doesn't ship per-tenant MFA out of the box.
In ISOLATED mode the question is moot: a "user" is per-tenant by definition, so each tenant has its own MFA state automatically.
@SkipMfa()
Some endpoints inside the auth flow itself need to bypass MFA enforcement (e.g. the verify endpoint can't require MFA — that's circular). Annotate them with @SkipMfa():
Security: use
@SkipMfa()only on routes that a challenge-stage session (isMfaEnabled && !isMfaVerified) legitimately needs — the ones that complete or prove a factor:mfa/status,mfa/challenge,mfa/verify,mfa/reset-totp, and reading the device list. Never put it on a route that changes MFA config (enable/disable, enrol/verify a device, mint recovery codes, delete a device): a half-authenticated token reaching those is a password-only MFA bypass. Those routes carry only@UseGuards(NestAuthAuthGuard), so the guard blocks a challenge token automatically while still allowing first-time enrolment (a user with MFA off has no challenge in progress).
Related
- Recovery codes recipe.
- Custom trusted-device header recipe.
- Sending Emails — wiring
TwoFactorCodeSentEventto your email provider. - Sending SMS — same for SMS.
See also
- Passwordless OTP — Email/SMS OTP as a primary login factor.
- NestAuthModule — the
mfaconfig block in context with every other option. GET /auth/mfa/status— the generated MFA endpoint reference.