code examples
code examples
SMS OTP Authentication Tutorial: Build Two-Factor Authentication with Sinch, Node.js & React
Step-by-step guide to building secure SMS OTP two-factor authentication using Sinch Verification API, Node.js/Express backend, and React with Vite. Production-ready code examples included.
How to Build SMS OTP Authentication with Sinch, Node.js, and React: Complete 2FA Tutorial
Meta Description: Step-by-step guide to building secure SMS OTP two-factor authentication using Sinch Verification API, Node.js/Express backend, and React with Vite. Production-ready code examples included.
Learn how to implement SMS-based One-Time Password (OTP) authentication in a modern web application using Sinch's Verification API, Node.js backend, and React with Vite. This comprehensive tutorial walks you through building a complete two-factor authentication (2FA) system from scratch, covering backend OTP generation, frontend verification flow, security best practices, and production deployment considerations.
What you'll learn in this SMS OTP authentication tutorial: Setting up Sinch SMS verification API, creating Express API endpoints for OTP delivery and validation, building a React component for phone number input and OTP verification, implementing rate limiting and security measures for 2FA, handling edge cases and errors in OTP workflows, and deploying your OTP authentication system to production with Node.js and React.
Technology Requirements:
- Node.js: v18 LTS or v20 LTS
- React: v18.x
- Vite: v5.x (with Hot Module Replacement for fast development)
- Express: v4.x
- @sinch/verification: Latest SDK version from npm
- express-rate-limit: v7.x for API protection
- axios: v1.x for HTTP requests
Security Note: OTP codes should be 6 digits minimum and expire within 5 – 10 minutes. Always validate phone numbers in E.164 format (international standard with country code prefix, e.g., +1234567890) for reliable global SMS delivery.
Why SMS OTP Provides Secure Two-Factor Authentication
SMS-based OTP authentication adds a critical security layer to your application by verifying user identity through something they possess – their mobile phone. Unlike passwords alone, which can be compromised through phishing or data breaches, SMS OTP combines "something you know" (username/password) with "something you have" (mobile device), significantly reducing unauthorized access risk.
Two-factor authentication via SMS provides these key security benefits:
- Possession-Based Verification: Confirms users control the registered phone number
- Time-Limited Security: OTP codes expire within 5 – 10 minutes, reducing replay attack risk
- Easy Implementation: SMS works on all mobile devices without requiring app installation
- Universal Compatibility: Reaches users globally across 190+ countries
- Fraud Prevention: Significantly reduces account takeover and unauthorized access attempts
What You'll Build in This OTP Authentication Tutorial
You'll create a complete OTP authentication system with these components:
- Backend API (Node.js + Express): Handles OTP generation, SMS delivery through Sinch, and verification logic
- Frontend Interface (React + Vite): Provides a user-friendly form for phone number entry and OTP code input
- Security Layer: Implements rate limiting, input validation, and error handling
- Production-Ready Features: Includes logging, environment configuration, and deployment guidelines
By the end, you'll have a working authentication system you can integrate into your application or customize for specific use cases like WhatsApp integration or bulk messaging campaigns.
How Does Sinch SMS Verification Work?
Sinch's Verification API simplifies SMS OTP implementation by handling the complexities of international SMS delivery, carrier routing, and message templates. Here's the authentication flow:
- User Initiates Verification: Enter their phone number in your application
- Backend Requests OTP: Your server calls Sinch API to generate and send a verification code
- Sinch Delivers SMS: Sinch routes the message through optimal carrier networks globally
- User Receives Code: SMS arrives on their mobile device within seconds
- User Submits Code: Enter the received OTP in your application
- Backend Verifies Code: Your server validates the code with Sinch API
- Authentication Complete: Grant access upon successful verification
This flow abstracts away SMS delivery complexity, allowing you to focus on your application logic rather than telecommunications infrastructure.
How to Set Up Your Sinch Account for SMS OTP
Before writing code, you'll need Sinch API credentials for SMS verification. Follow these steps:
Step 1: Create a Sinch Account
Visit the Sinch Dashboard and sign up for a free account.
Step 2: Access Verification Product
Navigate to the "Verification" section in your dashboard.
Step 3: Get API Credentials
Locate your Application Key and Application Secret – you'll need these for authentication.
Step 4: Note Your Service Plan ID
This identifies your verification configuration (optional but recommended for production).
Security Best Practice: Never commit credentials to version control. You'll store these in environment variables in the next section.
How to Build the Backend Node.js OTP Verification API
Create a new backend project with these steps:
Step 1: Initialize Node.js Project
mkdir otp-backend
cd otp-backend
npm init -yStep 2: Install Dependencies
# Install necessary dependencies with version constraints
npm install express@^4.0.0 cors@^2.0.0 dotenv@^16.0.0 @sinch/verification express-rate-limit@^7.0.0Package Explanation:
- express: Web framework for building REST API endpoints
- cors: Enable cross-origin requests from your React frontend
- dotenv: Load environment variables from .env file
- @sinch/verification: Official Sinch SDK for Node.js
- express-rate-limit: Prevent abuse by limiting request frequency (15-minute window, 10 requests per IP recommended)
Step 3: Configure Environment Variables
Create a .env file in your backend root directory:
# .env
SINCH_APPLICATION_KEY=your_application_key_here
SINCH_APPLICATION_SECRET=your_application_secret_here
PORT=5000Replace placeholder values with your actual Sinch credentials from the dashboard. Important: Add .env to your .gitignore file to prevent committing secrets to version control.
Step 4: Create Express Server with OTP Endpoints
Create server.js:
// server.js
const express = require("express");
const cors = require("cors");
const rateLimit = require("express-rate-limit");
const { Verification } = require("@sinch/verification");
require("dotenv").config();
const app = express();
// Middleware configuration
app.use(cors()); // Enable cross-origin requests
app.use(express.json()); // Parse JSON request bodies
// Initialize Sinch Verification client with credentials
const sinchClient = new Verification({
applicationKey: process.env.SINCH_APPLICATION_KEY,
applicationSecret: process.env.SINCH_APPLICATION_SECRET,
});
// Rate limiting: Prevent abuse by limiting OTP requests
// Configure 15-minute window with 10 requests per IP
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Maximum 10 requests per window
message: "Too many OTP requests from this IP. Please try again later.",
});
app.use("/api/otp", limiter); // Apply rate limiting to all OTP endpoints
/**
* POST /api/otp/start
* Initiates OTP verification by sending SMS to the provided phone number
*
* Request body:
* {
* "phoneNumber": "+1234567890" // E.164 format required
* }
*/
app.post("/api/otp/start", async (req, res) => {
const { phoneNumber } = req.body;
// Validate phone number presence
if (!phoneNumber) {
return res.status(400).json({
error: "Phone number is required",
message: "Please provide a valid phone number in E.164 format (e.g., +1234567890)"
});
}
try {
// Request OTP from Sinch – SMS will be sent automatically
const response = await sinchClient.start({
method: "sms", // Use SMS delivery method
identity: {
type: "number",
endpoint: phoneNumber,
},
});
console.log("OTP request successful:", response.id);
// Return verification ID to frontend for the confirmation step
res.json({
success: true,
verificationId: response.id,
message: "OTP sent successfully. Check your phone for the code.",
});
} catch (error) {
console.error("Error sending OTP:", error.message);
// Handle specific Sinch API errors
if (error.statusCode === 400) {
return res.status(400).json({
error: "Invalid phone number format",
message: "Please ensure your phone number is in E.164 format (e.g., +1234567890)",
});
}
res.status(500).json({
error: "Failed to send OTP",
message: "An error occurred while processing your request. Please try again.",
});
}
});
/**
* POST /api/otp/verify
* Verifies the OTP code entered by the user
*
* Request body:
* {
* "phoneNumber": "+1234567890",
* "code": "123456"
* }
*/
app.post("/api/otp/verify", async (req, res) => {
const { phoneNumber, code } = req.body;
// Validate required fields
if (!phoneNumber || !code) {
return res.status(400).json({
error: "Missing required fields",
message: "Both phone number and verification code are required",
});
}
try {
// Verify OTP code with Sinch
const response = await sinchClient.report({
method: "sms",
identity: {
type: "number",
endpoint: phoneNumber,
},
code: code,
});
console.log("OTP verification result:", response.status);
// Check verification status
if (response.status === "SUCCESSFUL") {
res.json({
success: true,
message: "Phone number verified successfully!",
});
} else {
res.status(400).json({
success: false,
error: "Invalid verification code",
message: "The code you entered is incorrect or has expired. Please try again.",
});
}
} catch (error) {
console.error("Error verifying OTP:", error.message);
res.status(500).json({
error: "Verification failed",
message: "An error occurred while verifying your code. Please try again.",
});
}
});
// Start the Express server
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`OTP Backend server running on http://localhost:${PORT}`);
});Code Highlights:
- Rate Limiting: Configured with 15-minute window and 10-request limit per IP to prevent abuse
- E.164 Validation: Ensures phone numbers include country code prefix for international SMS delivery
- Error Handling: Provides specific error messages for common issues (invalid format, expired codes)
- Logging: Console logs help debug verification flow during development
Step 5: Test Your Backend API
Start the server:
node server.jsYou should see: OTP Backend server running on http://localhost:5000
Test the /api/otp/start endpoint using curl or a tool like Postman:
curl -X POST http://localhost:5000/api/otp/start \
-H "Content-Type: application/json" \
-d '{"phoneNumber": "+1234567890"}'Replace +1234567890 with your actual phone number in E.164 format. You should receive an SMS with your OTP code within seconds.
How to Build the Frontend React OTP Verification Component
Create a React application with Vite for fast development and hot module replacement:
Step 1: Initialize React Project with Vite
npm create vite@latest otp-frontend -- --template react
cd otp-frontend
npm installStep 2: Install HTTP Client
npm install axios@^1.0.0Step 3: Create OTP Component
Replace the contents of src/App.jsx with this complete OTP verification component:
// src/App.jsx
import { useState } from "react";
import axios from "axios";
import "./App.css";
function App() {
// State management for the two-step verification flow
const [phoneNumber, setPhoneNumber] = useState("");
const [otpCode, setOtpCode] = useState("");
const [step, setStep] = useState(1); // Step 1: Phone input, Step 2: OTP verification
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
// Backend API endpoint – update if your backend runs on a different port
const API_BASE_URL = "http://localhost:5000";
/**
* Handle phone number submission and OTP request
*/
const handleSendOtp = async (e) => {
e.preventDefault();
setLoading(true);
setMessage("");
setError("");
try {
const response = await axios.post(`${API_BASE_URL}/api/otp/start`, {
phoneNumber,
});
if (response.data.success) {
setMessage(response.data.message);
setStep(2); // Move to OTP entry step
}
} catch (err) {
setError(
err.response?.data?.message || "Failed to send OTP. Please try again."
);
} finally {
setLoading(false);
}
};
/**
* Handle OTP code verification
*/
const handleVerifyOtp = async (e) => {
e.preventDefault();
setLoading(true);
setMessage("");
setError("");
try {
const response = await axios.post(`${API_BASE_URL}/api/otp/verify`, {
phoneNumber,
code: otpCode,
});
if (response.data.success) {
setMessage("✓ Verification successful! You are now authenticated.");
// Here you would typically:
// 1. Store authentication token
// 2. Redirect to dashboard
// 3. Update user session
}
} catch (err) {
setError(
err.response?.data?.message || "Verification failed. Please check your code and try again."
);
} finally {
setLoading(false);
}
};
/**
* Reset the form to start over
*/
const handleReset = () => {
setPhoneNumber("");
setOtpCode("");
setStep(1);
setMessage("");
setError("");
};
return (
<div className="App">
<div className="container">
<h1>SMS OTP Verification</h1>
<p className="subtitle">
Secure two-factor authentication with Sinch
</p>
{/* Step 1: Phone Number Input */}
{step === 1 && (
<form onSubmit={handleSendOtp} className="form">
<div className="form-group">
<label htmlFor="phoneNumber">Phone Number</label>
<input
type="tel"
id="phoneNumber"
placeholder="+1234567890"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
required
disabled={loading}
/>
<small>Enter your phone number in E.164 format (include country code)</small>
</div>
<button type="submit" disabled={loading} className="btn-primary">
{loading ? "Sending..." : "Send OTP"}
</button>
</form>
)}
{/* Step 2: OTP Code Verification */}
{step === 2 && (
<form onSubmit={handleVerifyOtp} className="form">
<div className="form-group">
<label htmlFor="otpCode">Verification Code</label>
<input
type="text"
id="otpCode"
placeholder="Enter 6-digit code"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value)}
required
disabled={loading}
maxLength="6"
/>
<small>Check your phone for the verification code (expires in 5 – 10 minutes)</small>
</div>
<button type="submit" disabled={loading} className="btn-primary">
{loading ? "Verifying..." : "Verify Code"}
</button>
<button
type="button"
onClick={handleReset}
disabled={loading}
className="btn-secondary"
>
Start Over
</button>
</form>
)}
{/* Success Message */}
{message && <div className="alert alert-success">{message}</div>}
{/* Error Message */}
{error && <div className="alert alert-error">{error}</div>}
</div>
</div>
);
}
export default App;Component Highlights:
- Two-Step Flow: Separate UI for phone entry and code verification
- Loading States: Disable inputs during API requests to prevent duplicate submissions
- Error Handling: Display user-friendly error messages for common issues
- E.164 Format: Placeholder text and helper text guide users to enter correct format
- Accessibility: Proper labels, IDs, and semantic HTML elements
Step 4: Style Your Component
Update src/App.css with modern, accessible styles:
/* src/App.css */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.App {
width: 100%;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
padding: 40px;
max-width: 450px;
margin: 0 auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
text-align: center;
}
.subtitle {
color: #666;
text-align: center;
margin-bottom: 30px;
font-size: 14px;
}
.form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
label {
font-weight: 600;
color: #333;
font-size: 14px;
}
input {
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
input:focus {
outline: none;
border-color: #667eea;
}
input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
small {
color: #666;
font-size: 12px;
}
.btn-primary,
.btn-secondary {
padding: 14px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: white;
color: #667eea;
border: 2px solid #667eea;
margin-top: 10px;
}
.btn-secondary:hover:not(:disabled) {
background: #f8f9ff;
}
.alert {
padding: 12px;
border-radius: 8px;
font-size: 14px;
margin-top: 20px;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
/* Responsive design */
@media (max-width: 500px) {
.container {
padding: 30px 20px;
}
h1 {
font-size: 24px;
}
}Step 5: Run Your React Application
Start the Vite development server:
npm run devOpen your browser to http://localhost:5173 (Vite's default port). You should see your OTP verification interface.
How to Test Your Complete OTP Authentication System
Test the end-to-end flow with these steps:
-
Start Backend Server:
bashcd otp-backend node server.js -
Start Frontend Server (in a new terminal):
bashcd otp-frontend npm run dev -
Test Verification Flow:
- Open http://localhost:5173 in your browser
- Enter your phone number in E.164 format (e.g., +1234567890)
- Click "Send OTP"
- Check your phone for the SMS message
- Enter the 6-digit code in the verification form
- Click "Verify Code"
- See the success message confirming verification
-
Test Error Cases:
- Enter invalid phone format (e.g., without +) – should show format error
- Enter wrong OTP code – should show verification failure
- Try sending multiple OTPs rapidly – should trigger rate limiting after 10 requests
Production Security Best Practices for SMS OTP Authentication
Implement these security measures before deploying SMS OTP authentication to production:
1. Rate Limiting Configuration
The tutorial implements basic rate limiting (10 requests per 15 minutes). Enhance this for production:
// Enhanced rate limiting with different limits for different endpoints
const sendOtpLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 3, // Maximum 3 OTP requests per phone number per window
keyGenerator: (req) => req.body.phoneNumber, // Limit per phone number, not just IP
message: "Too many OTP requests for this phone number. Please wait before trying again.",
});
const verifyOtpLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Allow 5 verification attempts per phone number
keyGenerator: (req) => req.body.phoneNumber,
message: "Too many verification attempts. Please request a new code.",
});
app.post("/api/otp/start", sendOtpLimiter, async (req, res) => {
// ... existing code ...
});
app.post("/api/otp/verify", verifyOtpLimiter, async (req, res) => {
// ... existing code ...
});2. Phone Number Validation
Add robust validation using a library like libphonenumber-js:
npm install libphonenumber-jsconst { parsePhoneNumber, isValidPhoneNumber } = require("libphonenumber-js");
app.post("/api/otp/start", async (req, res) => {
const { phoneNumber } = req.body;
// Validate phone number format
if (!isValidPhoneNumber(phoneNumber)) {
return res.status(400).json({
error: "Invalid phone number",
message: "Please provide a valid phone number with country code (e.g., +1234567890)",
});
}
// Parse to ensure E.164 format
const parsedNumber = parsePhoneNumber(phoneNumber);
const formattedNumber = parsedNumber.format("E.164");
// Continue with OTP sending using formattedNumber
// ... existing code ...
});3. Environment-Specific Configuration
Use different configurations for development, staging, and production:
// config.js
module.exports = {
development: {
otpExpiry: 10 * 60 * 1000, // 10 minutes
rateLimitWindow: 15 * 60 * 1000,
rateLimitMax: 10,
},
production: {
otpExpiry: 5 * 60 * 1000, // 5 minutes for better security
rateLimitWindow: 15 * 60 * 1000,
rateLimitMax: 3, // Stricter limits in production
},
};
const config = require("./config")[process.env.NODE_ENV || "development"];4. Logging and Monitoring
Implement comprehensive logging for security auditing:
// Use a proper logging library like Winston
const winston = require("winston");
const logger = winston.createLogger({
level: "info",
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: "error.log", level: "error" }),
new winston.transports.File({ filename: "combined.log" }),
],
});
app.post("/api/otp/start", async (req, res) => {
const { phoneNumber } = req.body;
logger.info("OTP request initiated", {
phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, "*"), // Mask phone number
ip: req.ip,
timestamp: new Date().toISOString(),
});
// ... existing code ...
});5. HTTPS and CORS Configuration
Configure CORS properly for production:
// Production CORS configuration
const corsOptions = {
origin: process.env.FRONTEND_URL || "http://localhost:5173",
methods: ["POST"], // Only allow POST requests
allowedHeaders: ["Content-Type"],
credentials: true,
};
app.use(cors(corsOptions));Important: Always use HTTPS in production to encrypt credentials and OTP codes in transit.
How to Handle Edge Cases and Errors in OTP Verification
Account for these common scenarios in your SMS OTP authentication system:
1. Expired OTP Codes
Sinch automatically handles OTP expiration (typically 5 – 10 minutes). Provide clear error messages:
if (response.status === "EXPIRED") {
return res.status(400).json({
error: "Code expired",
message: "Your verification code has expired. Please request a new one.",
});
}2. SMS Delivery Failures
Handle cases where SMS cannot be delivered:
try {
const response = await sinchClient.start({
method: "sms",
identity: {
type: "number",
endpoint: phoneNumber,
},
});
} catch (error) {
if (error.statusCode === 403) {
return res.status(403).json({
error: "Delivery not available",
message: "SMS delivery is not available for this phone number or region.",
});
}
// Log error for monitoring
logger.error("SMS delivery failed", {
phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, "*"),
error: error.message,
});
// Generic error to user
return res.status(500).json({
error: "Delivery failed",
message: "We couldn't send the verification code. Please try again or contact support.",
});
}3. Network Timeouts
Add timeout handling for API requests:
// Frontend: Configure axios with timeout
const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 10000, // 10 second timeout
});
apiClient.post("/api/otp/start", { phoneNumber })
.catch(error => {
if (error.code === "ECONNABORTED") {
setError("Request timed out. Please check your internet connection and try again.");
} else {
setError(error.response?.data?.message || "An unexpected error occurred.");
}
});4. Multiple Simultaneous Requests
Prevent users from clicking "Send OTP" multiple times:
// Frontend: Disable button and add cooldown
const [cooldown, setCooldown] = useState(0);
const handleSendOtp = async (e) => {
e.preventDefault();
if (cooldown > 0) {
return; // Ignore if still in cooldown
}
setLoading(true);
try {
// ... send OTP ...
// Start 60-second cooldown
setCooldown(60);
const interval = setInterval(() => {
setCooldown(prev => {
if (prev <= 1) {
clearInterval(interval);
return 0;
}
return prev - 1;
});
}, 1000);
} finally {
setLoading(false);
}
};
// Update button text
<button disabled={loading || cooldown > 0}>
{cooldown > 0 ? `Wait ${cooldown}s` : loading ? "Sending..." : "Send OTP"}
</button>How to Integrate OTP Verification with User Sessions
After successful OTP verification, you'll typically want to:
1. Generate Authentication Token
const jwt = require("jsonwebtoken");
app.post("/api/otp/verify", async (req, res) => {
// ... verify OTP ...
if (response.status === "SUCCESSFUL") {
// Generate JWT token for authenticated session
const token = jwt.sign(
{ phoneNumber, verified: true },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
);
res.json({
success: true,
message: "Verification successful",
token, // Send token to frontend
});
}
});2. Store Token in Frontend
// Frontend: Store token after verification
const handleVerifyOtp = async (e) => {
e.preventDefault();
try {
const response = await axios.post(`${API_BASE_URL}/api/otp/verify`, {
phoneNumber,
code: otpCode,
});
if (response.data.success && response.data.token) {
// Store token in localStorage
localStorage.setItem("authToken", response.data.token);
// Configure axios to use token for future requests
axios.defaults.headers.common["Authorization"] = `Bearer ${response.data.token}`;
// Redirect to dashboard or home page
window.location.href = "/dashboard";
}
} catch (error) {
setError(error.response?.data?.message || "Verification failed");
}
};3. Protect Backend Routes
// Middleware to verify JWT tokens
const authenticateToken = (req, res, next) => {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
return res.status(401).json({ error: "Access token required" });
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: "Invalid or expired token" });
}
req.user = user;
next();
});
};
// Protected route example
app.get("/api/user/profile", authenticateToken, (req, res) => {
res.json({
phoneNumber: req.user.phoneNumber,
verified: req.user.verified,
});
});How to Add Database Integration for OTP Tracking
For production systems, store OTP attempts and verification status in a database:
Schema Example (PostgreSQL with Prisma)
// schema.prisma
model OTPVerification {
id String @id @default(uuid())
phoneNumber String
verificationId String // Sinch verification ID
status String // PENDING, SUCCESSFUL, FAILED, EXPIRED
attempts Int @default(0)
createdAt DateTime @default(now())
verifiedAt DateTime?
expiresAt DateTime
@@index([phoneNumber])
@@index([verificationId])
}
model User {
id String @id @default(uuid())
phoneNumber String @unique
isVerified Boolean @default(false)
verifiedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Database Operations
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
// Store OTP request
app.post("/api/otp/start", async (req, res) => {
const { phoneNumber } = req.body;
try {
const response = await sinchClient.start({
method: "sms",
identity: {
type: "number",
endpoint: phoneNumber,
},
});
// Store in database for tracking
await prisma.oTPVerification.create({
data: {
phoneNumber,
verificationId: response.id,
status: "PENDING",
expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes
},
});
res.json({
success: true,
verificationId: response.id,
message: "OTP sent successfully",
});
} catch (error) {
// ... error handling ...
}
});
// Update after verification
app.post("/api/otp/verify", async (req, res) => {
const { phoneNumber, code } = req.body;
try {
const response = await sinchClient.report({
method: "sms",
identity: {
type: "number",
endpoint: phoneNumber,
},
code,
});
// Update verification record
await prisma.oTPVerification.updateMany({
where: {
phoneNumber,
status: "PENDING",
},
data: {
status: response.status,
verifiedAt: response.status === "SUCCESSFUL" ? new Date() : null,
},
});
// Update or create user record
if (response.status === "SUCCESSFUL") {
await prisma.user.upsert({
where: { phoneNumber },
update: {
isVerified: true,
verifiedAt: new Date(),
},
create: {
phoneNumber,
isVerified: true,
verifiedAt: new Date(),
},
});
}
res.json({
success: response.status === "SUCCESSFUL",
message: response.status === "SUCCESSFUL"
? "Verification successful"
: "Invalid code",
});
} catch (error) {
// ... error handling ...
}
});What Should You Check Before Deploying OTP Authentication to Production?
Before deploying your OTP system to production, verify these items:
Backend Deployment
- Environment Variables: Set all secrets (SINCH_APPLICATION_KEY, SINCH_APPLICATION_SECRET, JWT_SECRET) in your hosting platform
- HTTPS: Ensure your backend uses HTTPS with valid SSL certificate
- CORS: Configure CORS to allow only your frontend domain
- Rate Limiting: Implement strict rate limits (3 – 5 requests per 15 minutes)
- Logging: Set up centralized logging (e.g., CloudWatch, LogDNA, Datadog)
- Monitoring: Configure uptime monitoring and alert notifications
- Database: Use production database with backups and replication
- Error Handling: Implement comprehensive error handling for all edge cases
- Input Validation: Validate and sanitize all user inputs
- Health Check: Add
/healthendpoint for load balancer health checks
Frontend Deployment
- Environment Variables: Set API_BASE_URL to production backend URL
- Build Optimization: Run production build with
npm run build - CDN: Deploy static assets to CDN for faster loading
- HTTPS: Ensure frontend is served over HTTPS
- Error Boundaries: Implement React error boundaries
- Analytics: Add user analytics to track verification success rates
- Accessibility: Test with screen readers and keyboard navigation
- Mobile Testing: Verify functionality on iOS and Android devices
- Browser Testing: Test on major browsers (Chrome, Firefox, Safari, Edge)
Security Checklist
- Credential Management: Never commit secrets to version control
- Token Expiration: Set reasonable JWT expiration times (7 – 30 days)
- Phone Number Privacy: Mask phone numbers in logs (show only last 4 digits)
- SQL Injection: Use parameterized queries or ORMs
- XSS Protection: Sanitize user inputs in frontend
- CSRF Protection: Implement CSRF tokens for state-changing operations
- Brute Force: Rate limit verification attempts per phone number
- Compliance: Ensure GDPR/CCPA compliance for phone number storage
How to Troubleshoot Common OTP Authentication Issues
Issue 1: SMS Not Received
Symptoms: User doesn't receive OTP code on their phone
Possible Causes:
- Invalid phone number format (missing country code or incorrect E.164 format)
- Phone number blocked or on DND (Do Not Disturb) list
- Network delays or carrier issues
- Insufficient Sinch account balance
Solutions:
- Verify phone number format with
libphonenumber-js - Check Sinch dashboard for delivery status and error logs
- Implement fallback verification method (voice call OTP)
- Add retry mechanism with exponential backoff
Issue 2: "Invalid Signature" or Authentication Errors
Symptoms: Sinch API returns 401 or 403 errors
Possible Causes:
- Incorrect API credentials in .env file
- Credentials not loaded properly (dotenv not configured)
- Credentials expired or rotated in Sinch dashboard
Solutions:
- Verify credentials in Sinch dashboard match .env file exactly
- Ensure
require("dotenv").config()is called before initializing Sinch client - Check for whitespace or newlines in credential strings
- Regenerate credentials in Sinch dashboard if necessary
Issue 3: Rate Limiting Triggers Too Quickly
Symptoms: Users hit rate limits unexpectedly
Possible Causes:
- Multiple users behind same IP (corporate network, VPN)
- Rate limit window too strict
- Testing causing rate limit hits
Solutions:
- Use phone number as rate limit key instead of IP:
keyGenerator: (req) => req.body.phoneNumber - Adjust rate limit windows for your use case
- Implement different limits for different environments (development vs. production)
- Add rate limit bypass for testing with special header
Issue 4: CORS Errors in Browser Console
Symptoms: "Access to XMLHttpRequest blocked by CORS policy"
Possible Causes:
- Backend not configured with CORS middleware
- Frontend URL not in CORS allowed origins
- Preflight requests failing
Solutions:
- Ensure
app.use(cors())is called before route handlers - Configure specific origins:
cors({ origin: "http://localhost:5173" }) - Allow credentials if needed:
cors({ credentials: true }) - Check browser network tab for OPTIONS preflight requests
What Advanced Features Can You Add to SMS OTP Authentication?
Enhance your OTP system with these advanced features:
1. Multi-Channel Verification
Add fallback verification methods:
// Allow users to choose verification method
app.post("/api/otp/start", async (req, res) => {
const { phoneNumber, method } = req.body; // method: "sms" or "callout"
const response = await sinchClient.start({
method: method || "sms", // Default to SMS
identity: {
type: "number",
endpoint: phoneNumber,
},
});
// ... rest of code ...
});2. Custom SMS Templates
Customize OTP message content:
const response = await sinchClient.start({
method: "sms",
identity: {
type: "number",
endpoint: phoneNumber,
},
custom: "Your YourApp verification code is {{CODE}}. Expires in 10 minutes.",
});3. Analytics and Reporting
Track verification metrics:
// Track success rates
const metrics = {
totalRequests: 0,
successfulVerifications: 0,
failedVerifications: 0,
averageVerificationTime: 0,
};
// Update after each verification
app.post("/api/otp/verify", async (req, res) => {
const startTime = Date.now();
// ... verification code ...
const verificationTime = Date.now() - startTime;
metrics.totalRequests++;
if (response.status === "SUCCESSFUL") {
metrics.successfulVerifications++;
} else {
metrics.failedVerifications++;
}
// Calculate average time
metrics.averageVerificationTime =
(metrics.averageVerificationTime * (metrics.totalRequests - 1) + verificationTime)
/ metrics.totalRequests;
});
// Expose metrics endpoint
app.get("/api/metrics", authenticateAdmin, (req, res) => {
res.json(metrics);
});4. White-Label Sender IDs
Configure custom sender IDs for branded SMS:
// Contact Sinch support to register your sender ID
const response = await sinchClient.start({
method: "sms",
identity: {
type: "number",
endpoint: phoneNumber,
},
smsOptions: {
senderId: "YourBrand", // Custom sender ID
},
});Frequently Asked Questions About SMS OTP Authentication with Sinch
How much does Sinch SMS verification cost?
Sinch SMS OTP pricing varies by region and volume. Typically, SMS verification costs $0.01 – $0.05 per message in most countries. Visit the Sinch Pricing Page for detailed regional pricing. New accounts usually receive free trial credits to test the service.
Can I use Sinch OTP verification with phone numbers from any country?
Yes, Sinch supports SMS OTP delivery to 190+ countries worldwide. However, delivery reliability and costs vary by region. Some countries have stricter regulations requiring sender ID registration. Always test with your target regions before deploying SMS 2FA to production.
How long are SMS OTP codes valid?
Sinch OTP codes typically expire after 5 – 10 minutes, depending on your configuration. This balance provides enough time for users to receive and enter codes while maintaining security. You can configure expiration times through Sinch's API options for your specific two-factor authentication needs.
What happens if a user doesn't receive the SMS OTP?
Implement these fallback strategies for SMS delivery failures:
- Allow users to request a new code after 60 – 120 seconds
- Offer voice call OTP as an alternative delivery method
- Check Sinch dashboard logs to diagnose delivery issues
- Display helpful error messages if the phone number is invalid or blocked
How do I prevent SMS pumping fraud in my OTP system?
SMS pumping (artificially inflating verification requests) is a common attack on OTP systems. Protect against it with:
- Rate limiting: Limit requests per phone number and IP address (10 requests per 15 minutes)
- CAPTCHA: Add reCAPTCHA before allowing OTP requests
- Phone validation: Validate phone numbers in E.164 format before sending SMS
- Geographic restrictions: Block high-risk countries if you don't serve them
- Cost alerts: Set up Sinch spending alerts to detect unusual activity
Can I customize the SMS OTP message content?
Yes, Sinch allows custom SMS message templates for OTP delivery. Use the custom parameter in the start() method with {{CODE}} as a placeholder for the verification code. Keep messages under 160 characters to avoid SMS segmentation charges.
Is this Node.js and React OTP implementation secure for production?
The basic implementation provides a good foundation but requires additional hardening for production SMS 2FA:
- Implement comprehensive rate limiting (15-minute window, 10 requests per IP)
- Add phone number validation using libphonenumber-js
- Use HTTPS everywhere (backend API and frontend)
- Implement proper session management with JWT tokens
- Add monitoring and alerting for OTP delivery failures
- Store verification attempts in a database (PostgreSQL recommended)
- Follow the "Deployment Checklist" section above
How do I test OTP verification in development without sending real SMS?
Sinch provides test credentials and phone numbers for development environments:
- Use Sinch sandbox environment with test credentials
- Configure specific test phone numbers that always return success
- Implement a development mode that accepts a universal bypass code (e.g., "123456")
- Never use production credentials in development environments
- Use environment variables to toggle between test and production modes
What's the difference between SMS and voice call verification for OTP?
SMS OTP Verification:
- Lower cost ($0.01 – $0.05 per message)
- Faster delivery (2 – 5 seconds)
- User can reference code multiple times
- May fail on VOIP or landline numbers
- Works globally across 190+ countries
Voice Call OTP Verification:
- Higher cost ($0.02 – $0.10 per call)
- Slower delivery (10 – 20 seconds)
- Works on landlines and VOIP numbers
- Better for accessibility (users with vision impairments)
- Can reach users in areas with poor SMS infrastructure
Implement both methods for maximum reliability in your 2FA system.
How do I handle international phone number formats in my OTP system?
Always use E.164 format for international phone numbers in SMS OTP authentication:
- E.164 Format:
+[country code][subscriber number] - Example: +14155552671 (US), +442071838750 (UK), +919876543210 (India)
Use libphonenumber-js library to parse and validate phone numbers:
import { parsePhoneNumber } from "libphonenumber-js";
const phoneNumber = parsePhoneNumber("+1 (415) 555-2671");
console.log(phoneNumber.format("E.164")); // +14155552671
console.log(phoneNumber.country); // USThis ensures reliable SMS delivery across all countries and carriers.
What are the advantages of using Sinch over other SMS providers for OTP?
Sinch offers several advantages for SMS OTP authentication:
- Global Reach: Direct carrier connections in 190+ countries for reliable delivery
- Fast Delivery: Optimized routing ensures 2 – 5 second SMS delivery times
- Built-in Verification: Dedicated Verification API handles OTP generation and validation
- Fallback Options: Automatic failover to voice call if SMS delivery fails
- Developer Experience: Official Node.js SDK (@sinch/verification) with TypeScript support
- Compliance: Built-in GDPR compliance and data privacy features
- Cost Optimization: Competitive pricing with volume discounts
How many OTP verification attempts should I allow before locking an account?
Industry best practices for OTP authentication recommend:
- 5 failed attempts within a 15-minute window before temporary lockout
- 30-minute cooldown period after 5 failed attempts
- 3 OTP requests per phone number per 15-minute window maximum
- Progressive delays: Add 5-second delays between verification attempts after 3 failures
These limits balance security (preventing brute force attacks) with user experience (allowing legitimate mistakes).
Can I integrate this SMS OTP system with existing authentication frameworks?
Yes, you can integrate Sinch SMS OTP with popular authentication frameworks:
- Passport.js: Create custom strategy for SMS OTP verification
- NextAuth.js: Implement custom credentials provider with OTP validation
- Auth0: Use Auth0 Custom Database with SMS OTP as second factor
- Firebase Authentication: Combine Firebase Auth with Sinch for SMS delivery
- Supabase Auth: Add Sinch OTP as additional verification layer
The key is to trigger Sinch OTP verification at the appropriate point in your existing authentication flow (typically after username/password validation).
What metrics should I monitor for my production OTP system?
Track these key performance indicators (KPIs) for SMS OTP authentication:
- Delivery Success Rate: Percentage of SMS successfully delivered (target: >95%)
- Verification Success Rate: Percentage of users completing OTP verification (target: >80%)
- Average Verification Time: Time from OTP request to successful verification (target: <60 seconds)
- Failed Verification Attempts: Track for fraud detection and user experience issues
- Cost Per Verification: Monitor SMS costs and optimize for volume discounts
- Rate Limit Triggers: Identify potential abuse or overly restrictive limits
- Geographic Delivery Issues: Track regions with poor delivery rates
Use these metrics to optimize your OTP system and improve user experience.
Summary: Building Production-Ready SMS OTP Authentication
You've now built a complete SMS OTP/2FA authentication system using Sinch, Node.js, Express, React, and Vite. This tutorial covered:
- Setting up Sinch API credentials and SDK integration for SMS verification
- Building Express endpoints for OTP generation and verification with Node.js v18/v20 LTS
- Creating a React v18.x component with two-step verification flow using Vite v5.x
- Implementing security measures (rate limiting with express-rate-limit v7.x, input validation, error handling)
- Integrating with user sessions using JWT tokens for authenticated access
- Database tracking of verification attempts using PostgreSQL and Prisma v5.x
- Production deployment considerations and security best practices for 2FA
- Advanced features (custom SMS templates, multi-channel verification, analytics)
Key takeaways for SMS OTP authentication:
- Always use E.164 format for international phone numbers (+[country][number])
- Implement strict rate limiting to prevent SMS pumping fraud (10 requests per 15 minutes)
- Use HTTPS in production for all API communications between frontend and backend
- Add comprehensive error handling for edge cases (expired codes, delivery failures, invalid formats)
- Monitor verification success rates and SMS delivery metrics in production
- Consider fallback verification methods (voice calls) for users who don't receive SMS
- Store verification attempts in a database for security auditing and fraud detection
- Test thoroughly with international phone numbers across different regions and carriers
Next steps: Explore related tutorials on implementing delivery status callbacks to track SMS delivery in real-time, or build scheduling and reminder systems using similar Sinch integration patterns.
The complete source code from this tutorial provides a production-ready foundation you can customize for your specific use case. For enterprise implementations, consider additional features like custom sender IDs, white-label branding, multi-factor authentication combinations (SMS + authenticator apps), and advanced fraud detection with machine learning.
Visit Sinch Documentation for more advanced features, API references, and integration guides for SMS OTP authentication.