Skip to main content

SMS MFA

Add SMS-based multi-factor authentication. By the end of this guide you will have MFA working via text message. SMS MFA uses the same backend routes as Email MFA — only the configuration and request/response payloads differ.

EndpointMethodAuthPurpose
/auth/mfa/statusGETProtectedCheck MFA enrollment status
/auth/mfa/setup-dataPOSTProtectedInitiate SMS MFA setup (sends code)
/auth/mfa/verify-setupPOSTProtectedConfirm setup with SMS code
/auth/mfa/devicesGETProtectedList enrolled MFA devices
/auth/mfa/devices/:id/preferredPOSTProtectedSet preferred MFA device
/auth/mfa/devices/:idDELETEProtectedRemove an MFA device
/auth/respond-challengePOSTPublicComplete MFA challenge during login
/auth/challenge/setup-dataPOSTPublicGet setup data during forced MFA setup
/auth/challenge/resendPOSTPublicResend MFA verification code
Sample apps

SMS MFA is configured in the nauth example apps using ConsoleSMSProvider (logs to stdout). See the NestJS, Express, and Fastify examples.

SMS Security

SMS is vulnerable to SIM swapping attacks. Consider offering SMS as a secondary option alongside TOTP or Passkey, which are more resistant to interception.

Prerequisites

  • Basic Auth Flows are working (signup, login, challenge endpoints)
  • The user must have a verified phone number on their account

Step 1: Install

npm install @nauth-toolkit/mfa-sms @nauth-toolkit/sms-console

For production, use the AWS SNS provider instead:

npm install @nauth-toolkit/mfa-sms @nauth-toolkit/sms-aws-sns

Step 2: Configure

config/auth.config.ts
import { MFAMethod } from '@nauth-toolkit/core';
import { ConsoleSMSProvider } from '@nauth-toolkit/sms-console';

smsProvider: new ConsoleSMSProvider(), // Logs SMS to console (dev only)

mfa: {
enabled: true,
enforcement: 'OPTIONAL',
allowedMethods: [MFAMethod.SMS],

rememberDevices: 'user_opt_in',
rememberDeviceDays: 30,
bypassMFAForTrustedDevices: true,
},

For production with AWS SNS:

config/auth.config.ts
import { AwsSNSSMSProvider } from '@nauth-toolkit/sms-aws-sns';

smsProvider: new AwsSNSSMSProvider({
region: 'us-east-1',
originationNumber: process.env.AWS_SMS_ORIGINATION || '+12345678901',
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
}),

Step 3: Add Backend Routes

Already have MFA routes?

If you already set up routes for another MFA method (Email, TOTP, Passkey), the same routes handle SMS — just add MFAMethod.SMS to allowedMethods in your config and skip to Step 4.

MFA management routes (protected — user must be logged in)

src/auth/mfa.controller.ts
import { Controller, Get, Post, Delete, Body, Param, UseGuards, HttpCode, HttpStatus, Inject } from '@nestjs/common';
import { AuthGuard, MFAService } from '@nauth-toolkit/nestjs';

@UseGuards(AuthGuard)
@Controller('auth/mfa')
export class MfaController {
constructor(
@Optional() @Inject(MFAService)
private readonly mfaService?: MFAService,
) {}

@Get('status')
async getStatus() {
return await this.mfaService.getMfaStatus();
}

@Post('setup-data')
@HttpCode(HttpStatus.OK)
async setup(@Body() dto: any) {
return await this.mfaService.setup(dto);
}

@Post('verify-setup')
@HttpCode(HttpStatus.OK)
async verifySetup(@Body() dto: any) {
const provider = this.mfaService.getProvider(dto.methodName);
const deviceId = await provider.verifySetup(dto.setupData);
return { deviceId };
}

@Get('devices')
async getDevices() {
return await this.mfaService.getUserDevices({});
}

@Post('devices/:deviceId/preferred')
@HttpCode(HttpStatus.OK)
async setPreferred(@Param('deviceId') deviceId: string) {
return await this.mfaService.setPreferredDevice({ deviceId } as any);
}

@Delete('devices/:deviceId')
@HttpCode(HttpStatus.OK)
async removeDevice(@Param('deviceId') deviceId: string) {
return await this.mfaService.removeDevice({ deviceId } as any);
}
}

Register the controller in your auth module.

Challenge helper route (public — used during login)

Add to your existing auth controller/routes from the Basic Auth Flows guide:

