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.
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.
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
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:
Replace 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:
JavaScript
// server.jsconst 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 credentialsconst sinchClient = newVerification({
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 IPconst limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutesmax: 10, // Maximum 10 requests per windowmessage: "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 presenceif (!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 automaticallyconst response = await sinchClient.start({
method: "sms", // Use SMS delivery methodidentity: {
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 errorsif (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 fieldsif (!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 Sinchconst response = await sinchClient.report({
method: "sms",
identity: {
type: "number",
endpoint: phoneNumber,
},
code: code,
});
console.log("OTP verification result:", response.status);
// Check verification statusif (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 serverconstPORT = 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:
Bash
node server.js
You should see: OTP Backend server running on http://localhost:5000
Test the /api/otp/start endpoint using curl or a tool like Postman:
// Contact Sinch support to register your sender IDconst 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:
This 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%)
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.
Frequently asked questions
Implement OTP verification by setting up a Node.js backend with Express and a React frontend using Vite. The backend uses the Sinch Verification API to send OTPs via SMS, while the frontend handles user input and interaction with the API. The article provides a step-by-step guide for building both components and connecting them.
The Sinch Verification API is a service that handles sending SMS messages containing one-time passwords (OTPs). It manages OTP generation, delivery to users' phones, and verification status, providing a secure way to confirm a user's identity.
Sinch requires phone numbers in E.164 format (e.g., +15551234567) for reliable SMS delivery worldwide. This international standard ensures consistent formatting, making it easier for Sinch to route messages correctly regardless of the user's location.
Two-factor authentication (2FA) with OTP is recommended whenever enhanced security is needed. It adds an extra layer of protection during logins, sensitive transactions, or account changes, safeguarding against unauthorized access even if a password is compromised.
While the default and example code uses a 6-digit OTP, which is Sinch's default, you might be able to configure this length depending on Sinch's API capabilities. The article recommends making the length configurable if your application requires a different OTP length.
Set up your backend by creating a Node.js project, installing Express, CORS, dotenv, the Sinch Verification SDK, and express-rate-limit. You will then need to initialize an Express server, use middleware for CORS and JSON body parsing, define routes for starting and verifying OTP, implement basic error handling, and add rate limiting.
express-rate-limit middleware limits repeated requests to the OTP API endpoints, preventing abuse and protecting against brute-force attacks. The example code sets a limit of 10 requests per 15 minutes per IP address.
Error handling involves input validation (phone and OTP format), wrapping Sinch API calls in try-catch blocks, implementing error handling middleware, and providing informative error messages to the user. The provided code examples demonstrate these strategies for both backend and frontend.
Send an OTP by making a POST request to the /api/otp/start endpoint on your backend. The request body should contain the user's phone number in E.164 format. The backend then uses the sinchClient.verification.verifications.startSms() method to interact with the Sinch API.
Verify the OTP by making a POST request to the /api/otp/verify endpoint. Send the verificationId (received from the /start endpoint) and the user-entered OTP in the request body. The backend then calls sinchClient.verification.verifications.reportSmsById() with this data to check the OTP against Sinch.
After successful OTP verification, find the associated user in your database and update their isPhoneNumberVerified status. Generate a JWT or set up a session and return this token/session information to the frontend for subsequent authenticated requests.
The frontend should provide a "Resend OTP" button that triggers another request to the /api/otp/start endpoint with the same phone number. Sinch handles rate limiting and might invalidate previous OTPs if resent within a short time.
Consider using Sinch's status page and setting up your own monitoring for service disruptions. Implement retry logic (with exponential backoff) for transient network errors. For critical apps, offer alternative 2FA methods (email, authenticator apps) as a fallback, though this is more complex.
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:
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:
What You'll Build in This OTP Authentication Tutorial
You'll create a complete OTP authentication system with these components:
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:
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:
Step 3: Configure Environment Variables
Create a
.envfile 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
.envto your.gitignorefile 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:
Step 5: Test Your Backend API
Start the server:
You should see:
OTP Backend server running on http://localhost:5000Test the
/api/otp/startendpoint 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
+1234567890with 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
Step 3: Create OTP Component
Replace the contents of
src/App.jsxwith 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:
Step 4: Style Your Component
Update
src/App.csswith 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:
Open 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:
cd otp-backend node server.jsStart Frontend Server (in a new terminal):
cd otp-frontend npm run devTest Verification Flow:
Test Error Cases:
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:const { 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)
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
/healthendpoint for load balancer health checksFrontend Deployment
npm run buildSecurity Checklist
How to Troubleshoot Common OTP Authentication Issues
Issue 1: SMS Not Received
Symptoms: User doesn't receive OTP code on their phone
Possible Causes:
Solutions:
libphonenumber-jsIssue 2: "Invalid Signature" or Authentication Errors
Symptoms: Sinch API returns 401 or 403 errors
Possible Causes:
Solutions:
require("dotenv").config()is called before initializing Sinch clientIssue 3: Rate Limiting Triggers Too Quickly
Symptoms: Users hit rate limits unexpectedly
Possible Causes:
Solutions:
keyGenerator: (req) => req.body.phoneNumberIssue 4: CORS Errors in Browser Console
Symptoms: "Access to XMLHttpRequest blocked by CORS policy"
Possible Causes:
Solutions:
app.use(cors())is called before route handlerscors({ origin: "http://localhost:5173" })cors({ credentials: true })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:
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:
Can I customize the SMS OTP message content?
Yes, Sinch allows custom SMS message templates for OTP delivery. Use the
customparameter in thestart()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:
How do I test OTP verification in development without sending real SMS?
Sinch provides test credentials and phone numbers for development environments:
What's the difference between SMS and voice call verification for OTP?
SMS OTP Verification:
Voice Call OTP Verification:
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:
+[country code][subscriber number]Use
libphonenumber-jslibrary 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:
How many OTP verification attempts should I allow before locking an account?
Industry best practices for OTP authentication recommend:
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:
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:
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:
Key takeaways for SMS OTP authentication:
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.