How to Implement SMS OTP Verification in Node.js and Next.js with Sinch (2025 Guide)
Learn how to build secure phone number verification and SMS two-factor authentication (2FA) using Sinch Verification API, Node.js, Next.js, and Redis. Complete implementation guide with working code examples.
This comprehensive guide walks you through implementing secure phone number verification and SMS-based two-factor authentication (2FA) using Sinch's Verification API. You'll learn how to build a production-ready system with a Next.js frontend and Node.js (Express) backend that handles OTP generation, SMS delivery via Sinch, and secure verification.
SMS OTP authentication is essential for verifying user phone numbers during sign-up, securing login flows with an additional authentication factor, and confirming high-value transactions. By the end of this tutorial, you'll have a fully functional passwordless authentication system that enhances security while maintaining excellent user experience.
Project Overview and Goals
What We'll Build:
A Node.js (Express) backend API responsible for:
Generating secure OTPs using Node.js's crypto module.
Storing OTPs temporarily (using Redis).
Sending OTPs to users' phone numbers via the Sinch Verification API.
Verifying user-submitted OTPs against the stored values.
A Next.js frontend application with:
A form to capture the user's phone number and request an OTP.
A form to submit the received OTP for verification.
Communication logic to interact with the backend API.
Problem Solved: Securely verify user phone numbers and implement multi-factor authentication (MFA) using SMS one-time passwords. This implementation leverages Sinch's reliable global SMS delivery infrastructure and follows modern security best practices for OTP verification systems.
Technologies Used:
Node.js: JavaScript runtime environment for the backend server.
Express.js: Minimalist web framework for building the REST API.
Next.js: React-based framework for the frontend application (v15.x as of 2025; App Router requires React 19 for full features).
Sinch: Communications Platform as a Service (CPaaS) for reliable global SMS delivery via their Verification API.
Redis: High-performance in-memory data store for secure temporary OTP storage with automatic expiration. We'll use ioredis (note: ioredis is maintained on best-effort basis; for new projects in 2025, consider node-redis as the officially recommended client).
dotenv: Environment variable management for secure credential storage.
@sinch/sdk-core & @sinch/verification: Official Sinch Node.js SDKs for the Verification API (reached GA v1.0.0 in May 2024). This guide uses @sinch/verification, which targets the modern Sinch Verification API product.
axios (optional): Promise-based HTTP client for frontend-to-backend communication.
express-rate-limit: Middleware for preventing brute-force attacks on OTP endpoints.
Final Outcome: A functional application where users can enter their phone number, receive an SMS OTP via Sinch, and verify that OTP to gain access or confirm an action.
1. Setting Up the Backend (Node.js/Express)
We'll start by creating the backend application that will handle the core logic for OTP generation and verification. This backend API will manage secure random code generation, temporary storage in Redis, and integration with Sinch's SMS delivery service.
Steps:
Create Project Directory:
Bash
mkdir sinch-otp-backend
cd sinch-otp-backend
Initialize Node.js Project:
Bash
npm init -y
Install Dependencies:
Bash
npm install express dotenv ioredis @sinch/sdk-core @sinch/verification cors express-rate-limit
express: Web framework.
dotenv: Loads environment variables.
ioredis: Redis client.
@sinch/sdk-core, @sinch/verification: Sinch SDKs for the Verification API.
cors: Enables Cross-Origin Resource Sharing (needed for frontend communication).
express-rate-limit: Basic rate limiting to prevent abuse.
Create .env File: Create a file named .env in the sinch-otp-backend root directory. This file will store sensitive credentials and configuration. Never commit this file to version control.
.env
# .env
# Server Configuration
PORT=4000
# Redis Configuration
REDIS_URL=redis://localhost:6379 # Replace with your Redis connection string (e.g., redis://:password@hostname:port)
# Sinch API Credentials (Get these from your Sinch Dashboard)
# Navigate to Settings -> API Credentials
SINCH_KEY_ID=YOUR_SINCH_KEY_ID
SINCH_KEY_SECRET=YOUR_SINCH_KEY_SECRET
SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID # Find this in your Sinch project settings
# Frontend URL (for CORS)
FRONTEND_URL=http://localhost:3000
PORT: The port your backend server will run on.
REDIS_URL: The connection string for your Redis instance.
SINCH_KEY_ID, SINCH_KEY_SECRET: Found in your Sinch Dashboard under Settings > API Credentials. Treat these like passwords.
SINCH_PROJECT_ID: Your Sinch Project ID, usually visible in the dashboard URL or project settings.
FRONTEND_URL: The URL of your Next.js frontend, used for CORS configuration.
Create Basic Server File (server.js):
Create a file named server.js in the root directory.
JavaScript
// server.jsrequire('dotenv').config(); // Load environment variables firstconst express = require('express');
constRedis = require('ioredis');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const crypto = require('crypto'); // For secure OTP generationconstSinch = require('@sinch/sdk-core'); // Core SDKconst { SmsVerification } = require('@sinch/verification'); // Verification specific part// --- Configuration ---constPORT = process.env.PORT || 4000;
constOTP_EXPIRY_SECONDS = 300; // 5 minutes// Note: NIST SP 800-63B recommends OTPs change at least once every 2 minutes (120s).// This implementation uses 5 minutes for improved user experience, but consider// reducing to 120-180 seconds for stricter security compliance.// Source: NIST Special Publication 800-63B (Digital Identity Guidelines).// --- Initialization ---const app = express();
// Initialize Redis Clientconst redisClient = newRedis(process.env.REDIS_URL, {
// Optional: Add error handling for Redis connectionretryStrategy(times) {
const delay = Math.min(times * 50, 2000); // Exponential backoffconsole.warn(`Redis connection failed, retrying in ${delay}ms... (Attempt ${times})`);
return delay;
},
maxRetriesPerRequest: 3, // Limit retries per command
});
redisClient.on('error', (err) => {
console.error('Redis Client Error:', err);
// Implement more robust error handling/alerting in production
});
redisClient.on('connect', () => {
console.log('Connected to Redis');
});
// Initialize Sinch Clientif (!process.env.SINCH_KEY_ID || !process.env.SINCH_KEY_SECRET || !process.env.SINCH_PROJECT_ID) {
console.error('FATAL ERROR: Missing Sinch API credentials in .env file.');
process.exit(1); // Exit if credentials are missing
}
const sinchCredentials = {
projectId: process.env.SINCH_PROJECT_ID,
keyId: process.env.SINCH_KEY_ID,
keySecret: process.env.SINCH_KEY_SECRET,
};
const sinchClient = newSinch.SinchClient(sinchCredentials);
const verificationService = newSmsVerification(sinchClient); // Initialize the SMS Verification service// --- Middleware ---
app.use(express.json()); // Parse JSON request bodies// CORS Configurationconst corsOptions = {
origin: process.env.FRONTEND_URL, // Allow only your frontendoptionsSuccessStatus: 200// Some legacy browsers choke on 204
};
app.use(cors(corsOptions));
// Rate Limiting (apply to specific routes later for more granularity)const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutesmax: 100, // Limit each IP to 100 requests per windowMsstandardHeaders: true, // Return rate limit info in the `RateLimit-*` headerslegacyHeaders: false, // Disable the `X-RateLimit-*` headersmessage: 'Too many requests from this IP, please try again after 15 minutes',
});
app.use(limiter); // Apply to all routes for now// --- API Routes (To be added in next steps) ---// Placeholder for health check
app.get('/health', (req, res) => {
res.status(200).json({ status: 'OK', redis: redisClient.status });
});
// --- Start Server ---// Assign the server instance for graceful shutdownconst server = app.listen(PORT, () => {
console.log(`Backend server running on http://localhost:${PORT}`);
});
// Graceful Shutdown Handlingconstshutdown = () => {
console.info('Signal received: closing HTTP server');
server.close(() => {
console.log('HTTP server closed');
redisClient.quit(() => {
console.log('Redis connection closed');
process.exit(0);
});
});
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown); // Handle Ctrl+C locally
dotenv.config(): Must be called early to load .env variables.
Redis Client: Initializes ioredis with the URL from .env. Includes basic retry logic and error handling.
Sinch Client: Initializes the Sinch core client and the specific SmsVerification service using credentials from .env. Includes a check for missing credentials.
Middleware:
express.json(): Parses incoming JSON requests.
cors(): Enables requests from your frontend URL specified in .env. Crucial for development and production.
rateLimit(): Basic protection against brute-force attacks.
Graceful Shutdown: The app.listen return value is stored in server, allowing server.close() to be called correctly on SIGTERM or SIGINT.
Add Start Script to package.json:
Open package.json and add/ensure you have a start script:
JSON
{"name":"sinch-otp-backend","version":"1.0.0","description":"","main":"server.js","scripts":{"start":"node server.js","test":"echo \"Error: no test specified\" && exit 1"},"keywords":[],"author":"","license":"ISC","dependencies":{"@sinch/sdk-core":"...","@sinch/verification":"...","cors":"...","dotenv":"...","express":"...","express-rate-limit":"...","ioredis":"..."}}
Initial Run:
Make sure your Redis server is running. Then, start the backend:
Bash
npm start
You should see Backend server running on http://localhost:4000 and Connected to Redis if everything is configured correctly. Access http://localhost:4000/health in your browser or Postman to check the health endpoint.
2. Implementing Core Functionality (Backend API Endpoints)
Now, let's build the REST API endpoints for requesting and verifying OTPs. These endpoints form the backbone of your phone verification system and handle the complete authentication flow from code generation to validation.
Steps:
Create OTP Generation Logic: Use Node.js's built-in crypto module for secure random number generation.
Add this function within server.js, before the API routes:
JavaScript
// server.js (add this function)// --- Helper Functions ---functiongenerateOtp(length = 6) {
// Generate a cryptographically secure random integerconst min = Math.pow(10, length - 1);
const max = Math.pow(10, length) - 1;
try {
// crypto.randomInt is preferred for security-sensitive generation// It generates an integer between min (inclusive) and max + 1 (exclusive)return crypto.randomInt(min, max + 1).toString().padStart(length, '0');
} catch (error) {
console.error('FATAL: Error generating secure OTP:', error);
// In a real application, you might want to throw or handle this more gracefully// than falling back to Math.random, which is not cryptographically secure.// For this example, we'll throw to highlight the issue.thrownewError('Failed to generate secure OTP.');
}
}
Security: Uses crypto.randomInt which is suitable for security-sensitive values like OTPs.
Padding: Ensures the OTP is always the desired length (e.g., 012345 instead of 12345).
Create Request OTP Endpoint (/api/otp/request):
This endpoint receives a phone number, generates an OTP, stores it in Redis, and triggers Sinch to send the SMS.
Add this route handler in server.js under the // --- API Routes --- comment:
JavaScript
// server.js (add this route)// Rate limiting specific to OTP requests (more strict)const otpRequestLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutesmax: 5, // Limit each IP to 5 OTP requests per 5 minutesstandardHeaders: true,
legacyHeaders: false,
message: 'Too many OTP requests, please try again later.',
});
app.post('/api/otp/request', otpRequestLimiter, async (req, res) => {
const { phoneNumber } = req.body;
// 1. Validate Inputif (!phoneNumber || !/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) {
// Basic E.164 format check (adjust regex as needed for stricter validation)return res.status(400).json({ error: 'Invalid phone number format. Use E.164 format (e.g., +1234567890).' });
}
try {
// 2. Generate OTPconst otp = generateOtp(6); // Generate a 6-digit OTPconst redisKey = `otp:${phoneNumber}`;
// 3. Store OTP in Redis with Expiry// Use SET with EX option for atomicity (Set and Expire)await redisClient.set(redisKey, otp, 'EX', OTP_EXPIRY_SECONDS);
console.log(`OTP ${otp} generated for ${phoneNumber}, expires in ${OTP_EXPIRY_SECONDS}s`);
// 4. Send OTP via Sinch Verification APIconsole.log(`Attempting to send OTP to ${phoneNumber} via Sinch...`);
// --- IMPORTANT: Verify Sinch SDK Payload Structure ---// The exact structure of this request object might change between SDK versions.// Always consult the official @sinch/verification SDK documentation for the// 'startSms' method to ensure the payload format is correct.const startVerificationRequest = {
smsVerificationStartRequest: {
identity: { type: 'number', endpoint: phoneNumber },
reference: `MyAppVerification_${Date.now()}`, // Optional reference for tracking// You can often customize SMS templates in the Sinch dashboard.// Specifying custom content here might depend on your Sinch plan/settings.
}
};
// Ensure verificationService is initialized (though we check at startup)if (!verificationService) {
console.error('Sinch Verification Service not initialized!');
return res.status(500).json({ error: 'Internal server error (Sinch service unavailable)' });
}
const response = await verificationService.startSms(startVerificationRequest);
// --- IMPORTANT: Verify Sinch SDK Success Response ---// Check the actual structure of the 'response' object returned by the SDK// in successful cases. The success condition might involve different fields// or checks than just 'response.id'. Log the response for debugging.console.log('Sinch Start SMS Verification Response:', response);
// Assuming success if no error is thrown AND a verification ID is presentif (response && response.id) {
console.log(`Sinch verification initiated successfully for ${phoneNumber}. Verification ID: ${response.id}`);
// Optionally return verificationId if needed for reporting later
res.status(200).json({ success: true, message: 'OTP sent successfully.', verificationId: response.id });
} else {
// Handle cases where Sinch might not throw an error but indicates failure in the responseconsole.error(`Sinch verification initiation failed for ${phoneNumber}, unexpected response:`, response);
res.status(500).json({ error: 'Failed to initiate OTP sending via Sinch.' });
}
} catch (error) {
console.error(`Error requesting OTP for ${phoneNumber}:`, error);
// --- Enhanced Error Handling ---// Attempt to parse specific Sinch errors (structure depends on SDK)let errorMessage = 'Failed to send OTP.';
let statusCode = 500;
// Check if it looks like a Sinch API error (structure may vary)if (error && typeof error === 'object' && 'response'in error && error.response && error.response.data) {
const sinchError = error.response.data.error;
errorMessage = `Sinch Error: ${sinchError?.message || 'Unknown Sinch Error'}`;
statusCode = error.response.status || 500;
console.error('Sinch API Error Details:', sinchError);
// Add specific handling based on sinchError.code if needed
} elseif (error.code === 'CONNECTION_BROKEN' || (error.message && error.message.includes('Redis connection'))) {
errorMessage = 'Service temporarily unavailable (Database). Please try again later.';
statusCode = 503; // Service Unavailable
} elseif (error.message === 'Failed to generate secure OTP.') {
errorMessage = 'Internal server error during OTP generation.';
statusCode = 500;
} else {
// Generic internal error
errorMessage = 'Internal server error.';
statusCode = 500;
}
res.status(statusCode).json({ error: errorMessage });
}
});
Validation: Basic check for phone number format (E.164 recommended).
Rate Limiting: Applies a stricter rate limit specifically to this endpoint.
OTP Generation: Calls the secure generateOtp helper.
Redis Storage: Stores the OTP using the phone number as part of the key (otp:+1234567890). EX sets the expiration time atomically.
Sinch Call: Uses verificationService.startSms. Includes important comments advising verification of the payload structure and success response against current Sinch SDK documentation.
Response Handling: Checks the Sinch response (assuming response.id indicates success, but requires verification). Includes improved error handling trying to distinguish Sinch API errors from Redis errors or other internal issues.
Create Verify OTP Endpoint (/api/otp/verify):
This endpoint receives the phone number and the OTP entered by the user, compares it with the value in Redis, and returns the verification result.
Add this route handler in server.js:
JavaScript
// server.js (add this route)// Rate limiting specific to OTP verification attemptsconst otpVerifyLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutesmax: 10, // Limit each IP to 10 verify attempts per 15 minutes (adjust as needed)standardHeaders: true,
legacyHeaders: false,
message: 'Too many verification attempts, please try again later.',
});
app.post('/api/otp/verify', otpVerifyLimiter, async (req, res) => {
const { phoneNumber, otp } = req.body;
// const { verificationId } = req.body; // Include if reporting verification to Sinch// 1. Validate Inputif (!phoneNumber || !/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) {
return res.status(400).json({ error: 'Invalid phone number format.' });
}
if (!otp || !/^\d{6}$/.test(otp)) { // Assuming 6-digit OTPreturn res.status(400).json({ error: 'Invalid OTP format. Must be 6 digits.' });
}
const redisKey = `otp:${phoneNumber}`;
try {
// 2. Retrieve OTP from Redisconst storedOtp = await redisClient.get(redisKey);
// 3. Check if OTP exists (not expired or invalid number/key)if (!storedOtp) {
console.warn(`Verification attempt failed for ${phoneNumber}: OTP not found in Redis or expired.`);
// Keep error generic for the clientreturn res.status(400).json({ error: 'Invalid or expired OTP.' });
}
// 4. Compare submitted OTP with stored OTP (use simple equality)if (storedOtp === otp) {
console.log(`OTP verified successfully for ${phoneNumber}`);
// Success! OTP matches. Delete from Redis immediately to prevent reuse.await redisClient.del(redisKey);
// --- Optional: Report Verification to Sinch ---// This step might be required depending on your Sinch setup/plan for billing or analytics.// It often involves using the verification ID returned from the 'startSms' call./*
if (verificationId) {
try {
// --- IMPORTANT: Verify Sinch SDK Payload Structure ---
// The exact structure for reporting verification might differ.
// Consult the official @sinch/verification SDK documentation for the
// 'reportSms' method (or equivalent) and its required payload.
// The structure below is a hypothetical example.
const reportRequest = {
reportSmsVerificationRequest: {
verificationReportRequest: {
sms: { code: otp }
}
},
id: verificationId // Use the ID from the start request response
};
await verificationService.reportSms(reportRequest);
console.log(`Reported successful verification to Sinch for ID: ${verificationId}`);
} catch(reportError) {
// Log the reporting error, but usually don't fail the user's verification flow
console.error(`Failed to report verification to Sinch for ${phoneNumber} (ID: ${verificationId}):`, reportError);
}
}
*/// --- End Optional Sinch Report// --- Session Management TODO ---// IMPORTANT: Successful OTP verification alone doesn't log the user in.// You need to implement session management here. This typically involves:// 1. Finding/Creating a user record in your database associated with 'phoneNumber'.// 2. Marking the phone number as verified in the database.// 3. Generating a session token (e.g., JWT) or setting a session cookie.// Consider using libraries like NextAuth.js (frontend/backend) or express-session (backend).// For now, just return success.return res.status(200).json({ success: true, message: 'OTP verified successfully.' });
} else {
// Failure: OTP does not matchconsole.warn(`Verification attempt failed for ${phoneNumber}: Incorrect OTP submitted.`);
// Optional: Implement attempt tracking in Redis to lock out after N failures for a specific number// Keep error generic for the clientreturn res.status(400).json({ error: 'Invalid or expired OTP.' });
}
} catch (error) {
console.error(`Error verifying OTP for ${phoneNumber}:`, error);
if (error.code === 'CONNECTION_BROKEN' || (error.message && error.message.includes('Redis connection'))) {
res.status(503).json({ error: 'Service temporarily unavailable (Database). Please try again later.' });
} else {
res.status(500).json({ error: 'Internal server error.' });
}
}
});
Validation: Checks both phone number and OTP format.
Rate Limiting: Applies rate limiting to verification attempts.
Redis Retrieval: Fetches the expected OTP using the phone number key.
Comparison: Checks if the fetched OTP exists and matches the submitted OTP.
Cleanup: If verification is successful, the OTP must be deleted from Redis (redisClient.del) to prevent reuse.
Sinch Reporting (Optional): Includes a commented-out section for reporting back to Sinch, with a strong warning to verify the SDK method and payload structure.
Session Management TODO: Contains an expanded comment clarifying why session management is the necessary next step after successful verification and suggests common approaches.
Restart Backend: Stop (Ctrl+C) and restart the backend server (npm start) to load the new routes. Test the endpoints using Postman or curl:
Request OTP:POST http://localhost:4000/api/otp/request with JSON body:
JSON
{"phoneNumber":"+1234567890"}
(use a real phone number you can receive SMS on). Check your phone for the OTP and the console logs.
Verify OTP:POST http://localhost:4000/api/otp/verify with JSON body:
JSON
{"phoneNumber":"+1234567890","otp":"123456"}
(replace with the OTP you received). Check the response and logs.
3. Setting Up the Frontend (Next.js)
Now, let's create the Next.js application that provides the user interface for SMS authentication. This frontend will handle phone number input, OTP request submission, and verification code entry with real-time feedback.
Steps:
Create Next.js App:
Navigate outside your backend directory and run:
Create .env.local File:
In the sinch-otp-frontend root, create .env.local. This is for client-side environment variables.
.env
# .env.local
# Must start with NEXT_PUBLIC_ to be exposed to the browser
NEXT_PUBLIC_API_BASE_URL=http://localhost:4000/api
NEXT_PUBLIC_API_BASE_URL: The base URL for your backend API. The NEXT_PUBLIC_ prefix makes it available in the browser.
Modify the Home Page (app/page.tsx or pages/index.tsx):
Replace the content of your main page file with a basic structure containing forms for requesting and verifying OTPs.
(Example using App Router app/page.tsx and useState)
TypeScript
// app/page.tsx'use client'; // Required for useState and event handlers in App Routerimport { useState, FormEvent } from'react';
import axios from'axios'; // Or use fetchconstAPI_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL;
exportdefaultfunctionHomePage() {
const [phoneNumber, setPhoneNumber] = useState('');
const [otp, setOtp] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [showOtpForm, setShowOtpForm] = useState(false);
// Optional: Store verification ID if needed for reporting back to Sinch// const [verificationId, setVerificationId] = useState<string | null>(null);consthandleRequestOtp = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setMessage('');
setError('');
if (!API_BASE_URL) {
setError('API URL not configured. Check .env.local.');
setIsLoading(false);
return;
}
try {
console.log(`Requesting OTP for ${phoneNumber} at ${API_BASE_URL}/otp/request`);
const response = await axios.post(`${API_BASE_URL}/otp/request`, {
phoneNumber,
});
// Handle successif (response.data.success) {
setMessage('OTP sent successfully! Please check your phone.');
setShowOtpForm(true);
// Store verificationId if backend sends it and you need it later// if (response.data.verificationId) {// setVerificationId(response.data.verificationId);// }
} else {
// This case might not be reached if backend uses proper HTTP status codes for errorssetError(response.data.error || 'Failed to request OTP.');
}
} catch (err: any) {
console.error('Request OTP error:', err);
// Extract error message from backend response if availableconst errorMsg = err.response?.data?.error || err.message || 'An unexpected error occurred.';
setError(`Error: ${errorMsg}`);
setShowOtpForm(false); // Hide OTP form on error
} finally {
setIsLoading(false);
}
};
consthandleVerifyOtp = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setMessage('');
setError('');
if (!API_BASE_URL) {
setError('API URL not configured. Check .env.local.');
setIsLoading(false);
return;
}
try {
console.log(`Verifying OTP ${otp} for ${phoneNumber} at ${API_BASE_URL}/otp/verify`);
const response = await axios.post(`${API_BASE_URL}/otp/verify`, {
phoneNumber,
otp,
// Include verificationId if you stored it and need to send it for reporting// verificationId: verificationId
});
if (response.data.success) {
setMessage('Phone number verified successfully!');
setShowOtpForm(false); // Hide OTP form after successsetPhoneNumber(''); // Optionally clear formssetOtp('');
// TODO: Redirect user or update application state (e.g., set logged-in status)// This is where you'd typically handle the post-verification logic (session, redirect etc.)
} else {
// This case might not be reached if backend uses proper HTTP status codes for errorssetError(response.data.error || 'Failed to verify OTP.');
}
} catch (err: any) {
// Error handling logic now correctly inside the catch blockconsole.error('Verify OTP error:', err);
const errorMsg = err.response?.data?.error || err.message || 'An unexpected error occurred.';
setError(`Error: ${errorMsg}`);
} finally {
setIsLoading(false);
}
};
// Basic Styling (inline for simplicity, use CSS modules or Tailwind in production)const inputStyle = { padding: '10px', margin: '8px 0', border: '1px solid #ccc', borderRadius: '4px', width: '100%', boxSizing: 'border-box'asconst };
const buttonStyle = { padding: '12px 20px', margin: '10px 0', cursor: 'pointer', backgroundColor: '#0070f3', color: 'white', border: 'none', borderRadius: '4px', width: '100%', fontSize: '16px' };
const disabledButtonStyle = { ...buttonStyle, backgroundColor: '#ccc', cursor: 'not-allowed' };
const secondaryButtonStyle = { ...buttonStyle, backgroundColor: '#aaa', marginTop: '5px' };
const messageStyle = { marginTop: '15px', color: 'green', fontWeight: 'bold'asconst };
const errorStyle = { marginTop: '15px', color: 'red', fontWeight: 'bold'asconst };
const containerStyle = { padding: '20px', fontFamily: 'Arial, sans-serif', maxWidth: '400px', margin: '40px auto', border: '1px solid #eee', borderRadius: '8px', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' };
return (
<divstyle={containerStyle}><h1>Sinch OTP Verification</h1>
{!showOtpForm ? (
<formonSubmit={handleRequestOtp}><h2>Step 1: Request OTP</h2><labelhtmlFor="phoneNumber">Phone Number (E.164 format):</label><inputid="phoneNumber"type="tel"value={phoneNumber}onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="+1234567890"
required
style={inputStyle}
/>
<buttontype="submit"disabled={isLoading}style={isLoading ? disabledButtonStyle:buttonStyle}>
{isLoading ? 'Sending...' : 'Send OTP'}
</button></form>
) : (
<formonSubmit={handleVerifyOtp}><h2>Step 2: Verify OTP</h2><p>Enter the OTP sent to {phoneNumber}</p><labelhtmlFor="otp">One-Time Password:</label><inputid="otp"type="text"value={otp}onChange={(e) => setOtp(e.target.value)}
placeholder="123456"
required
maxLength={6}
pattern="\d{6}"
style={inputStyle}
/>
<buttontype="submit"disabled={isLoading}style={isLoading ? disabledButtonStyle:buttonStyle}>
{isLoading ? 'Verifying...' : 'Verify OTP'}
</button><buttontype="button"onClick={() => setShowOtpForm(false)} style={secondaryButtonStyle} disabled={isLoading}>
Change Phone Number
</button></form>
)}
{message && <pstyle={messageStyle}>{message}</p>}
{error && <pstyle={errorStyle}>{error}</p>}
</div>
);
}
'use client': Necessary for using hooks (useState) and event handlers in Next.js App Router components.
State: Manages phone number, OTP input, loading status, messages, errors, and form visibility.
API URL: Reads the backend URL from the environment variable. Includes a check.
handleRequestOtp: Sends the phone number to the backend /api/otp/request endpoint. Handles success and error responses, updating the UI state. Shows the OTP form on success.
handleVerifyOtp: Sends the phone number and OTP to the backend /api/otp/verify endpoint. Handles success (clears form, shows success message, TODO for next steps) and errors.
Error Handling: Uses try...catch and attempts to parse error messages from the backend response (err.response?.data?.error).
Basic Form Structure: Provides simple HTML forms with input fields and submit buttons. Includes basic inline styling for demonstration.
Conditional Rendering: Shows either the phone number input form or the OTP input form based on the showOtpForm state.
Run the Frontend:
Bash
npm run dev
Open your browser to http://localhost:3000. You should see the OTP verification form.
Test the Complete Flow:
Enter your phone number in E.164 format (e.g., +1234567890).
Click "Send OTP" - you should receive an SMS with the OTP code.
Enter the received OTP code in the verification form.
Click "Verify OTP" - you should see a success message if the code is correct.
Congratulations! You now have a working SMS OTP verification system using Sinch, Node.js, and Next.js. This implementation provides a solid foundation for building secure phone-based authentication into your applications.
Frequently asked questions
Implement Sinch 2FA by setting up a Node.js/Express backend to handle OTP generation and sending via Sinch's Verification API. Then create a Next.js frontend to capture phone numbers, request OTPs, and allow users to submit the received OTP for verification against the backend.
The Sinch Verification API is used to send SMS-based OTPs for two-factor authentication (2FA). This enhances security during user sign-up, login, or transaction approvals by verifying phone numbers.
Redis is used as a temporary, secure, and efficient store for the generated OTPs. Its in-memory nature ensures fast retrieval and verification, while the expiry mechanism enhances security by automatically deleting OTPs after a set time (e.g., 5 minutes).
Use the @sinch/verification SDK when integrating with the newer Sinch Verification API product, which is distinct from older, general SMS APIs. Ensure your project has @sinch/sdk-core installed as a dependency as well.
Use Node.js's built-in crypto.randomInt function to generate cryptographically secure random integers for OTPs. Ensure the generated OTP is padded to maintain the desired length, such as 6 digits.
Dotenv loads environment variables from a .env file. This is crucial for storing sensitive API keys (Sinch, Redis) and configuration data securely, preventing them from being exposed in your codebase.
Cross-Origin Resource Sharing (CORS) is essential for communication between the Next.js frontend (running on a different port/domain in development) and the Node.js backend. Correctly configuring CORS allows secure data exchange between the two.
Express-rate-limit provides basic rate limiting to prevent abuse of your API endpoints (OTP request and verification). This protects against brute-force attacks by limiting the number of requests from a specific IP address within a given time window.
In your backend, retrieve the stored OTP from Redis using the user's phone number as a key. Compare the submitted OTP against the retrieved value using simple equality. If they match and are not expired, the OTP is valid. Delete the OTP from Redis upon successful verification to prevent reuse.
Yes, you can often customize SMS templates directly within the Sinch dashboard, though this might depend on your Sinch plan and settings. The specific way to modify content in the API request itself may vary based on the SDK and Sinch's configuration.
Successful OTP verification doesn't inherently log a user in. You need to implement session management. This involves actions like finding/creating a user in your database, marking their number as verified, and issuing a session token (e.g., JWT) or cookie, often using tools like NextAuth.js or express-session.
E.164 is an international standard for phone number formatting. It ensures consistent representation of phone numbers, typically starting with a '+' and the country code (e.g., +1 for USA, +44 for UK), followed by the subscriber number, with no spaces or special characters. It's highly recommended to use this format in your application.
Graceful shutdown allows your Node.js server to finish processing existing requests and close connections (HTTP server, database clients like Redis) properly before exiting. This prevents data loss or abrupt interruptions during deployments or server restarts. In this setup, SIGINT (Ctrl+C) and SIGTERM signals trigger this shutdown sequence.
This comprehensive guide walks you through implementing secure phone number verification and SMS-based two-factor authentication (2FA) using Sinch's Verification API. You'll learn how to build a production-ready system with a Next.js frontend and Node.js (Express) backend that handles OTP generation, SMS delivery via Sinch, and secure verification.
SMS OTP authentication is essential for verifying user phone numbers during sign-up, securing login flows with an additional authentication factor, and confirming high-value transactions. By the end of this tutorial, you'll have a fully functional passwordless authentication system that enhances security while maintaining excellent user experience.
Project Overview and Goals
What We'll Build:
cryptomodule.Problem Solved: Securely verify user phone numbers and implement multi-factor authentication (MFA) using SMS one-time passwords. This implementation leverages Sinch's reliable global SMS delivery infrastructure and follows modern security best practices for OTP verification systems.
Technologies Used:
ioredis(note:ioredisis maintained on best-effort basis; for new projects in 2025, considernode-redisas the officially recommended client).@sinch/verification, which targets the modern Sinch Verification API product.Sources: Next.js 15 release notes (nextjs.org); Sinch SDK GA announcement (May 2024); ioredis deprecation notice (redis.io/docs).
System Architecture:
Prerequisites:
Source: Node.js release schedule (nodejs.org); Node.js 18 EOL confirmed April 30, 2025.
Final Outcome: A functional application where users can enter their phone number, receive an SMS OTP via Sinch, and verify that OTP to gain access or confirm an action.
1. Setting Up the Backend (Node.js/Express)
We'll start by creating the backend application that will handle the core logic for OTP generation and verification. This backend API will manage secure random code generation, temporary storage in Redis, and integration with Sinch's SMS delivery service.
Steps:
Create Project Directory:
mkdir sinch-otp-backend cd sinch-otp-backendInitialize Node.js Project:
Install Dependencies:
express: Web framework.dotenv: Loads environment variables.ioredis: Redis client.@sinch/sdk-core,@sinch/verification: Sinch SDKs for the Verification API.cors: Enables Cross-Origin Resource Sharing (needed for frontend communication).express-rate-limit: Basic rate limiting to prevent abuse.Create
.envFile: Create a file named.envin thesinch-otp-backendroot directory. This file will store sensitive credentials and configuration. Never commit this file to version control.PORT: The port your backend server will run on.REDIS_URL: The connection string for your Redis instance.SINCH_KEY_ID,SINCH_KEY_SECRET: Found in your Sinch Dashboard under Settings > API Credentials. Treat these like passwords.SINCH_PROJECT_ID: Your Sinch Project ID, usually visible in the dashboard URL or project settings.FRONTEND_URL: The URL of your Next.js frontend, used for CORS configuration.Create Basic Server File (
server.js): Create a file namedserver.jsin the root directory.// server.js require('dotenv').config(); // Load environment variables first const express = require('express'); const Redis = require('ioredis'); const cors = require('cors'); const rateLimit = require('express-rate-limit'); const crypto = require('crypto'); // For secure OTP generation const Sinch = require('@sinch/sdk-core'); // Core SDK const { SmsVerification } = require('@sinch/verification'); // Verification specific part // --- Configuration --- const PORT = process.env.PORT || 4000; const OTP_EXPIRY_SECONDS = 300; // 5 minutes // Note: NIST SP 800-63B recommends OTPs change at least once every 2 minutes (120s). // This implementation uses 5 minutes for improved user experience, but consider // reducing to 120-180 seconds for stricter security compliance. // Source: NIST Special Publication 800-63B (Digital Identity Guidelines). // --- Initialization --- const app = express(); // Initialize Redis Client const redisClient = new Redis(process.env.REDIS_URL, { // Optional: Add error handling for Redis connection retryStrategy(times) { const delay = Math.min(times * 50, 2000); // Exponential backoff console.warn(`Redis connection failed, retrying in ${delay}ms... (Attempt ${times})`); return delay; }, maxRetriesPerRequest: 3, // Limit retries per command }); redisClient.on('error', (err) => { console.error('Redis Client Error:', err); // Implement more robust error handling/alerting in production }); redisClient.on('connect', () => { console.log('Connected to Redis'); }); // Initialize Sinch Client if (!process.env.SINCH_KEY_ID || !process.env.SINCH_KEY_SECRET || !process.env.SINCH_PROJECT_ID) { console.error('FATAL ERROR: Missing Sinch API credentials in .env file.'); process.exit(1); // Exit if credentials are missing } const sinchCredentials = { projectId: process.env.SINCH_PROJECT_ID, keyId: process.env.SINCH_KEY_ID, keySecret: process.env.SINCH_KEY_SECRET, }; const sinchClient = new Sinch.SinchClient(sinchCredentials); const verificationService = new SmsVerification(sinchClient); // Initialize the SMS Verification service // --- Middleware --- app.use(express.json()); // Parse JSON request bodies // CORS Configuration const corsOptions = { origin: process.env.FRONTEND_URL, // Allow only your frontend optionsSuccessStatus: 200 // Some legacy browsers choke on 204 }; app.use(cors(corsOptions)); // Rate Limiting (apply to specific routes later for more granularity) const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per windowMs standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers message: 'Too many requests from this IP, please try again after 15 minutes', }); app.use(limiter); // Apply to all routes for now // --- API Routes (To be added in next steps) --- // Placeholder for health check app.get('/health', (req, res) => { res.status(200).json({ status: 'OK', redis: redisClient.status }); }); // --- Start Server --- // Assign the server instance for graceful shutdown const server = app.listen(PORT, () => { console.log(`Backend server running on http://localhost:${PORT}`); }); // Graceful Shutdown Handling const shutdown = () => { console.info('Signal received: closing HTTP server'); server.close(() => { console.log('HTTP server closed'); redisClient.quit(() => { console.log('Redis connection closed'); process.exit(0); }); }); }; process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); // Handle Ctrl+C locallydotenv.config(): Must be called early to load.envvariables.iorediswith the URL from.env. Includes basic retry logic and error handling.SmsVerificationservice using credentials from.env. Includes a check for missing credentials.express.json(): Parses incoming JSON requests.cors(): Enables requests from your frontend URL specified in.env. Crucial for development and production.rateLimit(): Basic protection against brute-force attacks.app.listenreturn value is stored inserver, allowingserver.close()to be called correctly onSIGTERMorSIGINT.Add Start Script to
package.json: Openpackage.jsonand add/ensure you have astartscript:{ "name": "sinch-otp-backend", "version": "1.0.0", "description": "", "main": "server.js", "scripts": { "start": "node server.js", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "@sinch/sdk-core": "...", "@sinch/verification": "...", "cors": "...", "dotenv": "...", "express": "...", "express-rate-limit": "...", "ioredis": "..." } }Initial Run: Make sure your Redis server is running. Then, start the backend:
You should see
Backend server running on http://localhost:4000andConnected to Redisif everything is configured correctly. Accesshttp://localhost:4000/healthin your browser or Postman to check the health endpoint.2. Implementing Core Functionality (Backend API Endpoints)
Now, let's build the REST API endpoints for requesting and verifying OTPs. These endpoints form the backbone of your phone verification system and handle the complete authentication flow from code generation to validation.
Steps:
Create OTP Generation Logic: Use Node.js's built-in
cryptomodule for secure random number generation.Add this function within
server.js, before the API routes:// server.js (add this function) // --- Helper Functions --- function generateOtp(length = 6) { // Generate a cryptographically secure random integer const min = Math.pow(10, length - 1); const max = Math.pow(10, length) - 1; try { // crypto.randomInt is preferred for security-sensitive generation // It generates an integer between min (inclusive) and max + 1 (exclusive) return crypto.randomInt(min, max + 1).toString().padStart(length, '0'); } catch (error) { console.error('FATAL: Error generating secure OTP:', error); // In a real application, you might want to throw or handle this more gracefully // than falling back to Math.random, which is not cryptographically secure. // For this example, we'll throw to highlight the issue. throw new Error('Failed to generate secure OTP.'); } }crypto.randomIntwhich is suitable for security-sensitive values like OTPs.012345instead of12345).Create Request OTP Endpoint (
/api/otp/request): This endpoint receives a phone number, generates an OTP, stores it in Redis, and triggers Sinch to send the SMS.Add this route handler in
server.jsunder the// --- API Routes ---comment:// server.js (add this route) // Rate limiting specific to OTP requests (more strict) const otpRequestLimiter = rateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes max: 5, // Limit each IP to 5 OTP requests per 5 minutes standardHeaders: true, legacyHeaders: false, message: 'Too many OTP requests, please try again later.', }); app.post('/api/otp/request', otpRequestLimiter, async (req, res) => { const { phoneNumber } = req.body; // 1. Validate Input if (!phoneNumber || !/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) { // Basic E.164 format check (adjust regex as needed for stricter validation) return res.status(400).json({ error: 'Invalid phone number format. Use E.164 format (e.g., +1234567890).' }); } try { // 2. Generate OTP const otp = generateOtp(6); // Generate a 6-digit OTP const redisKey = `otp:${phoneNumber}`; // 3. Store OTP in Redis with Expiry // Use SET with EX option for atomicity (Set and Expire) await redisClient.set(redisKey, otp, 'EX', OTP_EXPIRY_SECONDS); console.log(`OTP ${otp} generated for ${phoneNumber}, expires in ${OTP_EXPIRY_SECONDS}s`); // 4. Send OTP via Sinch Verification API console.log(`Attempting to send OTP to ${phoneNumber} via Sinch...`); // --- IMPORTANT: Verify Sinch SDK Payload Structure --- // The exact structure of this request object might change between SDK versions. // Always consult the official @sinch/verification SDK documentation for the // 'startSms' method to ensure the payload format is correct. const startVerificationRequest = { smsVerificationStartRequest: { identity: { type: 'number', endpoint: phoneNumber }, reference: `MyAppVerification_${Date.now()}`, // Optional reference for tracking // You can often customize SMS templates in the Sinch dashboard. // Specifying custom content here might depend on your Sinch plan/settings. } }; // Ensure verificationService is initialized (though we check at startup) if (!verificationService) { console.error('Sinch Verification Service not initialized!'); return res.status(500).json({ error: 'Internal server error (Sinch service unavailable)' }); } const response = await verificationService.startSms(startVerificationRequest); // --- IMPORTANT: Verify Sinch SDK Success Response --- // Check the actual structure of the 'response' object returned by the SDK // in successful cases. The success condition might involve different fields // or checks than just 'response.id'. Log the response for debugging. console.log('Sinch Start SMS Verification Response:', response); // Assuming success if no error is thrown AND a verification ID is present if (response && response.id) { console.log(`Sinch verification initiated successfully for ${phoneNumber}. Verification ID: ${response.id}`); // Optionally return verificationId if needed for reporting later res.status(200).json({ success: true, message: 'OTP sent successfully.', verificationId: response.id }); } else { // Handle cases where Sinch might not throw an error but indicates failure in the response console.error(`Sinch verification initiation failed for ${phoneNumber}, unexpected response:`, response); res.status(500).json({ error: 'Failed to initiate OTP sending via Sinch.' }); } } catch (error) { console.error(`Error requesting OTP for ${phoneNumber}:`, error); // --- Enhanced Error Handling --- // Attempt to parse specific Sinch errors (structure depends on SDK) let errorMessage = 'Failed to send OTP.'; let statusCode = 500; // Check if it looks like a Sinch API error (structure may vary) if (error && typeof error === 'object' && 'response' in error && error.response && error.response.data) { const sinchError = error.response.data.error; errorMessage = `Sinch Error: ${sinchError?.message || 'Unknown Sinch Error'}`; statusCode = error.response.status || 500; console.error('Sinch API Error Details:', sinchError); // Add specific handling based on sinchError.code if needed } else if (error.code === 'CONNECTION_BROKEN' || (error.message && error.message.includes('Redis connection'))) { errorMessage = 'Service temporarily unavailable (Database). Please try again later.'; statusCode = 503; // Service Unavailable } else if (error.message === 'Failed to generate secure OTP.') { errorMessage = 'Internal server error during OTP generation.'; statusCode = 500; } else { // Generic internal error errorMessage = 'Internal server error.'; statusCode = 500; } res.status(statusCode).json({ error: errorMessage }); } });generateOtphelper.otp:+1234567890).EXsets the expiration time atomically.verificationService.startSms. Includes important comments advising verification of the payload structure and success response against current Sinch SDK documentation.response.idindicates success, but requires verification). Includes improved error handling trying to distinguish Sinch API errors from Redis errors or other internal issues.Create Verify OTP Endpoint (
/api/otp/verify): This endpoint receives the phone number and the OTP entered by the user, compares it with the value in Redis, and returns the verification result.Add this route handler in
server.js:// server.js (add this route) // Rate limiting specific to OTP verification attempts const otpVerifyLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, // Limit each IP to 10 verify attempts per 15 minutes (adjust as needed) standardHeaders: true, legacyHeaders: false, message: 'Too many verification attempts, please try again later.', }); app.post('/api/otp/verify', otpVerifyLimiter, async (req, res) => { const { phoneNumber, otp } = req.body; // const { verificationId } = req.body; // Include if reporting verification to Sinch // 1. Validate Input if (!phoneNumber || !/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) { return res.status(400).json({ error: 'Invalid phone number format.' }); } if (!otp || !/^\d{6}$/.test(otp)) { // Assuming 6-digit OTP return res.status(400).json({ error: 'Invalid OTP format. Must be 6 digits.' }); } const redisKey = `otp:${phoneNumber}`; try { // 2. Retrieve OTP from Redis const storedOtp = await redisClient.get(redisKey); // 3. Check if OTP exists (not expired or invalid number/key) if (!storedOtp) { console.warn(`Verification attempt failed for ${phoneNumber}: OTP not found in Redis or expired.`); // Keep error generic for the client return res.status(400).json({ error: 'Invalid or expired OTP.' }); } // 4. Compare submitted OTP with stored OTP (use simple equality) if (storedOtp === otp) { console.log(`OTP verified successfully for ${phoneNumber}`); // Success! OTP matches. Delete from Redis immediately to prevent reuse. await redisClient.del(redisKey); // --- Optional: Report Verification to Sinch --- // This step might be required depending on your Sinch setup/plan for billing or analytics. // It often involves using the verification ID returned from the 'startSms' call. /* if (verificationId) { try { // --- IMPORTANT: Verify Sinch SDK Payload Structure --- // The exact structure for reporting verification might differ. // Consult the official @sinch/verification SDK documentation for the // 'reportSms' method (or equivalent) and its required payload. // The structure below is a hypothetical example. const reportRequest = { reportSmsVerificationRequest: { verificationReportRequest: { sms: { code: otp } } }, id: verificationId // Use the ID from the start request response }; await verificationService.reportSms(reportRequest); console.log(`Reported successful verification to Sinch for ID: ${verificationId}`); } catch(reportError) { // Log the reporting error, but usually don't fail the user's verification flow console.error(`Failed to report verification to Sinch for ${phoneNumber} (ID: ${verificationId}):`, reportError); } } */ // --- End Optional Sinch Report // --- Session Management TODO --- // IMPORTANT: Successful OTP verification alone doesn't log the user in. // You need to implement session management here. This typically involves: // 1. Finding/Creating a user record in your database associated with 'phoneNumber'. // 2. Marking the phone number as verified in the database. // 3. Generating a session token (e.g., JWT) or setting a session cookie. // Consider using libraries like NextAuth.js (frontend/backend) or express-session (backend). // For now, just return success. return res.status(200).json({ success: true, message: 'OTP verified successfully.' }); } else { // Failure: OTP does not match console.warn(`Verification attempt failed for ${phoneNumber}: Incorrect OTP submitted.`); // Optional: Implement attempt tracking in Redis to lock out after N failures for a specific number // Keep error generic for the client return res.status(400).json({ error: 'Invalid or expired OTP.' }); } } catch (error) { console.error(`Error verifying OTP for ${phoneNumber}:`, error); if (error.code === 'CONNECTION_BROKEN' || (error.message && error.message.includes('Redis connection'))) { res.status(503).json({ error: 'Service temporarily unavailable (Database). Please try again later.' }); } else { res.status(500).json({ error: 'Internal server error.' }); } } });redisClient.del) to prevent reuse.Invalid or expired OTP.).Restart Backend: Stop (
Ctrl+C) and restart the backend server (npm start) to load the new routes. Test the endpoints using Postman orcurl:Request OTP:
POST http://localhost:4000/api/otp/requestwith JSON body:{ "phoneNumber": "+1234567890" }(use a real phone number you can receive SMS on). Check your phone for the OTP and the console logs.
Verify OTP:
POST http://localhost:4000/api/otp/verifywith JSON body:{ "phoneNumber": "+1234567890", "otp": "123456" }(replace with the OTP you received). Check the response and logs.
3. Setting Up the Frontend (Next.js)
Now, let's create the Next.js application that provides the user interface for SMS authentication. This frontend will handle phone number input, OTP request submission, and verification code entry with real-time feedback.
Steps:
Create Next.js App: Navigate outside your backend directory and run:
npx create-next-app@latest sinch-otp-frontend # Choose options: TypeScript? (Yes), ESLint? (Yes), Tailwind? (No - for simplicity), `src/` dir? (Yes/No), App Router? (Yes - recommended), Import alias? (@/*) cd sinch-otp-frontendInstall Dependencies:
npm install axios # Or use built-in fetchCreate
.env.localFile: In thesinch-otp-frontendroot, create.env.local. This is for client-side environment variables.NEXT_PUBLIC_API_BASE_URL: The base URL for your backend API. TheNEXT_PUBLIC_prefix makes it available in the browser.Modify the Home Page (
app/page.tsxorpages/index.tsx): Replace the content of your main page file with a basic structure containing forms for requesting and verifying OTPs.(Example using App Router
app/page.tsxanduseState)// app/page.tsx 'use client'; // Required for useState and event handlers in App Router import { useState, FormEvent } from 'react'; import axios from 'axios'; // Or use fetch const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL; export default function HomePage() { const [phoneNumber, setPhoneNumber] = useState(''); const [otp, setOtp] = useState(''); const [isLoading, setIsLoading] = useState(false); const [message, setMessage] = useState(''); const [error, setError] = useState(''); const [showOtpForm, setShowOtpForm] = useState(false); // Optional: Store verification ID if needed for reporting back to Sinch // const [verificationId, setVerificationId] = useState<string | null>(null); const handleRequestOtp = async (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); setIsLoading(true); setMessage(''); setError(''); if (!API_BASE_URL) { setError('API URL not configured. Check .env.local.'); setIsLoading(false); return; } try { console.log(`Requesting OTP for ${phoneNumber} at ${API_BASE_URL}/otp/request`); const response = await axios.post(`${API_BASE_URL}/otp/request`, { phoneNumber, }); // Handle success if (response.data.success) { setMessage('OTP sent successfully! Please check your phone.'); setShowOtpForm(true); // Store verificationId if backend sends it and you need it later // if (response.data.verificationId) { // setVerificationId(response.data.verificationId); // } } else { // This case might not be reached if backend uses proper HTTP status codes for errors setError(response.data.error || 'Failed to request OTP.'); } } catch (err: any) { console.error('Request OTP error:', err); // Extract error message from backend response if available const errorMsg = err.response?.data?.error || err.message || 'An unexpected error occurred.'; setError(`Error: ${errorMsg}`); setShowOtpForm(false); // Hide OTP form on error } finally { setIsLoading(false); } }; const handleVerifyOtp = async (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); setIsLoading(true); setMessage(''); setError(''); if (!API_BASE_URL) { setError('API URL not configured. Check .env.local.'); setIsLoading(false); return; } try { console.log(`Verifying OTP ${otp} for ${phoneNumber} at ${API_BASE_URL}/otp/verify`); const response = await axios.post(`${API_BASE_URL}/otp/verify`, { phoneNumber, otp, // Include verificationId if you stored it and need to send it for reporting // verificationId: verificationId }); if (response.data.success) { setMessage('Phone number verified successfully!'); setShowOtpForm(false); // Hide OTP form after success setPhoneNumber(''); // Optionally clear forms setOtp(''); // TODO: Redirect user or update application state (e.g., set logged-in status) // This is where you'd typically handle the post-verification logic (session, redirect etc.) } else { // This case might not be reached if backend uses proper HTTP status codes for errors setError(response.data.error || 'Failed to verify OTP.'); } } catch (err: any) { // Error handling logic now correctly inside the catch block console.error('Verify OTP error:', err); const errorMsg = err.response?.data?.error || err.message || 'An unexpected error occurred.'; setError(`Error: ${errorMsg}`); } finally { setIsLoading(false); } }; // Basic Styling (inline for simplicity, use CSS modules or Tailwind in production) const inputStyle = { padding: '10px', margin: '8px 0', border: '1px solid #ccc', borderRadius: '4px', width: '100%', boxSizing: 'border-box' as const }; const buttonStyle = { padding: '12px 20px', margin: '10px 0', cursor: 'pointer', backgroundColor: '#0070f3', color: 'white', border: 'none', borderRadius: '4px', width: '100%', fontSize: '16px' }; const disabledButtonStyle = { ...buttonStyle, backgroundColor: '#ccc', cursor: 'not-allowed' }; const secondaryButtonStyle = { ...buttonStyle, backgroundColor: '#aaa', marginTop: '5px' }; const messageStyle = { marginTop: '15px', color: 'green', fontWeight: 'bold' as const }; const errorStyle = { marginTop: '15px', color: 'red', fontWeight: 'bold' as const }; const containerStyle = { padding: '20px', fontFamily: 'Arial, sans-serif', maxWidth: '400px', margin: '40px auto', border: '1px solid #eee', borderRadius: '8px', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }; return ( <div style={containerStyle}> <h1>Sinch OTP Verification</h1> {!showOtpForm ? ( <form onSubmit={handleRequestOtp}> <h2>Step 1: Request OTP</h2> <label htmlFor="phoneNumber">Phone Number (E.164 format):</label> <input id="phoneNumber" type="tel" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder="+1234567890" required style={inputStyle} /> <button type="submit" disabled={isLoading} style={isLoading ? disabledButtonStyle : buttonStyle}> {isLoading ? 'Sending...' : 'Send OTP'} </button> </form> ) : ( <form onSubmit={handleVerifyOtp}> <h2>Step 2: Verify OTP</h2> <p>Enter the OTP sent to {phoneNumber}</p> <label htmlFor="otp">One-Time Password:</label> <input id="otp" type="text" value={otp} onChange={(e) => setOtp(e.target.value)} placeholder="123456" required maxLength={6} pattern="\d{6}" style={inputStyle} /> <button type="submit" disabled={isLoading} style={isLoading ? disabledButtonStyle : buttonStyle}> {isLoading ? 'Verifying...' : 'Verify OTP'} </button> <button type="button" onClick={() => setShowOtpForm(false)} style={secondaryButtonStyle} disabled={isLoading}> Change Phone Number </button> </form> )} {message && <p style={messageStyle}>{message}</p>} {error && <p style={errorStyle}>{error}</p>} </div> ); }'use client': Necessary for using hooks (useState) and event handlers in Next.js App Router components.handleRequestOtp: Sends the phone number to the backend/api/otp/requestendpoint. Handles success and error responses, updating the UI state. Shows the OTP form on success.handleVerifyOtp: Sends the phone number and OTP to the backend/api/otp/verifyendpoint. Handles success (clears form, shows success message, TODO for next steps) and errors.try...catchand attempts to parse error messages from the backend response (err.response?.data?.error).showOtpFormstate.Run the Frontend:
Open your browser to
http://localhost:3000. You should see the OTP verification form.Test the Complete Flow:
+1234567890).Congratulations! You now have a working SMS OTP verification system using Sinch, Node.js, and Next.js. This implementation provides a solid foundation for building secure phone-based authentication into your applications.