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.
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
Track failed verification attempts and lock accounts after 5 failed tries:
TypeScript
// 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_URL in .env matches 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 .env values
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
Start securing your NestJS applications with SMS-based OTP authentication today. Your users gain enhanced account protection, and your application meets modern security standards.
Implement 2FA by first setting up a NestJS project, integrating Sinch for SMS, and then building the authentication flow with phone verification and OTP delivery. The Sinch SMS API is used for sending OTPs, while Prisma handles database interactions for secure storage of user data and OTPs. This guide provides step-by-step instructions for setting up the entire process.
Prisma is used as an ORM (Object-Relational Mapper) to simplify database operations. It provides a type-safe interface for interacting with the PostgreSQL database, managing database migrations, and ensuring data integrity. This makes it easier to manage user data, OTP records, and other information related to authentication securely.
NestJS provides a robust and modular architecture well-suited for building scalable server-side applications. Its TypeScript support enhances code quality and maintainability. The modular structure helps organize code into controllers, services, and modules, improving code reusability and maintainability.
Enable 2FA after a user has successfully registered and verified their phone number. This adds an extra layer of security to the login process, protecting against unauthorized access even if the user's password is compromised. It's usually presented as an optional feature for users to activate.
While this guide focuses on Sinch, you can adapt the principles to integrate other SMS API providers. You'll need to adjust the SinchService to interact with your chosen provider's API and ensure it's compatible with the rest of the architecture. Key adjustments would involve modifying the service to make requests to the new provider's API endpoints and handle their specific response format.
PostgreSQL serves as the persistent data store for user information, including hashed passwords, phone numbers, 2FA status, and OTP records. It is a robust and reliable database solution chosen for its stability and open-source nature.
Create a .env file in the project root and add your Sinch Service Plan ID, API token, and optional Sinch phone number. The values for SINCH_SERVICE_PLAN_ID and SINCH_API_TOKEN are obtained from your Sinch account dashboard. The SINCH_PHONE_NUMBER is the virtual number or shortcode you use to send SMS.
You need Node.js, npm or yarn, a running PostgreSQL server, a Sinch account with SMS API credentials, and a basic understanding of TypeScript, REST APIs, and async/await. The NestJS CLI should also be installed globally to manage the project effectively.
bcrypt is used to securely hash user passwords before they are stored in the database. This ensures that even if the database is compromised, the raw passwords are not exposed, making it significantly harder for attackers to gain access to user accounts.
Class-validator and class-transformer are used for validating and transforming request data using Data Transfer Objects (DTOs). This helps ensure that data conforms to expected formats and types, improving the security and robustness of the API endpoints.
JSON Web Tokens (JWTs) are used for managing stateless authentication sessions after a user successfully logs in. Once authenticated, a JWT is issued to the client, which can be used to access protected routes without needing to send credentials each time.
The architecture consists of a Client, NestJS API, Prisma Client, PostgreSQL Database and the Sinch SMS API. The client interacts with the NestJS API, which handles the logic and communication with other services, like the Sinch API for SMS and Prisma for database interactions.
Use the NestJS CLI to create a new project: nest new sinch-otp-nestjs-app and navigate into the directory cd sinch-otp-nestjs-app. This command sets up the basic structure of your NestJS project.
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:
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:
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:
SMS OTP Security Best Practices
Following OWASP and NIST security guidelines, this NestJS implementation includes:
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:
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.prismawith your database models:This schema defines:
userIdGenerate the Prisma client and run migrations:
# Generate Prisma Client npx prisma generate # Create database tables npx prisma migrate dev --name initUpdate your
.envfile with your database connection:Step 3: Set Up Sinch SMS Configuration
Configure your Sinch credentials in
.env:Authentication Options:
Sinch supports two authentication methods in SDK v1.x:
projectId,keyId, andkeySecretservicePlanIdandapiTokenChoose 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:
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:
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:
Test 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:
// 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:
Update your login response to include JWT tokens for session management.
3. Implement OTP Cleanup Scheduler
Automatically remove expired OTPs:
// 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:
Configure Winston for production-grade logging with log rotation and remote transport.
6. Database Indexing
Optimize your database queries with indexes:
Apply the migration:
Security Best Practices
Protect your OTP system with these security measures:
1. Store Sinch credentials securely
Never commit
.envfiles 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:
.env"Invalid or expired OTP" errors
Symptoms: Valid OTP codes are rejected
Solutions:
Database connection failures
Symptoms: Prisma queries fail with connection errors
Solutions:
DATABASE_URLin.envmatches your PostgreSQL configurationpg_isreadySinch authentication errors
Symptoms: "Missing Sinch credentials" or "Authentication failed"
Solutions:
SINCH_PROJECT_ID,SINCH_KEY_ID,SINCH_KEY_SECRETSINCH_SERVICE_PLAN_ID,SINCH_API_TOKEN.envvaluesSource: 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_MINUTESconstant in theOtpServiceto 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: trueafter 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
SinchServiceautomatically 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_ENVcheck inSinchService.sendSMS(): ifNODE_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
OtpServiceinfrastructure 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 separateOtpTypeenum 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:
Key takeaways:
Related tutorials:
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: