Two-factor authentication (2FA), often implemented via one-time passwords (OTP), adds a critical layer of security to user verification and login processes. This guide provides a complete walkthrough for building a robust SMS-based OTP verification system using Node.js for the backend, React (with Vite) for the frontend, and Sinch's Verification API for delivering OTPs.
We will build a simple application where a user can enter their phone number, receive an SMS OTP via Sinch, and verify that OTP to gain access or confirm an action. This guide focuses on the core OTP flow, providing a foundation you can integrate into larger authentication systems. By the end, you'll have a functional backend API and a React frontend that interact to securely verify users via SMS OTP.
System Architecture
The flow involves three main components:
- React Frontend (Vite): Captures the user's phone number and the received OTP, interacting with the Node.js backend.
- Node.js Backend (Express): Exposes API endpoints to start the Sinch verification process and verify the submitted OTP. It communicates securely with the Sinch API using your credentials.
- Sinch Verification API: Handles the generation and delivery of SMS OTPs to the user's phone number and manages the verification status.
Prerequisites
- Node.js and npm (or yarn) installed.
- A Sinch account with access to the Verification API. (https://dashboard.sinch.com/signup)
- Basic understanding of Node.js, Express, React, REST APIs, and asynchronous JavaScript.
- A code editor (like VS Code).
- A tool for testing APIs (like
curl
, Postman, or Insomnia).
1. Setting up the Project
We'll create separate directories for the backend and frontend.
1.1. Backend Setup (Node.js/Express)
# Create the main project directory
mkdir sinch-otp-app
cd sinch-otp-app
# Create and navigate into the backend directory
mkdir backend
cd backend
# Initialize Node.js project
npm init -y
# Install necessary dependencies
npm install express cors dotenv @sinch/verification express-rate-limit
# Create main server file and environment file
touch server.js .env .gitignore
Add node_modules
and .env
to your backend/.gitignore
file:
node_modules
.env
1.2. Frontend Setup (React/Vite)
Navigate back to the root sinch-otp-app
directory before running these commands.
# Ensure you are in the root directory (sinch-otp-app)
cd ..
# Create React project using Vite
npm create vite@latest frontend -- --template react
# Navigate into the frontend directory
cd frontend
# Install dependencies
npm install
# Install axios for making API requests
npm install axios
Your project structure should now look like this:
sinch-otp-app/
├── backend/
│ ├── node_modules/
│ ├── package.json
│ ├── package-lock.json
│ ├── server.js
│ ├── .env
│ └── .gitignore
└── frontend/
├── node_modules/
├── public/
├── src/
├── index.html
├── package.json
├── package-lock.json
├── vite.config.js
└── ... (other Vite/React files)
1.3. Configuration Explanation
express
: A minimal and flexible Node.js web application framework for building the API.cors
: Middleware to enable Cross-Origin Resource Sharing, allowing the frontend (running on a different port) to communicate with the backend.dotenv
: Loads environment variables from a.env
file intoprocess.env
, keeping sensitive information like API keys out of the code.@sinch/verification
: The official Sinch SDK for Node.js to interact with the Verification API.express-rate-limit
: Middleware to limit repeated requests to public APIs such as OTP requests.axios
: A promise-based HTTP client for the browser and Node.js, used in the frontend to make requests to our backend API.vite
: A modern frontend build tool that provides an extremely fast development server and bundles code for production.
2. Implementing Core Functionality
Let's build the backend API endpoints first, then the React frontend components.
2.1. Backend: Initializing Express and Sinch
Update your backend/server.js
file:
// backend/server.js
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const { SinchClient } = require('@sinch/verification');
const rateLimit = require('express-rate-limit');
// --- Input Validation ---
// Basic validation - Enhance as needed for production
const isValidPhoneNumber = (phoneNumber) => {
// E.164 format regex (basic) - Sinch expects this format.
// Note: This regex is basic and might not cover all edge cases or variations.
// Consider using a more robust library like 'google-libphonenumber' for production.
const e164Regex = /^\+[1-9]\d{1,14}$/;
return e164Regex.test(phoneNumber);
};
const isValidOtp = (otp) => {
// Example: Assume OTP is 6 digits (common default, matches Sinch default).
// Make this configurable if your use case requires different lengths.
const otpRegex = /^\d{6}$/;
return otpRegex.test(otp);
};
// --- Sinch Client Initialization ---
const sinchClient = new SinchClient({
applicationKey: process.env.SINCH_APPLICATION_KEY,
applicationSecret: process.env.SINCH_APPLICATION_SECRET,
});
// --- Express App Setup ---
const app = express();
const port = process.env.PORT || 4000; // Use environment variable or default
// --- Middleware ---
app.use(cors()); // Enable CORS for frontend requests
app.use(express.json()); // Parse JSON request bodies
// Apply rate limiting (before OTP routes) - see Security section for details
const otpLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Limit each IP to 10 requests per windowMs (example value)
message: { success: false, message: 'Too many requests, please try again after 15 minutes.' },
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/otp', otpLimiter);
// --- API Routes ---
// Define routes later
// --- Basic Error Handling Middleware ---
app.use((err, req, res, next) => {
console.error('[Error Middleware]:', err.stack); // Log the error stack
res.status(500).json({ success: false, message: 'Something went wrong!' });
});
// --- Start Server ---
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
if (!process.env.SINCH_APPLICATION_KEY || !process.env.SINCH_APPLICATION_SECRET) {
console.warn('WARN: Sinch Application Key/Secret not found in .env file.');
}
});
2.2. Backend: API Endpoint to Start Verification
Add the following route definition inside backend/server.js
(after middleware, before error handling):
// backend/server.js
// ... (existing code above)
// --- API Routes ---
// POST /api/otp/start - Initiate OTP verification
app.post('/api/otp/start', async (req, res, next) => {
const { phoneNumber } = req.body;
console.log(`[API] Received start request for phone: ${phoneNumber}`); // Logging
// --- Input Validation ---
// Why E.164? Sinch requires this international format (e.g., +15551234567) for reliable global delivery.
if (!phoneNumber || !isValidPhoneNumber(phoneNumber)) {
console.warn(`[API] Invalid phone number format: ${phoneNumber}`);
return res.status(400).json({ success: false, message: 'Invalid phone number format. Use E.164 (e.g., +15551234567).' });
}
try {
console.log('[Sinch] Attempting to start SMS verification...');
const startResponse = await sinchClient.verification.verifications.startSms({
identity: { type: 'number', endpoint: phoneNumber },
// Optional: 'reference' can be used to link the verification to an internal ID or transaction.
reference: 'user-verification-flow',
// Optional: 'custom' can carry data (string) that you want associated, returned in callbacks/events.
// custom: 'Any custom data string here',
});
console.log('[Sinch] Verification started successfully:', startResponse);
// IMPORTANT: Return only the verification ID to the client
res.status(200).json({
success: true,
message: 'OTP sent successfully.',
verificationId: startResponse.id, // Send verification ID back
});
} catch (error) {
console.error('[Sinch] Error starting verification:', error);
// Forward specific Sinch errors if needed, otherwise generic error
let errorMessage = 'Failed to send OTP. Please try again later.';
if (error.response && error.response.data) {
// Try to get a more specific error from Sinch if available
errorMessage = error.response.data.error?.message || errorMessage;
console.error('[Sinch Error Details]:', error.response.data);
}
// Use the error handling middleware
next(new Error(`Sinch start verification failed: ${errorMessage}`));
}
});
// ... (other routes and existing code below) ...
- E.164 Format: We emphasize why this format is needed and note the provided regex is basic.
startSms
Call: Uses the SDK's method, passing the requiredidentity
. Optionalreference
andcustom
parameters are shown with comments explaining their potential use.- Verification ID: Sinch returns a unique
id
. This is sent back to the frontend, which needs it for the verification step. - Error Handling: Wraps the Sinch call in
try...catch
. Logs errors and usesnext(error)
to pass control to the error handling middleware for a consistent response format.
2.3. Backend: API Endpoint to Verify OTP
Add the following route definition inside backend/server.js
(below the /start
route):
// backend/server.js
// ... (existing code above, including /api/otp/start route) ...
// POST /api/otp/verify - Verify the submitted OTP
app.post('/api/otp/verify', async (req, res, next) => {
const { verificationId, otp } = req.body;
console.log(`[API] Received verify request for ID: ${verificationId}, OTP: ${otp}`); // Logging
// --- Input Validation ---
if (!verificationId) {
console.warn('[API] Missing verificationId');
return res.status(400).json({ success: false, message: 'Verification ID is required.' });
}
// Using isValidOtp which assumes 6 digits (Sinch default). Adjust if needed.
if (!otp || !isValidOtp(otp)) {
console.warn(`[API] Invalid OTP format: ${otp}`);
return res.status(400).json({ success: false, message: 'Invalid OTP format. Must be 6 digits.' });
}
try {
console.log('[Sinch] Attempting to report OTP...');
const reportResponse = await sinchClient.verification.verifications.reportSmsById(
verificationId, // Use the ID received from the /start endpoint
{ sms: { code: otp } }
);
console.log('[Sinch] Verification report status:', reportResponse.status);
if (reportResponse.status === 'SUCCESSFUL') {
console.log('[Sinch] Verification successful!');
// --- Verification Successful ---
// TODO: In a real app, you would typically:
// 1. Retrieve the phone number associated with this verification (may require storing it temporarily or using Sinch callbacks).
// 2. Find the user associated with the phone number in your database.
// 3. Mark the user/phone number as verified in your database.
// 4. Generate a JWT or session token for login.
return res.status(200).json({ success: true, message: 'Verification successful!' });
} else {
// Handle other statuses like PENDING, FAIL, ERROR, DENIED
console.warn(`[Sinch] Verification failed or pending. Status: ${reportResponse.status}`);
return res.status(400).json({ success: false, message: 'Invalid or expired OTP.' });
}
} catch (error) {
console.error('[Sinch] Error verifying OTP:', error);
let errorMessage = 'Failed to verify OTP. Please try again later.';
if (error.response && error.response.data) {
errorMessage = error.response.data.error?.message || errorMessage;
// Specific check for ""Verification not found"" which might mean it expired or was invalid
if (error.response.data.error?.code === 40401) {
errorMessage = 'Verification attempt not found. It may have expired or is invalid.';
}
console.error('[Sinch Error Details]:', error.response.data);
}
// Use the error handling middleware
next(new Error(`Sinch verify OTP failed: ${errorMessage}`));
}
});
// ... (existing code below) ...
reportSmsById
Call: This SDK method takes theverificationId
(from/start
, passed back by frontend) and the user-enteredotp
.- Status Check: Checks the
status
.SUCCESSFUL
indicates correctness. Other statuses mean failure/expiration. - OTP Length: Notes that the validation assumes a 6-digit OTP.
- Security Note: Highlights that successful verification usually triggers further actions (DB update, JWT generation).
2.4. Frontend: Creating the OTP Form Component
Replace the contents of frontend/src/App.jsx
with the following:
// frontend/src/App.jsx
import React, { useState } from 'react';
import axios from 'axios';
import './App.css'; // Optional: for basic styling
function App() {
const [phoneNumber, setPhoneNumber] = useState('');
const [otp, setOtp] = useState('');
const [verificationId, setVerificationId] = useState('');
const [step, setStep] = useState(1); // 1: Enter phone, 2: Enter OTP
const [message, setMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
// Backend API base URL
// IMPORTANT: Update this URL for deployed environments!
const API_BASE_URL = 'http://localhost:4000/api/otp'; // Default for local dev
const handlePhoneNumberChange = (e) => {
setPhoneNumber(e.target.value);
};
const handleOtpChange = (e) => {
setOtp(e.target.value);
};
const handleRequestOtp = async (e) => {
e.preventDefault();
setIsLoading(true);
setMessage('');
setIsError(false);
try {
// Add basic '+' prefix if missing, simple validation example
const formattedPhoneNumber = phoneNumber.startsWith('+') ? phoneNumber : `+${phoneNumber}`;
const response = await axios.post(`${API_BASE_URL}/start`, {
phoneNumber: formattedPhoneNumber,
});
if (response.data.success) {
setVerificationId(response.data.verificationId);
setMessage('OTP sent! Please check your phone.');
setStep(2); // Move to OTP entry step
} else {
setMessage(response.data.message || 'Failed to send OTP.');
setIsError(true);
}
} catch (error) {
console.error('Error requesting OTP:', error);
const errorMessage = error.response?.data?.message || 'An error occurred while requesting OTP.';
setMessage(errorMessage);
setIsError(true);
} finally {
setIsLoading(false);
}
};
const handleVerifyOtp = async (e) => {
e.preventDefault();
setIsLoading(true);
setMessage('');
setIsError(false);
try {
const response = await axios.post(`${API_BASE_URL}/verify`, {
verificationId: verificationId,
otp: otp,
});
if (response.data.success) {
setMessage('Phone number verified successfully!');
// TODO: Handle successful verification (e.g., redirect, update main app state)
// Reset form after a delay - This is specific to this demo.
// In a real application, you'd likely navigate away or update the UI state differently.
setTimeout(() => {
setStep(1);
setPhoneNumber('');
setOtp('');
setVerificationId('');
setMessage('');
}, 3000); // Reset after 3 seconds
} else {
setMessage(response.data.message || 'Invalid OTP.');
setIsError(true);
}
} catch (error) {
console.error('Error verifying OTP:', error);
const errorMessage = error.response?.data?.message || 'An error occurred during verification.';
setMessage(errorMessage);
setIsError(true);
} finally {
setIsLoading(false);
}
};
return (
<div className=""container"">
<h1>Sinch OTP Verification</h1>
{message && (
<p className={`message ${isError ? 'error' : 'success'}`}>{message}</p>
)}
{step === 1 && (
<form onSubmit={handleRequestOtp}>
<h2>Step 1: Enter Phone Number</h2>
<p>Please enter your phone number in E.164 format (e.g., +15551234567).</p>
<div className=""form-group"">
<label htmlFor=""phoneNumber"">Phone Number:</label>
<input
type=""tel""
id=""phoneNumber""
value={phoneNumber}
onChange={handlePhoneNumberChange}
placeholder=""+15551234567""
required
disabled={isLoading}
/>
</div>
<button type=""submit"" disabled={isLoading}>
{isLoading ? 'Sending...' : 'Send OTP'}
</button>
</form>
)}
{step === 2 && (
<form onSubmit={handleVerifyOtp}>
<h2>Step 2: Enter OTP</h2>
<p>An OTP was sent to {phoneNumber}.</p>
<div className=""form-group"">
<label htmlFor=""otp"">OTP Code:</label>
<input
type=""text"" // Use ""text"" and pattern/maxLength for better UX on some devices
id=""otp""
value={otp}
onChange={handleOtpChange}
placeholder=""Enter 6-digit OTP""
required
maxLength=""6"" // Assuming 6 digit OTP
pattern=""\d{6}"" // Basic pattern validation
disabled={isLoading}
/>
</div>
<button type=""submit"" disabled={isLoading}>
{isLoading ? 'Verifying...' : 'Verify OTP'}
</button>
<button type=""button"" onClick={() => { setStep(1); setMessage(''); setOtp(''); }} disabled={isLoading} className=""back-button"">
Back
</button>
</form>
)}
</div>
);
}
export default App;
- State Management: Uses
useState
for inputs, step, loading, messages, andverificationId
. - API Calls: Uses
axios
to POST to/api/otp/start
and/api/otp/verify
. Includes a comment reminding to updateAPI_BASE_URL
for deployment. - User Feedback: Displays messages and loading states.
- Two Steps: UI adapts based on the
step
state. - Form Reset: Clarifies that the
setTimeout
reset is for demo purposes.
2.5. Frontend: Basic Styling (Optional)
Add some basic styles to frontend/src/App.css
:
/* frontend/src/App.css */
body {
font-family: sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f4f7f6;
margin: 0;
}
.container {
background-color: #ffffff;
padding: 30px 40px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
text-align: center;
max-width: 400px;
width: 90%;
}
h1 {
color: #333;
margin-bottom: 10px;
}
h2 {
color: #555;
margin-bottom: 20px;
}
p {
color: #666;
font-size: 0.9em;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
text-align: left;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #555;
}
input[type=""tel""],
input[type=""text""] {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box; /* Include padding in width */
font-size: 1em;
}
button {
background-color: #007bff;
color: white;
padding: 12px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
transition: background-color 0.2s ease;
width: 100%;
margin-top: 10px;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
button:not(:disabled):hover {
background-color: #0056b3;
}
.message {
padding: 10px;
margin-bottom: 20px;
border-radius: 4px;
font-weight: bold;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.back-button {
background-color: #6c757d;
margin-top: 15px;
}
.back-button:not(:disabled):hover {
background-color: #5a6268;
}
3. Building a Complete API Layer
Our backend exposes two core API endpoints.
3.1. API Endpoint Documentation
-
POST /api/otp/start
- Description: Initiates the SMS OTP verification process for a given phone number.
- Request Body (JSON):
{ ""phoneNumber"": ""+15551234567"" // E.164 format required }
- Success Response (200 OK):
{ ""success"": true, ""message"": ""OTP sent successfully."", ""verificationId"": ""unique_verification_id_from_sinch"" }
- Error Response (400 Bad Request):
{ ""success"": false, ""message"": ""Invalid phone number format. Use E.164 (e.g., +15551234567)."" }
- Error Response (429 Too Many Requests):
{ ""success"": false, ""message"": ""Too many requests, please try again after 15 minutes."" }
- Error Response (500 Internal Server Error / Other):
{ ""success"": false, ""message"": ""Failed to send OTP. Please try again later."" // Or more specific Sinch error }
-
POST /api/otp/verify
- Description: Verifies the OTP code submitted by the user against a specific verification attempt.
- Request Body (JSON):
{ ""verificationId"": ""unique_verification_id_from_sinch"", ""otp"": ""123456"" // 6-digit code entered by user }
- Success Response (200 OK):
{ ""success"": true, ""message"": ""Verification successful!"" }
- Error Response (400 Bad Request):
{ ""success"": false, ""message"": ""Invalid or expired OTP."" // Or other specific validation errors like ""Invalid OTP format..."" }
- Error Response (429 Too Many Requests):
{ ""success"": false, ""message"": ""Too many requests, please try again after 15 minutes."" }
- Error Response (500 Internal Server Error / Other):
{ ""success"": false, ""message"": ""Failed to verify OTP. Please try again later."" // Or more specific Sinch error like ""Verification attempt not found..."" }
3.2. API Testing Examples (curl
/ Postman)
Ensure your backend server is running (cd backend && node server.js
). You can use curl
from your terminal or a GUI tool like Postman or Insomnia.
-
Start Verification: (Using
curl
)curl -X POST http://localhost:4000/api/otp/start \ -H ""Content-Type: application/json"" \ -d '{""phoneNumber"": ""+1YOUR_TEST_PHONE_NUMBER""}' # Replace with a real number you can receive SMS on # Expected Output (example): # {""success"":true,""message"":""OTP sent successfully."",""verificationId"":""_VH_....""}
Note: Replace
+1YOUR_TEST_PHONE_NUMBER
with your actual phone number in E.164 format. You should receive an SMS. Note theverificationId
returned. -
Verify OTP (Success): Use the
verificationId
from the previous step and the OTP code you received via SMS. (Usingcurl
)curl -X POST http://localhost:4000/api/otp/verify \ -H ""Content-Type: application/json"" \ -d '{""verificationId"": ""PASTE_VERIFICATION_ID_HERE"", ""otp"": ""ENTER_RECEIVED_OTP""}' # Expected Output: # {""success"":true,""message"":""Verification successful!""}
-
Verify OTP (Failure - Incorrect OTP): (Using
curl
)curl -X POST http://localhost:4000/api/otp/verify \ -H ""Content-Type: application/json"" \ -d '{""verificationId"": ""PASTE_VERIFICATION_ID_HERE"", ""otp"": ""000000""}' # Expected Output: # {""success"":false,""message"":""Invalid or expired OTP.""}
(Using Postman/Insomnia: Create new POST requests, set the URL, select ""Body"" -> ""raw"" -> ""JSON"", paste the JSON payload, and click ""Send"".)
3.3. Authentication & Authorization
This guide focuses on the OTP mechanism itself. In a real application:
- Post-Verification: After a successful
POST /api/otp/verify
, your backend should typically perform actions like:- Finding the user associated with the verified phone number in your database.
- Generating a JSON Web Token (JWT) or setting up a session to log the user in.
- Sending this token/session info back to the frontend.
- Protecting Routes: Subsequent API requests requiring authentication would need to include the JWT (usually in the
Authorization: Bearer <token>
header) or session cookie, which your backend would validate using middleware.
4. Integrating with Sinch
4.1. Obtaining Sinch API Credentials
- Sign Up/Log In: Go to the Sinch Dashboard and sign up or log in.
- Navigate to Verification: Find the ""Verification"" section in the dashboard menu (might be under ""APIs & SDKs"" or similar).
- Create/Select an Application: You'll likely need to create a Verification ""App"" or select an existing one.
- Find Credentials: Within your Verification App settings, locate your Application Key and Application Secret. These are essential for the SDK to authenticate with Sinch.
- Secure Storage: Never hardcode these credentials in your source code. Use environment variables as shown below.
4.2. Configuring Environment Variables
Open the backend/.env
file and add your Sinch credentials:
# backend/.env
# Sinch API Credentials
SINCH_APPLICATION_KEY=YOUR_SINCH_APPLICATION_KEY
SINCH_APPLICATION_SECRET=YOUR_SINCH_APPLICATION_SECRET
# Server Configuration
PORT=4000
# Optional: Add JWT Secret if implementing login
# JWT_SECRET=your_super_secret_jwt_key_here
- Replace
YOUR_SINCH_APPLICATION_KEY
andYOUR_SINCH_APPLICATION_SECRET
with the actual values from your Sinch dashboard. SINCH_APPLICATION_KEY
: Public identifier for your Sinch Verification application.SINCH_APPLICATION_SECRET
: Private secret used with the key to authenticate API requests. Treat this like a password.PORT
: The port your Node.js server will listen on (defaulting to 4000 if not set).
The require('dotenv').config();
line at the top of backend/server.js
loads these variables into process.env
, making them accessible to the Sinch SDK initialization code: new SinchClient({ applicationKey: process.env.SINCH_APPLICATION_KEY, ... })
. Ensure you restart your backend server after modifying the .env
file.
4.3. Fallback Mechanisms
Sinch's Verification API is generally reliable, but service disruptions can occur. Consider:
- Monitoring: Use Sinch's status page and set up monitoring on your end to detect failures.
- Retry Logic (Client-Side): The frontend could offer a ""Resend OTP"" button (which would call
/api/otp/start
again, potentially with the same number – Sinch handles rate limiting and may invalidate old codes). - Alternative Methods (More Complex): For critical applications, you might implement alternative 2FA methods (e.g., Email OTP, Authenticator App) as a fallback, though this significantly increases complexity.
5. Error Handling, Logging, and Retry Mechanisms
5.1. Consistent Error Handling
- Backend:
- We implemented basic input validation (phone format, OTP format) returning
400 Bad Request
. - Sinch SDK calls are wrapped in
try...catch
. - Specific Sinch errors (like invalid credentials, rate limits, destination unreachable) might be logged with more detail on the server.
- A generic error middleware catches unhandled errors and returns a
500 Internal Server Error
. - Consistent JSON response format for errors:
{ success: false, message: '...' }
.
- We implemented basic input validation (phone format, OTP format) returning
- Frontend:
axios
calls use.catch()
to handle network errors or error responses from the backend.- User-friendly messages are displayed based on the backend response or generic error messages.
- The
isError
state helps style error messages distinctly.
5.2. Logging
- Backend: We use basic
console.log
,console.warn
, andconsole.error
for different levels of information. In production, replace this with a more robust logging library like Winston or Pino.- Log Key Events: Log requests received, attempts to call Sinch, Sinch success/failure responses, and any caught errors. Include correlation IDs (like
verificationId
) where possible. - Log Format: Use structured logging (e.g., JSON format) for easier parsing by log analysis tools.
- Log Levels: Use appropriate levels (INFO, WARN, ERROR) to filter logs effectively.
- Log Key Events: Log requests received, attempts to call Sinch, Sinch success/failure responses, and any caught errors. Include correlation IDs (like
- Frontend:
console.error
is used for debugging failed API calls. Consider using a frontend error tracking service (like Sentry or LogRocket) for production apps to capture UI errors and failed requests.
5.3. Retry Mechanisms
- Sinch Internal Retries: Sinch may have internal retries for SMS delivery issues, but this isn't directly controlled by you.
- Backend-to-Sinch: Retrying failed
startSms
orreportSmsById
calls immediately might not be effective if the issue is with Sinch or the user's input. Exponential backoff could be considered for transient network errors, but often it's better to return an error to the user and let them retry manually via the UI. - Frontend-to-Backend: The user implicitly retries by clicking the ""Send OTP"" or ""Verify OTP"" button again if the first attempt fails. Rate limiting (see Security) is crucial here.
Testing Error Scenarios:
- Enter an invalid phone number format.
- Enter an incorrect OTP.
- Wait for the OTP to expire (Sinch usually has a 5-10 minute validity) and then try to verify.
- Temporarily use incorrect Sinch credentials in
.env
to simulate auth errors. - (If possible) Use a test phone number known to be blocked or invalid via Sinch dashboard.
- Stop the backend server while the frontend is trying to make a request.
6. Database Schema and Data Layer (Conceptual)
While not implemented in this basic guide, integrating OTP into a user system requires database interaction.
6.1. User Schema Example (Conceptual)
You would typically extend your existing User
schema/table:
// Example User Schema (Conceptual - e.g., MongoDB/Mongoose)
{
""_id"": ""ObjectId(...)"",
""username"": ""john_doe"",
""email"": ""john.doe@example.com"",
""passwordHash"": ""..."", // Hashed password
""phoneNumber"": ""+15551234567"", // Stored in E.164 format
""isPhoneNumberVerified"": false, // Boolean flag
""createdAt"": ""ISODate(...)"",
""updatedAt"": ""ISODate(...)""
// ... other user fields
}
phoneNumber
: Store the user's phone number, ideally after initial validation.isPhoneNumberVerified
: A boolean flag set totrue
after successful OTP verification. This flag can be used to grant access to certain features or complete a registration process.
Note: The provided code does not include database integration. You would need to add database connection logic, user models (using an ORM like Mongoose, Sequelize, Prisma, etc.), and update the /api/otp/verify
endpoint to find the user by phone number and update the isPhoneNumberVerified
status upon successful verification. Retrieving the phone number associated with a verificationId
might require temporary storage or using Sinch's callback/event features if the /verify
endpoint doesn't inherently know the number.