How to Implement Sinch SMS OTP Verification in Next.js with NextAuth (2025 Guide)
Learn to implement secure SMS OTP two-factor authentication in Next.js 14 using Sinch Verification API and NextAuth v5. Step-by-step tutorial with complete code examples, security best practices, and production deployment.
Two-factor authentication (2FA) with SMS OTP adds a critical security layer to user accounts by requiring phone number verification beyond passwords. This comprehensive guide demonstrates how to implement SMS OTP verification in Next.js 14 applications using the Sinch Verification API and NextAuth v5 for seamless authentication.
Build a production-ready Next.js 14 application with NextAuth integration for secure SMS-based OTP verification. We cover everything from initial project setup to error handling, security best practices, and deployment strategies.
What You'll Build: SMS OTP Two-Factor Authentication
Goal: Integrate SMS-based two-factor authentication into a Next.js application using Sinch.
Problem Solved: Enhance application security by verifying user identity through a secondary channel (SMS OTP), mitigating risks associated with compromised passwords.
Technologies Used:
Node.js: JavaScript runtime environment for the backend server.
Next.js 14: React framework for building server-rendered applications, with built-in routing and API support via App Router.
NextAuth v5 (Auth.js): Authentication library for Next.js, providing session management, OAuth, and custom authentication flows. NextAuth v5 Documentation.
Configure Environment Variables:
Create a .env.local file in the project root. Add your Sinch Application Key and Secret. Never commit this file to version control.Get credentials from Sinch Dashboard.
Replace YOUR_APPLICATION_KEY_HERE and YOUR_APPLICATION_SECRET_HERE with actual credentials from your Sinch Verification dashboard. Generate a secure random string for NEXTAUTH_SECRET using:
Bash
openssl rand -base64 32
Update .gitignore:
Ensure .env.local is listed in your .gitignore file (Next.js includes this by default).
To implement SMS OTP in Next.js, use a verification service like Sinch Verification API with NextAuth. Create API routes for requesting and verifying OTP codes, integrate with NextAuth credentials provider, and implement rate limiting for security. Follow the complete implementation guide above for step-by-step instructions.
What is the difference between SMS OTP and 2FA?
SMS OTP (One-Time Password) is a specific type of two-factor authentication (2FA). While 2FA refers to any authentication method requiring two verification factors, SMS OTP specifically uses text messages to deliver temporary verification codes to users' mobile devices.
How secure is SMS-based two-factor authentication?
SMS-based 2FA adds significant security compared to passwords alone but is vulnerable to SIM swapping attacks. For critical applications, consider combining SMS OTP with additional security measures like rate limiting, device fingerprinting, or using alternative methods like TOTP (Time-based One-Time Password) authenticator apps.
How much does Sinch SMS verification cost?
Sinch SMS verification costs vary by country, typically ranging from $0.02–$0.04 per verification in the US. International rates vary significantly. Consider using FlashCall verification in supported regions for lower costs. Monitor usage in the Sinch Dashboard to manage expenses.
Conclusion
You now have a complete, production-ready SMS OTP 2FA implementation using Sinch Verification API, Next.js 14, and NextAuth v5. This solution provides:
✅ Secure phone number verification with SMS OTP
✅ Type-safe TypeScript implementation
✅ Modern Next.js App Router architecture
✅ Proper error handling and logging
✅ Security best practices (rate limiting, input validation)
✅ Production deployment guidance
For questions or issues, consult the Sinch Developer Forum or contact Sinch support.
Frequently asked questions
Two-factor authentication (2FA) can be added to your Node.js Express app using the Vonage Verify API. This involves sending a one-time password (OTP) to the user's phone via SMS, adding an extra layer of security beyond just a password. This guide provides a step-by-step tutorial for implementing this security measure.
The Vonage Verify API is used to manage the complexities of OTP generation, delivery (via SMS or voice call), and verification within your Node.js application. It simplifies the process by handling the OTP lifecycle, so you don't have to build and maintain this logic yourself.
2FA enhances security by verifying user identity through a secondary channel (SMS OTP). This mitigates the risks associated with compromised passwords, protecting user accounts more effectively.
Implement 2FA as early as possible in your development process to prioritize user account security from the start. This proactive approach minimizes vulnerabilities and reinforces trust with your users.
Yes, you can customize the "brand" name that appears in the SMS message sent to the user during the 2FA process. Set the brand parameter in the vonage.verify.start() method to your app's name, enhancing the user experience.
Use npm install express ejs body-parser @vonage/server-sdk dotenv in your terminal to install the required packages for a Node.js 2FA project using Vonage. This command sets up Express for the server, EJS for templating, body-parser for handling forms, the Vonage SDK, and dotenv for environment variables.
The project utilizes a structured approach with directories for views (EJS templates), a .env file for credentials, .gitignore for excluding files from version control, server.js for the main application logic, and the standard package.json and node_modules folders.
Create a .env file in your project's root directory and add your VONAGE_API_KEY, VONAGE_API_SECRET, and desired PORT. Load these variables into your server.js file using require('dotenv').config();.
Check the status property in the Vonage API response. A non-zero status indicates an error. Log the status and error_text and display a user-friendly error message based on these values.
Initiate 2FA by calling vonage.verify.start() with the user's phone number and your app's brand name. This sends the OTP to the user's device. The function returns a request_id which you need to verify the OTP later.
Call vonage.verify.check() with the request_id (obtained from vonage.verify.start()) and the OTP entered by the user. This confirms if the entered code matches the one sent.
The vonage.verify.cancel() function is used to explicitly cancel an ongoing verification request. This is useful if the user navigates away from the verification process or requests a new code. This can be implemented as a GET route in the Express application.
A status code of '0' in the Vonage Verify API response signifies success. Any other status code indicates an error, which can be debugged using the accompanying error text (error_text) from the API response.
Wrap Vonage Verify API calls within a try...catch block to handle potential network errors. Use a retry mechanism with exponential backoff for transient network issues.
You'll need Node.js and npm (or yarn) installed, a Vonage API account (sign up for free at https://dashboard.nexmo.com/sign-up), basic understanding of Node.js, Express, and web concepts, and a text editor or IDE.
Two-factor authentication (2FA) with SMS OTP adds a critical security layer to user accounts by requiring phone number verification beyond passwords. This comprehensive guide demonstrates how to implement SMS OTP verification in Next.js 14 applications using the Sinch Verification API and NextAuth v5 for seamless authentication.
Build a production-ready Next.js 14 application with NextAuth integration for secure SMS-based OTP verification. We cover everything from initial project setup to error handling, security best practices, and deployment strategies.
What You'll Build: SMS OTP Two-Factor Authentication
Goal: Integrate SMS-based two-factor authentication into a Next.js application using Sinch.
Problem Solved: Enhance application security by verifying user identity through a secondary channel (SMS OTP), mitigating risks associated with compromised passwords.
Technologies Used:
.envfile.System Architecture:
Final Outcome: A functional web application where users can:
Prerequisites:
1. Setting Up Your Next.js SMS Verification Project
Initialize your Next.js project and install the necessary dependencies for SMS OTP authentication.
Create Next.js Project: Use
create-next-appto scaffold a new Next.js 14 application with TypeScript and App Router.npx create-next-app@latest sinch-nextauth-2fa cd sinch-nextauth-2faWhen prompted, select these options:
src/directory: Yes (recommended)Install Dependencies: Install NextAuth v5 (currently in beta), the Sinch SDK, and dotenv for credential management. Sinch Node.js SDK Reference.
Create Project Structure: Set up directories for API routes and authentication configuration.
Configure Environment Variables: Create a
.env.localfile in the project root. Add your Sinch Application Key and Secret. Never commit this file to version control. Get credentials from Sinch Dashboard.Replace
YOUR_APPLICATION_KEY_HEREandYOUR_APPLICATION_SECRET_HEREwith actual credentials from your Sinch Verification dashboard. Generate a secure random string forNEXTAUTH_SECRETusing:openssl rand -base64 32Update
.gitignore: Ensure.env.localis listed in your.gitignorefile (Next.js includes this by default).Create Sinch Client Library (
src/lib/sinch.ts): Initialize the Sinch SDK client for reuse across API routes.// src/lib/sinch.ts import { SinchClient, Verification } from '@sinch/sdk-core'; // Validate environment variables at module load if (!process.env.SINCH_APPLICATION_KEY || !process.env.SINCH_APPLICATION_SECRET) { throw new Error('Missing SINCH_APPLICATION_KEY or SINCH_APPLICATION_SECRET in environment variables'); } // Initialize Sinch client with Application credentials // Reference: https://developers.sinch.com/docs/verification/sdk/node/syntax-reference export const sinchClient = new SinchClient({ applicationKey: process.env.SINCH_APPLICATION_KEY, applicationSecret: process.env.SINCH_APPLICATION_SECRET, }); // Export verification service for direct access export const verificationService = sinchClient.verification; // Helper to build SMS verification request export function buildSmsVerificationRequest(phoneNumber: string, reference?: string) { return Verification.startVerificationHelper.buildSmsRequest(phoneNumber, reference); } // Helper to build SMS report request export function buildSmsReportRequest(id: string, code: string) { return Verification.reportVerificationByIdHelper.buildSmsRequest(id, code); }Why this approach?
2. Building the SMS OTP Verification API Routes
Build the API routes for the SMS OTP 2FA flow using Next.js 14 App Router and the Sinch Verification API.
2.1 Create SMS Verification Request Route
This endpoint initiates the SMS verification process and sends the OTP to the user's phone.
File:
src/app/api/verification/request/route.ts// src/app/api/verification/request/route.ts import { NextRequest, NextResponse } from 'next/server'; import { verificationService, buildSmsVerificationRequest } from '@/lib/sinch'; import { z } from 'zod'; // Input validation schema using Zod const RequestSchema = z.object({ phoneNumber: z.string() .regex(/^\+[1-9]\d{1,14}$/, 'Phone number must be in E.164 format (e.g., +14155551234)') }); export async function POST(request: NextRequest) { try { // Parse and validate request body const body = await request.json(); const validation = RequestSchema.safeParse(body); if (!validation.success) { return NextResponse.json( { error: 'Invalid phone number format. Use E.164 format (e.g., +14155551234).' }, { status: 400 } ); } const { phoneNumber } = validation.data; // Log only last 4 digits for privacy console.log(`[Verification Request] Phone ending: ...${phoneNumber.slice(-4)}`); // Build request using Sinch SDK helper // Reference: https://developers.sinch.com/docs/verification/getting-started/node-sdk/sms-verification const requestData = buildSmsVerificationRequest(phoneNumber); // Start SMS verification const response = await verificationService.verifications.startSms(requestData); if (!response.id) { throw new Error('Sinch API did not return a verification ID'); } console.log(`[Verification Request] ID: ${response.id}, Status: ${response.status}`); return NextResponse.json({ success: true, verificationId: response.id, message: 'Verification code sent successfully' }); } catch (error: any) { console.error('[Verification Request Error]:', error); // Handle Sinch API errors if (error.statusCode) { return NextResponse.json( { error: error.message || 'Verification request failed' }, { status: error.statusCode } ); } // Generic error response return NextResponse.json( { error: 'An unexpected error occurred. Try again.' }, { status: 500 } ); } }2.2 Create OTP Verification Check Route
This endpoint verifies the SMS OTP code submitted by the user.
File:
src/app/api/verification/check/route.ts// src/app/api/verification/check/route.ts import { NextRequest, NextResponse } from 'next/server'; import { verificationService, buildSmsReportRequest } from '@/lib/sinch'; import { z } from 'zod'; // Input validation schema const CheckSchema = z.object({ verificationId: z.string().min(1, 'Verification ID is required'), code: z.string() .regex(/^\d{4,6}$/, 'Code must be 4–6 digits') }); export async function POST(request: NextRequest) { try { // Parse and validate request body const body = await request.json(); const validation = CheckSchema.safeParse(body); if (!validation.success) { return NextResponse.json( { error: validation.error.errors[0].message }, { status: 400 } ); } const { verificationId, code } = validation.data; console.log(`[Verification Check] ID: ${verificationId}`); // Build report request using Sinch SDK helper // Reference: https://developers.sinch.com/docs/verification/sdk/node/syntax-reference const requestData = buildSmsReportRequest(verificationId, code); // Report the SMS code for verification const response = await verificationService.verifications.reportSmsById(requestData); // Check response status // Possible statuses: SUCCESSFUL, PENDING, FAIL, ERROR, DENIED, ABORTED // Reference: https://developers.sinch.com/docs/verification/api-reference if (response.status === 'SUCCESSFUL') { console.log(`[Verification Check] Success – ID: ${verificationId}`); return NextResponse.json({ success: true, status: response.status, message: 'Phone number verified successfully' }); } else { console.warn(`[Verification Check] Failed – ID: ${verificationId}, Status: ${response.status}`); return NextResponse.json( { success: false, status: response.status, error: 'Invalid or expired verification code' }, { status: 400 } ); } } catch (error: any) { console.error('[Verification Check Error]:', error); // Handle Sinch API errors if (error.statusCode) { return NextResponse.json( { error: error.message || 'Verification check failed' }, { status: error.statusCode } ); } // Generic error response return NextResponse.json( { error: 'An unexpected error occurred. Try again.' }, { status: 500 } ); } }Why these choices?
app/apidirectory structure withroute.tsfiles for API endpoints (Next.js 14 standard).buildSmsRequest,buildSmsReportRequest) for correct payload structure per SDK documentation.SUCCESSFUL,PENDING,FAIL) as documented in Sinch Verification API Reference.2.3 Install Additional Dependencies
Add Zod for validation:
3. Creating the SMS OTP Verification UI
Create React components for the phone number input and OTP verification forms.
3.1 Main Page with SMS Verification Flow
File:
src/app/page.tsx'use client'; import { useState } from 'react'; export default function Home() { const [step, setStep] = useState<'phone' | 'code' | 'success'>('phone'); const [phoneNumber, setPhoneNumber] = useState(''); const [code, setCode] = useState(''); const [verificationId, setVerificationId] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const handleRequestCode = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setLoading(true); try { const response = await fetch('/api/verification/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phoneNumber }), }); const data = await response.json(); if (response.ok) { setVerificationId(data.verificationId); setStep('code'); } else { setError(data.error || 'Failed to send verification code'); } } catch (err) { setError('Network error. Try again.'); } finally { setLoading(false); } }; const handleVerifyCode = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setLoading(true); try { const response = await fetch('/api/verification/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ verificationId, code }), }); const data = await response.json(); if (response.ok && data.success) { setStep('success'); } else { setError(data.error || 'Invalid verification code'); } } catch (err) { setError('Network error. Try again.'); } finally { setLoading(false); } }; const reset = () => { setStep('phone'); setPhoneNumber(''); setCode(''); setVerificationId(''); setError(''); }; return ( <main className="flex min-h-screen flex-col items-center justify-center p-24"> <div className="w-full max-w-md space-y-8"> <div className="text-center"> <h1 className="text-3xl font-bold">Phone Verification</h1> <p className="mt-2 text-gray-600">Secure 2FA with Sinch SMS OTP</p> </div> {error && ( <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded"> {error} </div> )} {step === 'phone' && ( <form onSubmit={handleRequestCode} className="space-y-4"> <div> <label htmlFor="phone" className="block text-sm font-medium mb-2"> Phone Number (E.164 format) </label> <input id="phone" type="tel" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder="+14155551234" className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" required disabled={loading} /> <p className="mt-1 text-sm text-gray-500"> Include country code (e.g., +1 for US) </p> </div> <button type="submit" disabled={loading} className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 disabled:opacity-50" > {loading ? 'Sending...' : 'Send Verification Code'} </button> </form> )} {step === 'code' && ( <form onSubmit={handleVerifyCode} className="space-y-4"> <div> <label htmlFor="code" className="block text-sm font-medium mb-2"> Verification Code </label> <input id="code" type="text" value={code} onChange={(e) => setCode(e.target.value)} placeholder="Enter 4–6 digit code" className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" maxLength={6} pattern="\d{4,6}" required disabled={loading} /> <p className="mt-1 text-sm text-gray-500"> Code sent to {phoneNumber} </p> </div> <button type="submit" disabled={loading} className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 disabled:opacity-50" > {loading ? 'Verifying...' : 'Verify Code'} </button> <button type="button" onClick={reset} className="w-full bg-gray-200 text-gray-700 py-2 px-4 rounded-lg hover:bg-gray-300" > Use Different Number </button> </form> )} {step === 'success' && ( <div className="text-center space-y-4"> <div className="text-6xl">✅</div> <h2 className="text-2xl font-bold text-green-600"> Verification Successful! </h2> <p className="text-gray-600"> Your phone number has been verified. </p> <button onClick={reset} className="bg-blue-600 text-white py-2 px-6 rounded-lg hover:bg-blue-700" > Start Over </button> </div> )} </div> </main> ); }3.2 Run the Application
Start the development server:
Navigate to
http://localhost:3000and test the verification flow:4. Integrating SMS OTP with NextAuth v5
To integrate this SMS OTP verification flow with NextAuth for actual authentication, create a custom credentials provider.
File:
src/lib/auth.ts// src/lib/auth.ts import NextAuth from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; export const { handlers, signIn, signOut, auth } = NextAuth({ providers: [ Credentials({ name: 'Phone Verification', credentials: { phoneNumber: { label: 'Phone Number', type: 'tel' }, verificationId: { label: 'Verification ID', type: 'text' }, code: { label: 'Code', type: 'text' }, }, async authorize(credentials) { // In production, verify the code via your API and look up/create the user in your database const response = await fetch(`${process.env.NEXTAUTH_URL}/api/verification/check`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ verificationId: credentials.verificationId, code: credentials.code, }), }); const data = await response.json(); if (response.ok && data.success) { // Return user object – in production, fetch from database return { id: credentials.phoneNumber as string, phone: credentials.phoneNumber as string, }; } return null; // Authentication failed }, }), ], pages: { signIn: '/auth/signin', }, callbacks: { async jwt({ token, user }) { if (user) { token.phone = user.phone; } return token; }, async session({ session, token }) { if (token.phone) { session.user.phone = token.phone as string; } return session; }, }, });File:
src/app/api/auth/[...nextauth]/route.ts// src/app/api/auth/[...nextauth]/route.ts import { handlers } from '@/lib/auth'; export const { GET, POST } = handlers;5. SMS OTP Security Best Practices
5.1 Rate Limiting for SMS Verification
Implement rate limiting to prevent abuse. Install and configure
@upstash/ratelimit:Update verification request route:
import { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis'; const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(5, '1 h'), // 5 requests per hour analytics: true, }); export async function POST(request: NextRequest) { // Rate limit by IP const ip = request.ip ?? '127.0.0.1'; const { success } = await ratelimit.limit(ip); if (!success) { return NextResponse.json( { error: 'Too many requests. Try again later.' }, { status: 429 } ); } // ... rest of the handler }5.2 Environment Security
.env.localout of version control.5.3 Input Validation
5.4 Verification Best Practices
6. Error Handling and Logging
6.1 Comprehensive Error Types
Common Sinch Verification API errors and how to handle them:
Reference: Sinch API Error Codes
6.2 Structured Logging
Use a logging library like Winston or Pino for production:
import winston from 'winston'; export const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.json() ), transports: [ new winston.transports.Console(), new winston.transports.File({ filename: 'error.log', level: 'error' }), new winston.transports.File({ filename: 'combined.log' }), ], });7. Testing SMS OTP Verification
7.1 Unit Tests
Test the API routes using Jest and Supertest:
7.2 Integration Tests
Test the full verification flow:
describe('Verification Flow', () => { it('should send verification code', async () => { const response = await fetch('/api/verification/request', { method: 'POST', body: JSON.stringify({ phoneNumber: '+14155551234' }), }); expect(response.status).toBe(200); const data = await response.json(); expect(data.verificationId).toBeDefined(); }); });8. Production Deployment
8.1 Environment Configuration
Set environment variables in your hosting platform (Vercel, AWS, Azure):
8.2 Performance Optimization
8.3 Compliance Considerations
9. SMS Verification Cost Considerations
Sinch charges per verification attempt. Costs vary by country:
Monitor usage in the Sinch Dashboard and set up alerts for unexpected spikes. Reference: Sinch SMS Pricing.
10. Alternative Verification Methods
Sinch supports multiple verification methods beyond SMS:
Frequently Asked Questions (FAQ)
How do I implement SMS OTP in Next.js?
To implement SMS OTP in Next.js, use a verification service like Sinch Verification API with NextAuth. Create API routes for requesting and verifying OTP codes, integrate with NextAuth credentials provider, and implement rate limiting for security. Follow the complete implementation guide above for step-by-step instructions.
What is the difference between SMS OTP and 2FA?
SMS OTP (One-Time Password) is a specific type of two-factor authentication (2FA). While 2FA refers to any authentication method requiring two verification factors, SMS OTP specifically uses text messages to deliver temporary verification codes to users' mobile devices.
How secure is SMS-based two-factor authentication?
SMS-based 2FA adds significant security compared to passwords alone but is vulnerable to SIM swapping attacks. For critical applications, consider combining SMS OTP with additional security measures like rate limiting, device fingerprinting, or using alternative methods like TOTP (Time-based One-Time Password) authenticator apps.
How much does Sinch SMS verification cost?
Sinch SMS verification costs vary by country, typically ranging from $0.02–$0.04 per verification in the US. International rates vary significantly. Consider using FlashCall verification in supported regions for lower costs. Monitor usage in the Sinch Dashboard to manage expenses.
Conclusion
You now have a complete, production-ready SMS OTP 2FA implementation using Sinch Verification API, Next.js 14, and NextAuth v5. This solution provides:
✅ Secure phone number verification with SMS OTP ✅ Type-safe TypeScript implementation ✅ Modern Next.js App Router architecture ✅ Proper error handling and logging ✅ Security best practices (rate limiting, input validation) ✅ Production deployment guidance
Additional Resources
For questions or issues, consult the Sinch Developer Forum or contact Sinch support.