src/auth/auth.controller.ts
// Add to your existing AuthController:

@Public()
@Post('challenge/setup-data')
@HttpCode(HttpStatus.OK)
async getSetupData(@Body() dto: any) {
if (!this.mfaService) throw new BadRequestException('MFA service is not available');
return await this.mfaService.getSetupData(dto);
}

Step 4: Frontend — MFA Setup (Security Settings)

Initiate setup

POST /auth/mfa/setup-data

Request body:

{
"methodName": "sms"
}

Response — a 6-digit code is sent via text message:

{
"setupData": {
"maskedPhone": "+1***5678"
}
}

Show an input field for the user to enter the code they received.

Verify setup

POST /auth/mfa/verify-setup

Request body:

{
"methodName": "sms",
"setupData": {
"code": "123456"
}
}

Response:

{
"deviceId": 43
}

MFA is now enabled. The next login triggers an MFA_REQUIRED challenge via SMS.

Frontend setup example

React — SMS MFA setup (simplified)
const handleSetupSms = async () => {
const { setupData } = await fetch('/auth/mfa/setup-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ methodName: 'sms' }),
}).then(r => r.json());

setMaskedPhone(setupData.maskedPhone);
setShowCodeInput(true);
};

const handleVerify = async (code: string) => {
const { deviceId } = await fetch('/auth/mfa/verify-setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ methodName: 'sms', setupData: { code } }),
}).then(r => r.json());

// MFA is enabled
};

Step 5: Frontend — MFA Challenge (Login)

When a user with SMS MFA logs in, the login response contains an MFA_REQUIRED challenge. A code is sent to their phone automatically.

Login response (MFA enabled user)

POST /auth/login
{
"challengeName": "MFA_REQUIRED",
"session": "a21b654c-2746-4168-acee-c175083a65cd",
"challengeParameters": {
"availableMethods": ["sms"],
"preferredMethod": "sms",
"destination": "+1***5678",
"deliveryMedium": "SMS"
}
}

Complete the challenge

POST /auth/respond-challenge

Request body:

{
"session": "a21b654c-2746-4168-acee-c175083a65cd",
"type": "MFA_REQUIRED",
"method": "sms",
"code": "123456"
}

Response — tokens are issued (same structure as Email MFA).

Frontend challenge example

React — SMS MFA challenge (simplified)
const handleMfaChallenge = async (code: string) => {
const response = await fetch('/auth/respond-challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session: pendingChallenge.session,
type: 'MFA_REQUIRED',
method: 'sms',
code,
}),
}).then(r => r.json());

if (response.accessToken) {
// Login complete
}
};

Forced Setup (REQUIRED Enforcement)

When enforcement: 'REQUIRED', users without MFA receive an MFA_SETUP_REQUIRED challenge after login.

Login response (no MFA set up)

{
"challengeName": "MFA_SETUP_REQUIRED",
"session": "a21b654c-2746-4168-acee-c175083a65cd",
"challengeParameters": {
"allowedMethods": ["sms"]
}
}

Get setup data during login

POST /auth/challenge/setup-data

Request body:

{
"session": "a21b654c-2746-4168-acee-c175083a65cd",
"method": "sms"
}

Possible responses:

ScenarioResponse
Phone already verified{ "setupData": { "autoCompleted": true, "deviceId": 43 } }
Phone not yet verified{ "setupData": { "maskedPhone": "+1***5678" } } — code sent

Complete the challenge

If auto-completed:

{
"session": "a21b654c-2746-4168-acee-c175083a65cd",
"type": "MFA_SETUP_REQUIRED",
"method": "sms",
"setupData": { "deviceId": 43 }
}

If code verification needed:

{
"session": "a21b654c-2746-4168-acee-c175083a65cd",
"type": "MFA_SETUP_REQUIRED",
"method": "sms",
"setupData": { "code": "123456" }
}

Both are sent to POST /auth/respond-challenge. On success, tokens are issued.

Phone number collection

If the user doesn't have a phone number on their account, the challenge system can collect it during a VERIFY_PHONE challenge. See Basic Auth Flows > Other challenge types for the phone number collection flow.

What's Next

  • Email MFA — Email verification codes (simplest to set up)
  • TOTP MFA — Authenticator apps (Google Authenticator, Authy, 1Password)
  • Passkey MFA — Biometric authentication (Face ID, Touch ID, YubiKey)
  • How MFA Works — Adaptive MFA, error codes, device trust, enforcement modes