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
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 — but it can't access protected endpoints). - 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
When a user enables MFA, POST /auth/mfa/generate-recovery-code returns a code once. The library never returns it again. You must show it to the user immediately and tell them to save it somewhere safe.
If they lose their TOTP device, they call POST /auth/mfa/reset-totp with the recovery code — this disables every MFA secret on the account, letting them log in with just their password and re-enroll.
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.
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():
Related
- Recovery codes recipe.
- Custom trusted-device header recipe.
- Sending Emails — wiring
TwoFactorCodeSentEventto your email provider. - Sending SMS — same for SMS.