code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / nestjs

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:

bash
# 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.0

Package 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:

bash
# Initialize Prisma
npx prisma init

Update prisma/schema.prisma with your database models:

prisma
// ... 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:

bash
# Generate Prisma Client
npx prisma generate

# Create database tables
npx prisma migrate dev --name init

Update your .env file with your database connection:

env
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:

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:

  1. OAuth2 (recommended for US/EU): Uses projectId, keyId, and keySecret
  2. API Token (required for other regions): Uses servicePlanId and apiToken

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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

bash
npm run start:dev

Test the complete authentication flow:

1. Register a new user

bash
curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@example.com",
    "password": "SecurePassword123",
    "phoneNumber": "+1234567890"
  }'

Expected response:

json
{
  "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

bash
curl -X POST http://localhost:3000/auth/verify-otp \
  -H "Content-Type: application/json" \
  -d '{
    "userId": 1,
    "code": "123456"
  }'

Expected response:

json
{
  "message": "Phone number verified successfully",
  "verified": true
}

3. Login with verified credentials

bash
curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@example.com",
    "password": "SecurePassword123"
  }'

Expected response:

json
{
  "message": "Login successful",
  "userId": 1,
  "email": "user@example.com"
}

4. Resend OTP if needed

bash
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:

bash
npm install @nestjs/throttler
typescript
// 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:

bash
npm install @nestjs/jwt @nestjs/passport passport passport-jwt
npm install -D @types/passport-jwt

Update your login response to include JWT tokens for session management.

3. Implement OTP Cleanup Scheduler

Automatically remove expired OTPs:

bash
npm install @nestjs/schedule
typescript
// 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:

typescript
// 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:

bash
npm install winston nest-winston

Configure Winston for production-grade logging with log rotation and remote transport.

6. Database Indexing

Optimize your database queries with indexes:

prisma
model Otp {
  // ... existing fields

  @@index([userId, verified, expiresAt])
}

model User {
  // ... existing fields

  @@index([email])
  @@index([phoneNumber])
}

Apply the migration:

bash
npx prisma migrate dev --name add_indexes

Security 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:

typescript
// 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:

typescript
// 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:

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:

  1. Verify Sinch credentials are correct in .env
  2. Check phone number format – must be E.164 (+1234567890)
  3. Confirm your Sinch account has SMS credits
  4. Review Sinch Dashboard for delivery reports and error messages
  5. Test with your own phone number first
  6. Verify your Sinch number supports SMS in the destination country

"Invalid or expired OTP" errors

Symptoms: Valid OTP codes are rejected

Solutions:

  1. Check OTP expiration time – default is 10 minutes
  2. Verify system clock synchronization on your server
  3. Ensure OTP isn't being verified multiple times (single-use enforcement)
  4. Check database for OTP records with correct userId
  5. Confirm code is exactly 6 digits with no extra whitespace

Database connection failures

Symptoms: Prisma queries fail with connection errors

Solutions:

  1. Verify DATABASE_URL in .env matches your PostgreSQL configuration
  2. Ensure PostgreSQL is running: pg_isready
  3. Check database credentials and permissions
  4. Confirm network access if using remote database
  5. Review Prisma connection logs for specific error messages

Sinch authentication errors

Symptoms: "Missing Sinch credentials" or "Authentication failed"

Solutions:

  1. For OAuth2: Provide all three values: SINCH_PROJECT_ID, SINCH_KEY_ID, SINCH_KEY_SECRET
  2. For API Token: Provide both: SINCH_SERVICE_PLAN_ID, SINCH_API_TOKEN
  3. Don't mix authentication methods – use one complete set
  4. Verify credentials from Sinch Dashboard under your project settings
  5. Check for trailing spaces or quotes in .env values
  6. 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:

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: