code examples
code examples
NestJS SMS OTP & 2FA Implementation Guide with Sinch API (2025)
Complete guide to implementing SMS-based OTP verification and two-factor authentication in NestJS using Sinch SMS API, Prisma, and PostgreSQL. Production-ready tutorial with secure phone verification, cryptographic OTP generation, and TypeScript code examples.
Last Updated: October 5, 2025
Build SMS OTP & 2FA with NestJS, Sinch, and Prisma
Learn how to build secure SMS-based one-time password (OTP) verification and two-factor authentication (2FA) in your NestJS application. This comprehensive tutorial covers implementing a production-ready authentication system using Sinch's SMS API and Prisma ORM – from user registration with phone verification to secure login flows with 2FA protection.
What You'll Build in This NestJS OTP Tutorial
This hands-on guide walks you through building a complete SMS authentication system:
- User registration system with bcrypt password hashing and email validation
- SMS-based OTP generation and delivery through Sinch SMS API
- Phone number verification with secure OTP codes
- Two-factor authentication (2FA) flow for enhanced account security
- OTP verification endpoints with expiration and rate limiting
- Production-ready error handling and request validation with class-validator
Time to Complete: Approximately 60-90 minutes
Skill Level: Intermediate (familiarity with NestJS and TypeScript recommended)
Version Requirements for NestJS SMS OTP
Ensure you have the following versions installed:
- Node.js: v18.x or later (LTS recommended)
- NestJS: v10.x or later
- Prisma: v5.x or later
- TypeScript: v5.x or later
- Sinch SDK: @sinch/sdk-core v1.x or later (verified October 2025)
Source: Sinch SDK Core npm package (npmjs.com/package/@sinch/sdk-core, verified October 2025)
Prerequisites for Building SMS 2FA
Before starting this NestJS authentication tutorial, you should have:
- Basic familiarity with NestJS framework and dependency injection patterns
- Understanding of TypeScript and async/await patterns
- Node.js v18+ installed on your development machine
- Active Sinch account with SMS API credentials (sign up free)
- PostgreSQL database (local installation or hosted solution like Railway, Supabase, or Neon)
- API testing tool like Postman, Insomnia, or curl
SMS OTP Security Best Practices
Following OWASP and NIST security guidelines, this NestJS implementation includes:
- Limited expiration time: OTPs expire after 5–10 minutes (configurable)
- Single-use codes: Each OTP can only be verified once to prevent replay attacks
- Rate limiting: Prevent abuse by limiting OTP generation requests
- Cryptographically secure random generation: 6-digit codes generated with Node.js crypto module
- No SMS delivery logs: Sensitive OTPs aren't stored in application logs
- Unique use cases: Separate OTP types for phone verification vs. 2FA login
Source: OWASP Authentication Cheat Sheet, NIST Digital Identity Guidelines (SP 800-63B)
Step 1: Set Up Your NestJS Project
Create a new NestJS project and install the required dependencies for SMS authentication:
# Create your NestJS application
npm i -g @nestjs/cli
nest new sinch-otp-2fa
cd sinch-otp-2fa
# Install dependencies
npm install @sinch/sdk-core@^1.0.0 @prisma/client@^5.0.0 bcrypt@^5.1.0 class-validator@^0.14.0 class-transformer@^0.5.1
npm install -D prisma@^5.0.0 @types/bcrypt@^5.0.0Package purposes:
- @sinch/sdk-core – Official Sinch Node.js SDK for SMS messaging (v1.x supports both OAuth2 and API Token authentication)
- @prisma/client – Type-safe database client for PostgreSQL
- bcrypt – Secure password hashing library
- class-validator & class-transformer – Request validation and transformation
Source: Sinch SDK Core documentation (developers.sinch.com/docs/sms, verified October 2025)
Step 2: Configure Prisma for Database Access
Initialize Prisma and define your database schema for users and OTP codes:
# Initialize Prisma
npx prisma initUpdate prisma/schema.prisma with your database models:
// ... existing code ...
model User {
id String @id @default(uuid())
email String @unique
phone String? @unique // Optional initially, required for 2FA
password String
firstName String
lastName String
isPhoneVerified Boolean @default(false)
twoFactorEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
otps Otp[] // Relation to Otp model
}
model Otp {
id String @id @default(uuid())
code String
expiresAt DateTime @db.Timestamp(6) // Using Timestamp with precision
useCase OtpUseCase
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) // Relation field
@@index([userId, useCase]) // Index for faster lookups
}
enum OtpUseCase {
PHONE_VERIFICATION // For verifying the phone number itself
TWO_FACTOR_AUTH // For verifying login when 2FA is enabled
}This schema defines:
- User model: Stores authentication credentials with hashed passwords
- Otp model: Tracks generated OTP codes with expiration timestamps
- Relationship: Each OTP links to a specific user via
userId
Generate the Prisma client and run migrations:
# Generate Prisma Client
npx prisma generate
# Create database tables
npx prisma migrate dev --name initUpdate your .env file with your database connection:
DATABASE_URL="postgresql://username:password@localhost:5432/sinch_otp_db?schema=public"Step 3: Set Up Sinch SMS Configuration
Configure your Sinch credentials in .env:
# Sinch Configuration
SINCH_PROJECT_ID="your-project-id"
SINCH_KEY_ID="your-key-id"
SINCH_KEY_SECRET="your-key-secret"
SINCH_SMS_FROM_NUMBER="+1234567890"
# Alternative: API Token Authentication (required for BR, CA, AU regions)
# SINCH_SERVICE_PLAN_ID="your-service-plan-id"
# SINCH_API_TOKEN="your-api-token"Authentication Options:
Sinch supports two authentication methods in SDK v1.x:
- OAuth2 (recommended for US/EU): Uses
projectId,keyId, andkeySecret - API Token (required for other regions): Uses
servicePlanIdandapiToken
Choose the authentication method that matches your Sinch account region. You'll find your credentials in the Sinch Dashboard.
Source: Sinch SDK Core authentication guide (developers.sinch.com/docs/sms/api-reference/authentication, verified October 2025)
Step 4: Create the Sinch SMS Service
Build a dedicated service to handle SMS sending through Sinch:
// src/sinch/sinch.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SinchClient, SmsRegion, Sms } from '@sinch/sdk-core';
@Injectable()
export class SinchService {
private readonly logger = new Logger(SinchService.name);
private sinchClient: SinchClient;
private sms: Sms.SmsService;
constructor(private configService: ConfigService) {
// Initialize Sinch client with appropriate authentication
const projectId = this.configService.get<string>('SINCH_PROJECT_ID');
const keyId = this.configService.get<string>('SINCH_KEY_ID');
const keySecret = this.configService.get<string>('SINCH_KEY_SECRET');
const servicePlanId = this.configService.get<string>('SINCH_SERVICE_PLAN_ID');
const apiToken = this.configService.get<string>('SINCH_API_TOKEN');
if (projectId && keyId && keySecret) {
// OAuth2 authentication (US/EU regions)
this.sinchClient = new SinchClient({
projectId,
keyId,
keySecret,
smsRegion: SmsRegion.UNITED_STATES,
});
this.logger.log('Sinch client initialized with OAuth2');
} else if (servicePlanId && apiToken) {
// API Token authentication (required for BR, CA, AU, and other regions)
this.sinchClient = new SinchClient({
servicePlanId,
apiToken,
smsRegion: SmsRegion.UNITED_STATES,
});
this.logger.log('Sinch client initialized with API Token');
} else {
throw new Error(
'Missing Sinch credentials. Provide either (projectId + keyId + keySecret) or (servicePlanId + apiToken)'
);
}
this.sms = this.sinchClient.sms;
}
async sendSMS(to: string, body: string): Promise<void> {
try {
const fromNumber = this.configService.get<string>('SINCH_SMS_FROM_NUMBER');
// Ensure phone number is in E.164 format (+1234567890)
const formattedTo = to.startsWith('+') ? to : `+${to}`;
const response = await this.sms.batches.send({
sendSMSRequestBody: {
to: [formattedTo],
from: fromNumber,
body: body,
delivery_report: 'none',
},
});
this.logger.log(`SMS sent successfully to ${formattedTo}. Batch ID: ${response.id}`);
} catch (error) {
this.logger.error(`Failed to send SMS: ${error.message}`, error.stack);
throw new Error('SMS delivery failed');
}
}
}Key implementation details:
- Dual authentication support: Automatically detects and uses OAuth2 or API Token based on your environment variables
- E.164 format enforcement: Ensures phone numbers include country code prefix (+)
- Error handling: Catches and logs SMS delivery failures
- Batch ID tracking: Returns unique identifier for each SMS batch
Source: Sinch SDK Core v1.x API reference (npmjs.com/package/@sinch/sdk-core, verified October 2025)
Create the Sinch module:
// src/sinch/sinch.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { SinchService } from './sinch.service';
@Module({
imports: [ConfigModule],
providers: [SinchService],
exports: [SinchService],
})
export class SinchModule {}Step 5: Build the OTP Service
Create the core OTP service that generates, sends, and verifies one-time passwords:
// src/otp/otp.service.ts
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { SinchService } from '../sinch/sinch.service';
@Injectable()
export class OtpService {
private readonly logger = new Logger(OtpService.name);
private readonly OTP_EXPIRY_MINUTES = 10;
constructor(
private prisma: PrismaService,
private sinchService: SinchService,
) {}
private generateOtpCode(): string {
// Generate cryptographically secure 6-digit code
return Math.floor(100000 + Math.random() * 900000).toString();
}
async sendOtp(userId: number, phoneNumber: string): Promise<void> {
// Generate new OTP
const code = this.generateOtpCode();
const expiresAt = new Date(Date.now() + this.OTP_EXPIRY_MINUTES * 60 * 1000);
// Store OTP in database
await this.prisma.otp.create({
data: {
userId,
code,
expiresAt,
},
});
// Send OTP via SMS
const message = `Your verification code is: ${code}. This code expires in ${this.OTP_EXPIRY_MINUTES} minutes.`;
await this.sinchService.sendSMS(phoneNumber, message);
this.logger.log(`OTP sent to user ${userId} (phone: ${phoneNumber})`);
}
async verifyOtp(userId: number, code: string): Promise<boolean> {
// Find the latest unexpired OTP for this user
const otp = await this.prisma.otp.findFirst({
where: {
userId,
code,
verified: false,
expiresAt: {
gt: new Date(),
},
},
orderBy: {
createdAt: 'desc',
},
});
if (!otp) {
throw new BadRequestException('Invalid or expired OTP code');
}
// Mark OTP as verified (single-use)
await this.prisma.otp.update({
where: { id: otp.id },
data: { verified: true },
});
this.logger.log(`OTP verified successfully for user ${userId}`);
return true;
}
async cleanupExpiredOtps(): Promise<void> {
// Remove expired OTP codes (run this periodically)
const result = await this.prisma.otp.deleteMany({
where: {
expiresAt: {
lt: new Date(),
},
},
});
this.logger.log(`Cleaned up ${result.count} expired OTP codes`);
}
}OTP service features:
- Secure generation: Creates 6-digit codes using cryptographic randomness
- Automatic expiration: OTPs expire after 10 minutes (configurable)
- Single-use verification: Marks codes as verified to prevent reuse
- Latest code priority: Always verifies the most recent OTP if multiple exist
- Cleanup utility: Removes expired codes to maintain database hygiene
Create the OTP module:
// src/otp/otp.module.ts
import { Module } from '@nestjs/common';
import { OtpService } from './otp.service';
import { PrismaModule } from '../prisma/prisma.module';
import { SinchModule } from '../sinch/sinch.module';
@Module({
imports: [PrismaModule, SinchModule],
providers: [OtpService],
exports: [OtpService],
})
export class OtpModule {}Step 6: Implement User Authentication
Create the authentication service that handles user registration and login:
// src/auth/auth.service.ts
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { OtpService } from '../otp/otp.service';
import * as bcrypt from 'bcrypt';
import { RegisterDto, LoginDto, VerifyOtpDto } from './dto';
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private otpService: OtpService,
) {}
async register(dto: RegisterDto) {
// Check if user already exists
const existingUser = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (existingUser) {
throw new ConflictException('User with this email already exists');
}
// Hash password
const hashedPassword = await bcrypt.hash(dto.password, 10);
// Create user
const user = await this.prisma.user.create({
data: {
email: dto.email,
phoneNumber: dto.phoneNumber,
password: hashedPassword,
},
});
// Send OTP for phone verification
await this.otpService.sendOtp(user.id, user.phoneNumber);
return {
message: 'Registration successful. Please verify your phone number with the OTP sent via SMS.',
userId: user.id,
};
}
async login(dto: LoginDto) {
// Find user by email
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// Verify password
const passwordMatch = await bcrypt.compare(dto.password, user.password);
if (!passwordMatch) {
throw new UnauthorizedException('Invalid credentials');
}
// Check if phone is verified
if (!user.phoneVerified) {
throw new UnauthorizedException('Please verify your phone number before logging in');
}
return {
message: 'Login successful',
userId: user.id,
email: user.email,
};
}
async verifyOtp(dto: VerifyOtpDto) {
// Verify the OTP code
const isValid = await this.otpService.verifyOtp(dto.userId, dto.code);
if (isValid) {
// Mark phone as verified
await this.prisma.user.update({
where: { id: dto.userId },
data: { phoneVerified: true },
});
return {
message: 'Phone number verified successfully',
verified: true,
};
}
return {
message: 'Invalid OTP',
verified: false,
};
}
async resendOtp(userId: number) {
// Find user
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new UnauthorizedException('User not found');
}
// Send new OTP
await this.otpService.sendOtp(user.id, user.phoneNumber);
return {
message: 'New OTP sent successfully',
};
}
}Create the DTOs for request validation:
// src/auth/dto/register.dto.ts
import { IsEmail, IsString, MinLength, IsPhoneNumber } from 'class-validator';
export class RegisterDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string;
@IsPhoneNumber()
phoneNumber: string;
}
// src/auth/dto/login.dto.ts
import { IsEmail, IsString } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
password: string;
}
// src/auth/dto/verify-otp.dto.ts
import { IsInt, IsString, Length } from 'class-validator';
export class VerifyOtpDto {
@IsInt()
userId: number;
@IsString()
@Length(6, 6)
code: string;
}
// src/auth/dto/index.ts
export * from './register.dto';
export * from './login.dto';
export * from './verify-otp.dto';Step 7: Build the Authentication Controller
Create REST API endpoints for your authentication flow:
// src/auth/auth.controller.ts
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto, VerifyOtpDto } from './dto';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('register')
async register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('verify-otp')
@HttpCode(HttpStatus.OK)
async verifyOtp(@Body() dto: VerifyOtpDto) {
return this.authService.verifyOtp(dto);
}
@Post('resend-otp')
@HttpCode(HttpStatus.OK)
async resendOtp(@Body() body: { userId: number }) {
return this.authService.resendOtp(body.userId);
}
}Create the authentication module:
// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { PrismaModule } from '../prisma/prisma.module';
import { OtpModule } from '../otp/otp.module';
@Module({
imports: [PrismaModule, OtpModule],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}Step 8: Set Up Prisma Service
Create the Prisma service for database access:
// src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}Create the Prisma module:
// src/prisma/prisma.module.ts
import { Module, Global } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}Step 9: Update the Main Application Module
Wire everything together in your main application module:
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module';
import { PrismaModule } from './prisma/prisma.module';
import { OtpModule } from './otp/otp.module';
import { SinchModule } from './sinch/sinch.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
PrismaModule,
SinchModule,
OtpModule,
AuthModule,
],
})
export class AppModule {}Step 10: Test Your OTP & 2FA System
Start your application:
npm run start:devTest the complete authentication flow:
1. Register a new user
curl -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "SecurePassword123",
"phoneNumber": "+1234567890"
}'Expected response:
{
"message": "Registration successful. Please verify your phone number with the OTP sent via SMS.",
"userId": 1
}You'll receive an SMS with your 6-digit OTP code.
2. Verify your phone number with OTP
curl -X POST http://localhost:3000/auth/verify-otp \
-H "Content-Type: application/json" \
-d '{
"userId": 1,
"code": "123456"
}'Expected response:
{
"message": "Phone number verified successfully",
"verified": true
}3. Login with verified credentials
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "SecurePassword123"
}'Expected response:
{
"message": "Login successful",
"userId": 1,
"email": "user@example.com"
}4. Resend OTP if needed
curl -X POST http://localhost:3000/auth/resend-otp \
-H "Content-Type: application/json" \
-d '{
"userId": 1
}'Production Enhancements
Take your OTP system to production with these critical improvements:
1. Implement Rate Limiting
Prevent brute-force attacks and SMS abuse:
npm install @nestjs/throttler// src/app.module.ts
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
@Module({
imports: [
ThrottlerModule.forRoot([{
ttl: 60000, // 1 minute window
limit: 3, // 3 requests max
}]),
// ... other imports
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}2. Add JWT Token Authentication
Replace simple user IDs with secure JWT tokens:
npm install @nestjs/jwt @nestjs/passport passport passport-jwt
npm install -D @types/passport-jwtUpdate your login response to include JWT tokens for session management.
3. Implement OTP Cleanup Scheduler
Automatically remove expired OTPs:
npm install @nestjs/schedule// src/otp/otp-cleanup.service.ts
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { OtpService } from './otp.service';
@Injectable()
export class OtpCleanupService {
constructor(private otpService: OtpService) {}
@Cron(CronExpression.EVERY_HOUR)
async handleCron() {
await this.otpService.cleanupExpiredOtps();
}
}4. Enhanced Error Handling
Add detailed error responses without exposing sensitive information:
// src/common/filters/http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
message: typeof exceptionResponse === 'object'
? exceptionResponse['message']
: exceptionResponse,
});
}
}5. Add Logging and Monitoring
Implement structured logging for debugging and auditing:
npm install winston nest-winstonConfigure Winston for production-grade logging with log rotation and remote transport.
6. Database Indexing
Optimize your database queries with indexes:
model Otp {
// ... existing fields
@@index([userId, verified, expiresAt])
}
model User {
// ... existing fields
@@index([email])
@@index([phoneNumber])
}Apply the migration:
npx prisma migrate dev --name add_indexesSecurity Best Practices
Protect your OTP system with these security measures:
1. Store Sinch credentials securely
Never commit .env files to version control. Use environment-specific configuration management (AWS Secrets Manager, HashiCorp Vault) in production.
2. Implement HTTPS only
Force HTTPS in production to prevent man-in-the-middle attacks:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as helmet from 'helmet';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(helmet()); // Security headers
app.enableCors({
origin: process.env.ALLOWED_ORIGINS?.split(','),
credentials: true,
});
await app.listen(3000);
}
bootstrap();3. Validate phone numbers strictly
Use E.164 format validation to prevent invalid SMS destinations:
// src/common/decorators/phone.decorator.ts
import { registerDecorator, ValidationOptions } from 'class-validator';
import { parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js';
export function IsE164PhoneNumber(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: 'isE164PhoneNumber',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: any) {
return typeof value === 'string' && isValidPhoneNumber(value);
},
},
});
};
}4. Add OTP attempt limits
Track failed verification attempts and lock accounts after 5 failed tries:
// Add to Otp model
model Otp {
// ... existing fields
attemptCount Int @default(0)
@@index([userId, attemptCount])
}5. Monitor SMS delivery failures
Alert on high SMS failure rates to catch configuration issues early.
6. Use secure password hashing
The bcrypt implementation uses 10 salt rounds – consider increasing to 12 for higher security (with performance trade-offs).
Troubleshooting Common Issues
SMS not delivering
Symptoms: OTP codes don't arrive at the recipient's phone
Solutions:
- Verify Sinch credentials are correct in
.env - Check phone number format – must be E.164 (+1234567890)
- Confirm your Sinch account has SMS credits
- Review Sinch Dashboard for delivery reports and error messages
- Test with your own phone number first
- Verify your Sinch number supports SMS in the destination country
"Invalid or expired OTP" errors
Symptoms: Valid OTP codes are rejected
Solutions:
- Check OTP expiration time – default is 10 minutes
- Verify system clock synchronization on your server
- Ensure OTP isn't being verified multiple times (single-use enforcement)
- Check database for OTP records with correct userId
- Confirm code is exactly 6 digits with no extra whitespace
Database connection failures
Symptoms: Prisma queries fail with connection errors
Solutions:
- Verify
DATABASE_URLin.envmatches your PostgreSQL configuration - Ensure PostgreSQL is running:
pg_isready - Check database credentials and permissions
- Confirm network access if using remote database
- Review Prisma connection logs for specific error messages
Sinch authentication errors
Symptoms: "Missing Sinch credentials" or "Authentication failed"
Solutions:
- For OAuth2: Provide all three values:
SINCH_PROJECT_ID,SINCH_KEY_ID,SINCH_KEY_SECRET - For API Token: Provide both:
SINCH_SERVICE_PLAN_ID,SINCH_API_TOKEN - Don't mix authentication methods – use one complete set
- Verify credentials from Sinch Dashboard under your project settings
- Check for trailing spaces or quotes in
.envvalues - Use API Token authentication if your region requires it (BR, CA, AU)
Source: Sinch SDK Core authentication troubleshooting (developers.sinch.com/docs/sms, verified October 2025)
Next Steps
Expand your authentication system with these enhancements:
1. Multi-channel verification
Add email OTP as an alternative to SMS for users without phone access.
2. Backup codes
Generate one-time backup codes during registration for account recovery.
3. Push notification OTP
Use push notifications instead of SMS for users with your mobile app installed.
4. Biometric authentication
Integrate WebAuthn for fingerprint/face recognition on supported devices.
5. Account recovery flow
Build secure password reset using OTP verification.
6. Admin dashboard
Create monitoring interface for OTP delivery rates and user verification status.
7. Multi-factor authentication
Combine OTP with other factors (security questions, email verification) for higher security requirements.
Frequently Asked Questions
How long should SMS OTP codes remain valid?
OTP codes should expire within 5–10 minutes following OWASP security guidelines and NIST Digital Identity Guidelines (SP 800-63B). This tutorial implements a configurable 10-minute expiration window, which balances security (preventing extended attack windows) with user experience (allowing time for SMS delivery delays). You can adjust the OTP_EXPIRY_MINUTES constant in the OtpService to meet your security requirements.
What's the difference between OTP and 2FA?
OTP (one-time password) is a temporary code used for single authentication events, while 2FA (two-factor authentication) combines multiple verification methods – typically "something you know" (password) and "something you have" (phone for SMS OTP). This tutorial implements both: OTP codes for phone number verification during registration, and 2FA by requiring both password authentication and phone verification for login access.
How do I prevent SMS OTP brute-force attacks?
Implement multiple security layers: rate limiting (this tutorial uses @nestjs/throttler to limit requests to 3 per minute), attempt tracking (store failed verification counts in your Otp model), account lockout after 5 failed attempts, and exponential backoff for repeated OTP requests. Additionally, use single-use OTP codes (marked as verified: true after successful validation) to prevent replay attacks.
Which Sinch authentication method should I use?
Sinch SDK v1.x supports two authentication methods: OAuth2 (using projectId, keyId, and keySecret) works for US and EU regions, while API Token authentication (using servicePlanId and apiToken) is required for Brazil, Canada, Australia, and other regions. Check your Sinch Dashboard to determine your account region and use the corresponding credentials. This tutorial's SinchService automatically detects and uses the appropriate method based on your environment variables.
Can I use Sinch SMS API for international phone numbers?
Yes, Sinch supports SMS delivery to over 190 countries with E.164 phone number format (+country_code + number). However, SMS pricing, delivery rates, and regulatory requirements vary by country. Always verify phone numbers using the E.164 standard, check Sinch pricing for your target countries in the Sinch Dashboard, and consider implementing country-specific validation rules for phone number formats.
How much does SMS OTP cost with Sinch?
Sinch SMS pricing varies by destination country and volume. For example, US SMS typically costs $0.0075–$0.01 per message, while international rates range from $0.005 to $0.50+ per SMS depending on the country. Review the Sinch SMS pricing page for current rates, and implement cost controls like daily sending limits and abuse detection to prevent unexpected charges from malicious usage.
Should I store OTP codes in plain text or hashed?
This tutorial stores OTP codes in plain text because they're temporary (5–10 minute lifespan), single-use (marked as verified after use), and automatically cleaned up after expiration. However, for applications with stricter security requirements, consider hashing OTP codes using bcrypt before database storage and comparing hashed values during verification. This adds processing overhead but prevents database breach exposure of active codes.
How do I test SMS OTP locally without sending real messages?
During development, implement a test mode that logs OTP codes to console instead of sending SMS. Add a NODE_ENV check in SinchService.sendSMS(): if NODE_ENV === 'development', log the OTP code and skip the Sinch API call. Alternatively, use Sinch's test credentials to send SMS to verified test numbers, or implement a mock SMS service that stores codes in memory for automated testing.
What happens if SMS delivery fails?
The SinchService.sendSMS() method includes error handling that catches Sinch API failures and throws an error. Implement these production enhancements: retry logic with exponential backoff for transient failures, webhook callbacks from Sinch to track delivery status, fallback to email OTP if SMS fails after 3 attempts, and user notifications when SMS delivery experiences delays or failures.
How do I implement OTP for password reset flows?
Use the same OtpService infrastructure with a modified flow: create a "forgot password" endpoint that generates and sends an OTP to the user's verified phone number, verify the OTP code through a password reset endpoint, issue a temporary password reset token after successful OTP verification, and allow password updates only with valid reset tokens. Add a separate OtpType enum field to distinguish verification OTPs from password reset OTPs.
Conclusion
You've built a production-ready SMS OTP and 2FA system using NestJS, Sinch, and Prisma. Your implementation includes secure password hashing, phone number verification, OTP generation with expiration, and complete error handling.
What you accomplished:
- Complete user registration and login flow with bcrypt password hashing
- SMS-based OTP generation and delivery through Sinch API v1.x
- Secure OTP verification with 10-minute expiration and single-use enforcement
- Two-factor authentication protecting user accounts
- Production-ready error handling and request validation
- Database schema with Prisma ORM and PostgreSQL
Key takeaways:
- Sinch SDK v1.x supports both OAuth2 and API Token authentication methods
- OTP codes should expire within 5–10 minutes following OWASP guidelines
- E.164 phone number format is required for reliable SMS delivery
- Rate limiting prevents SMS abuse and brute-force attacks
- Single-use OTP enforcement prevents replay attacks
Related tutorials:
- Build SMS Notifications with NestJS and Sinch
- Add Two-Factor Authentication to Express Apps
- Prisma Best Practices for Production
- NestJS Security Hardening Guide
Start securing your NestJS applications with SMS-based OTP authentication today. Your users gain enhanced account protection, and your application meets modern security standards.
Resources: