Learn how to implement SMS OTP verification using Sinch API, Node.js, and Express. Step-by-step tutorial with code examples, security best practices, rate limiting, and database integration for two-factor authentication.
SMS OTP verification is essential for securing user authentication in modern web applications. This comprehensive tutorial shows you how to implement two-factor authentication (2FA) using the Sinch Verification API with Node.js and Express. You'll learn to build a production-ready SMS verification system with proper security controls, rate limiting, and database integration.
By following this guide, you'll create an Express application that sends SMS one-time passwords to users' phones and securely verifies their identity—perfect for login authentication, password resets, and transaction confirmations.
Goals:
Set up a Node.js Express project for OTP verification.
Integrate the Sinch Verification API for sending and verifying SMS OTPs using direct API calls.
Store user data (phone number, verification status) potentially using a database (PostgreSQL with Sequelize shown as an example).
Implement API endpoints for initiating and verifying OTPs.
Incorporate essential security practices like rate limiting and secure secret management.
Provide guidance on error handling, logging, deployment, and testing.
Technologies Used:
Node.js: JavaScript runtime environment (LTS versions 20.x or 22.x recommended as of 2025).
Express: Minimalist web framework for Node.js (v5.1.0+ as of 2025).
Sinch Verification API: Service for sending and verifying OTPs via SMS, voice, etc. (We'll focus on SMS).
axios: Promise-based HTTP client for making API calls to Sinch (v1.12.x as of 2025).
dotenv: Module to load environment variables from a .env file (v16.x).
(Optional) PostgreSQL & Sequelize: Database and ORM for user data persistence (Sequelize v6.x or v7.x).
(Optional) express-rate-limit: Middleware for basic brute-force protection (v8.1.x as of 2025).
Prerequisites:
Node.js and npm (or yarn) installed (Node.js 20.x LTS or 22.x LTS recommended).
Basic understanding of JavaScript, Node.js, Express, and REST APIs.
(Optional) PostgreSQL installed and running if you choose the database persistence approach.
(Optional) A code editor like VS Code.
(Optional) API testing tool like Postman or curl.
Flow:
User enters phone number and requests OTP on Frontend.
Frontend sends phone number to Backend API (/request-otp).
Backend uses an HTTP client (axios) to call the Sinch Verification API, requesting an OTP for the user's phone number.
Sinch API sends the OTP code via SMS to the User's Phone.
User receives SMS, enters OTP into Frontend.
Frontend sends phone number and OTP code to Backend API (/verify-otp). Backend uses the HTTP client to call the Sinch Verification API to verify the code. Optional: Backend updates user verification status in Database. Backend sends success/failure response to Frontend.
Final Outcome:
By the end of this guide, you will have a functional Node.js Express application capable of sending SMS OTPs via Sinch and verifying them using direct API calls, forming the basis of a secure 2FA system.
Setting Up Your Node.js OTP Project
Let's start by creating our project directory and initializing it with npm.
Step 1: Create Project Directory
Open your terminal and create a new directory for the project, then navigate into it:
Bash
mkdir node-sinch-otp
cd node-sinch-otp
Step 2: Initialize npm
Initialize the project using npm. The -y flag accepts default settings.
Bash
npm init -y
This creates a package.json file.
Step 3: Install Dependencies
We need Express for our server, dotenv to manage environment variables, and axios to interact with the Sinch REST API.
# .env
PORT=3000
# Sinch Credentials (Obtain from Sinch Dashboard - Verification API)
# IMPORTANT: Replace these placeholders with your ACTUAL credentials!
# These might be called 'Key ID' & 'Key Secret' or 'Application Key' & 'Application Secret'.
# Verify the exact names and requirements in your Sinch dashboard for the Verification API product.
SINCH_KEY_ID=YOUR_SINCH_KEY_ID
SINCH_KEY_SECRET=YOUR_SINCH_KEY_SECRET
# SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID # Might be needed depending on API/auth method used
SINCH_API_BASE_URL=https://verification.api.sinch.com # Check docs for current base URL
# (Optional) Database Credentials
DB_HOST=localhost
DB_PORT=5432
DB_USER=your_db_user
DB_PASSWORD=your_db_password
DB_NAME=your_db_name
# (Optional) Logging Level
LOG_LEVEL=info
NODE_ENV=development
CRITICAL: Replace YOUR_SINCH_KEY_ID and YOUR_SINCH_KEY_SECRET with your actual credentials obtained from the Sinch dashboard. The application will not work without them. Also, update database credentials if you are using the database option.
Never commit your actual .env file to version control. Create a .env.example file with placeholder values to guide other developers.
// src/utils/response.jsconst logger = require('../config/logger'); // Import logger/**
* Sends a standardized success response.
* @param {object} res - Express response object.
* @param {number} statusCode - HTTP status code.
* @param {object|array|string} data - Response payload.
* @param {string} [message='Success'] - Optional message.
*/constsuccessResponse = (res, statusCode, data, message = 'Success') => {
res.status(statusCode).json({
status: 'success',
message,
data,
});
};
/**
* Sends a standardized error response.
* @param {object} res - Express response object.
* @param {number} statusCode - HTTP status code.
* @param {string} message - Error message.
* @param {object} [errorDetails=null] - Optional additional error details (for logging)
*/consterrorResponse = (res, statusCode, message, errorDetails = null) => {
res.status(statusCode).json({
status: 'error',
message,
});
// Log the detailed error internally if providedif (errorDetails) {
logger.error(`Responding with ${statusCode}: ${message}`, errorDetails);
}
};
/**
* Basic global error handler middleware.
*/consterrorHandler = (err, req, res, next) => {
logger.error("ERROR:", err.stack || err); // Log the error stack// Default to 500 if no status code is set on the errorconst statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
// Avoid sending sensitive error details to the client in productionconst responseMessage = (process.env.NODE_ENV === 'production' && statusCode === 500)
? 'Internal Server Error'
: message;
res.status(statusCode).json({
status: 'error',
message: responseMessage,
});
};
module.exports = {
successResponse,
errorResponse,
errorHandler,
};
Step 10: Set up Logger (src/config/logger.js)
Create the logger configuration file. Ensure you installed winston in Step 3.
JavaScript
// src/config/logger.jsconst winston = require('winston');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }), // Log stack traces
winston.format.splat(),
winston.format.json() // Log in JSON format
),
defaultMeta: { service: 'node-sinch-otp' },
transports: [
// Write all logs with level `error` and below to `error.log`new winston.transports.File({ filename: 'error.log', level: 'error' }),
// Write all logs with level `info` and below to `combined.log`new winston.transports.File({ filename: 'combined.log' }),
],
});
// If we're not in production then log to the `console` with the format:// `${info.level}: ${info.message} JSON.stringify({ ...rest }) `if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(), // Add colors
winston.format.simple() // Simple format: level: message
)
}));
}
module.exports = logger;
You should now have a runnable basic Express server structure.
Implementing Sinch SMS OTP Service Layer
Now we'll implement the core SMS OTP functionality by integrating the Sinch Verification API using axios for HTTP requests. This service layer handles sending OTP codes via SMS and verifying user-submitted codes.
Step 1: Configure Sinch API Authentication
First, create a secure configuration file to load your Sinch Verification API credentials from environment variables. This setup uses Basic Authentication for API requests.
JavaScript
// src/config/sinch.config.jsrequire('dotenv').config();
// Validate essential Sinch environment variablesconst requiredSinchVars = ['SINCH_KEY_ID', 'SINCH_KEY_SECRET', 'SINCH_API_BASE_URL'];
requiredSinchVars.forEach(key => {
if (!process.env[key]) {
console.error(`FATAL ERROR: Missing required Sinch environment variable: ${key}. Check your .env file.`);
process.exit(1); // Exit if essential config is missing
}
});
const sinchConfig = {
keyId: process.env.SINCH_KEY_ID, // Often used as username in Basic AuthkeySecret: process.env.SINCH_KEY_SECRET, // Often used as password in Basic AuthapiBaseUrl: process.env.SINCH_API_BASE_URL,
// Add other Sinch config if needed
};
module.exports = sinchConfig;
Next, create the service file to encapsulate Sinch API interactions using axios.
JavaScript
// src/services/sinch.service.jsconst axios = require('axios');
const { keyId, keySecret, apiBaseUrl } = require('../config/sinch.config');
const logger = require('../config/logger');
// Create an axios instance for Sinch API callsconst sinchApiClient = axios.create({
baseURL: `${apiBaseUrl}/verification/v1`, // Assuming v1 verification endpoint baseauth: {
username: keyId, // Using Key ID as username for Basic Authpassword: keySecret // Using Key Secret as password for Basic Auth
},
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
<!-- GAP: Missing axios timeout configuration (Type: Substantive) -->
<!-- GAP: No axios retry logic for network failures (Type: Substantive) -->
/**
* Initiates an SMS OTP verification request via Sinch API.
* @param {string} phoneNumber - User's phone number in E.164 format (e.g., +1xxxxxxxxxx).
* @returns {Promise<object>} - Promise resolving with the verification result details (e.g., { id: verificationId })
* @throws {Error} - Throws error with details if the Sinch API call fails.
*/constrequestOtp = async (phoneNumber) => {
logger.info(`Requesting OTP for ${phoneNumber} via Sinch API`);
const payload = {
identity: { type: 'number', endpoint: phoneNumber },
method: 'sms'// Add other options like 'reference', 'custom', 'acceptedLanguages' as needed per Sinch docs// reference: 'your_internal_reference_id',// custom: 'Your custom message identifier or text'
};
<!-- GAP: Missing phone number format validation before APIcall (Type: Substantive) -->
<!-- EXPAND: Could include example of custom SMS message template (Type: Enhancement) -->
try {
const response = await sinchApiClient.post('/verifications', payload);
logger.info(`Sinch verification started successfully for ${phoneNumber}`, response.data);
// Return relevant info, typically the verification IDreturn { id: response.data.id }; // Adjust based on actual Sinch API response structure
} catch (error) {
logger.error('Sinch API Error - Request OTP:', {
message: error.message,
status: error.response?.status,
data: error.response?.data,
config: error.config// Log request details for debugging
});
// Provide a more specific error messageconst errorMessage = `Failed to initiate OTP verification with Sinch. Status: ${error.response?.status || 'N/A'}. Reason: ${error.response?.data?.error?.message || error.message}`;
const serviceError = newError(errorMessage);
serviceError.statusCode = error.response?.status || 500; // Pass status code up if available
serviceError.details = error.response?.data; // Attach detailsthrow serviceError;
}
};
/**
* Verifies an SMS OTP code via Sinch API using the Verification ID method.
* @param {string} verificationId - The ID received from the requestOtp call.
* @param {string} code - The OTP code entered by the user.
* @returns {Promise<boolean>} - Promise resolving with true if verification is successful, false otherwise.
* @throws {Error} - Throws error if the Sinch API call fails unexpectedly (e.g., network error, server error).
*/constverifyOtpById = async (verificationId, code) => {
logger.info(`Verifying OTP for verification ID ${verificationId} with code ${code}`);
const payload = {
method: 'sms',
sms: { code: code }
};
<!-- GAP: Missing code format validation (length, numeric) (Type: Substantive) -->
try {
// Sinch uses PUT to report verification result against the IDconst response = await sinchApiClient.put(`/verifications/id/${verificationId}`, payload);
logger.info(`Sinch verification report result for ID ${verificationId}:`, response.data);
// Check the actual success status field from the API response// Common success status is 'SUCCESSFUL', verify this in Sinch docsreturn response.data.status === 'SUCCESSFUL';
} catch (error) {
logger.error(`Sinch API Error - Verify OTP by ID ${verificationId}:`, {
message: error.message,
status: error.response?.status,
data: error.response?.data,
});
// Specific handling for common failure cases (like invalid code)// Check if Sinch returns a specific status code or error message for invalid OTP// Example: Assuming 4xx status indicates client error (like wrong code) vs 5xx server errorif (error.response && error.response.status >= 400 && error.response.status < 500) {
// Common reasons: Invalid code, verification expired, verification already used, verification not found
logger.warn(`Verification failed for ID ${verificationId}. Status: ${error.response.status}. Reason: ${error.response?.data?.error?.message || 'Client error'}`);
returnfalse; // Treat client-side errors (like wrong code) as verification failure
}
// For other errors (network, 5xx), re-throw a service errorconst errorMessage = `Failed to verify OTP code with Sinch for ID ${verificationId}. Status: ${error.response?.status || 'N/A'}. Reason: ${error.response?.data?.error?.message || error.message}`;
const serviceError = newError(errorMessage);
serviceError.statusCode = error.response?.status || 500;
serviceError.details = error.response?.data;
throw serviceError;
}
};
/**
* Verifies an SMS OTP code via Sinch API using the Phone Number method.
* NOTE: This might require a different endpoint or payload structure depending on the Sinch API version.
* Using the verification ID method (`verifyOtpById`) is generally preferred if the ID is available.
* @param {string} phoneNumber - User's phone number in E.164 format.
* @param {string} code - The OTP code entered by the user.
* @returns {Promise<boolean>} - Promise resolving with true if verification is successful, false otherwise.
* @throws {Error} - Throws error if the Sinch API call fails unexpectedly.
*/constverifyOtpByNumber = async (phoneNumber, code) => {
logger.info(`Verifying OTP for number ${phoneNumber} with code ${code}`);
const payload = {
method: 'sms',
sms: { code: code }
};
try {
// Sinch often uses PUT to report verification result against the numberconst response = await sinchApiClient.put(`/verifications/number/${phoneNumber}`, payload);
logger.info(`Sinch verification report result for number ${phoneNumber}:`, response.data);
// Check the actual success status field from the API responsereturn response.data.status === 'SUCCESSFUL';
} catch (error) {
logger.error(`Sinch API Error - Verify OTP by Number ${phoneNumber}:`, {
message: error.message,
status: error.response?.status,
data: error.response?.data,
});
// Specific handling for common failure cases (like invalid code)if (error.response && error.response.status >= 400 && error.response.status < 500) {
logger.warn(`Verification failed for number ${phoneNumber}. Status: ${error.response.status}. Reason: ${error.response?.data?.error?.message || 'Client error'}`);
returnfalse; // Treat client-side errors (like wrong code) as verification failure
}
// For other errors (network, 5xx), re-throw a service errorconst errorMessage = `Failed to verify OTP code with Sinch for number ${phoneNumber}. Status: ${error.response?.status || 'N/A'}. Reason: ${error.response?.data?.error?.message || error.message}`;
const serviceError = newError(errorMessage);
serviceError.statusCode = error.response?.status || 500;
serviceError.details = error.response?.data;
throw serviceError;
}
};
module.exports = {
requestOtp,
// Choose one verification method based on your flow and Sinch API docs:verifyOtp: verifyOtpByNumber, // Or verifyOtpById if you store/pass the verification ID// verifyOtp: verifyOtpById
};
Step 2: Set Up PostgreSQL Database for User Verification Status
For production applications, you'll want to persist user verification status. We'll use PostgreSQL with Sequelize ORM to store phone numbers and track which users have completed SMS verification.
(a) Configure Sequelize CLI:
Initialize Sequelize if you haven't:
Bash
npx sequelize-cli init
This creates config/config.json, models/index.js, etc. We want sequelize-cli to use our .env variables. Create a .sequelizerc file in the project root:
Now, create the JS configuration file referenced above (src/config/database.js):
JavaScript
// src/config/database.jsrequire('dotenv').config(); // Load .env variables// Define config for different environments// Reads from process.env, ensuring sensitive creds aren't hardcodedmodule.exports = {
development: {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
port: process.env.DB_PORT || 5432,
dialect: 'postgres', // Or your chosen dialectdialectOptions: { // Example: SSL for production Postgres// ssl: { require: true, rejectUnauthorized: false } // Adjust as needed
}
},
test: {
// Configuration for test environment (e.g., separate test DB)username: process.env.DB_USER_TEST || process.env.DB_USER,
password: process.env.DB_PASSWORD_TEST || process.env.DB_PASSWORD,
database: process.env.DB_NAME_TEST || `${process.env.DB_NAME}_test`,
host: process.env.DB_HOST_TEST || process.env.DB_HOST,
port: process.env.DB_PORT_TEST || 5432,
dialect: 'postgres',
logging: false, // Disable logging for tests
},
production: {
// Configuration for production environmentusername: process.env.DB_USER_PROD || process.env.DB_USER,
password: process.env.DB_PASSWORD_PROD || process.env.DB_PASSWORD,
database: process.env.DB_NAME_PROD || process.env.DB_NAME,
host: process.env.DB_HOST_PROD || process.env.DB_HOST,
port: process.env.DB_PORT_PROD || 5432,
dialect: 'postgres',
logging: false, // Usually disable verbose logging in proddialectOptions: {
// ssl: { require: true, rejectUnauthorized: false } // Example for cloud DBs
},
pool: { // Production pool settingsmax: 10,
min: 1,
acquire: 30000,
idle: 10000
}
}
};
(b) Configure Database Connection (src/config/db.config.js):
This file sets up the Sequelize instance for the application runtime.
JavaScript
// src/config/db.config.jsrequire('dotenv').config();
const { Sequelize } = require('sequelize');
const dbCliConfig = require('./database'); // Load the CLI configconst logger = require('./logger');
// Determine the environment (default to development)const env = process.env.NODE_ENV || 'development';
const config = dbCliConfig[env]; // Get the config for the current environmentlet sequelizeInstance = null;
letconnectDBFunction = async () => logger.info('DB connection skipped (config incomplete).');
if (config && config.database && config.username && config.host) {
sequelizeInstance = newSequelize(config.database, config.username, config.password, {
host: config.host,
port: config.port,
dialect: config.dialect,
logging: (msg) => logger.debug(msg), // Log SQL via logger debug levelpool: config.pool || { max: 5, min: 0, acquire: 30000, idle: 10000 },
dialectOptions: config.dialectOptions || {},
});
connectDBFunction = async () => {
try {
await sequelizeInstance.authenticate();
logger.info('Database connection established successfully.');
// DO NOT use sync() in production. Use migrations.// await sequelizeInstance.sync({ alter: process.env.NODE_ENV === 'development' });
} catch (error) {
logger.error('Unable to connect to the database:', error);
throw error; // Re-throw error to be caught by server start
}
};
} else {
logger.warn('Database configuration is incomplete. Skipping DB connection setup.');
}
module.exports = { sequelize: sequelizeInstance, connectDB: connectDBFunction };
(c) Update server.js to connect DB:
This was already handled in the src/server.js code provided in Section 1, Step 8. It checks process.env.DB_HOST before calling connectDB.
(d) Create User Model (src/models/user.model.js):
JavaScript
// src/models/user.model.jsconst { DataTypes } = require('sequelize');
const { sequelize } = require('../config/db.config'); // Use the configured instanceconst logger = require('../config/logger');
// Check if sequelize instance exists (it might be null if DB config is missing)if (!sequelize) {
logger.warn('Sequelize instance is null. Skipping User model definition.');
module.exports = null; // Export null if DB is not configured
} else {
constUser = sequelize.define('User', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
phoneNumber: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
// Basic E.164 format check. Consider using google-libphonenumber for robust validation.isE164(value) {
if(!/^\+[1-9]\d{1,14}$/.test(value)) {
thrownewError('Phone number must be in E.164 format (e.g., +1xxxxxxxxxx)');
}
}
}
},
isVerified: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
// Add other fields as needed: name, email, verificationId (optional) etc.// verificationId: { // Optionally store the latest Sinch verification ID// type: DataTypes.STRING,// allowNull: true,// },
}, {
timestamps: true, // Automatically adds createdAt and updatedAttableName: 'Users', // Explicitly define table name
});
Code
module.exports = User;
}
```
(e) Create Migration File:
Use Sequelize CLI. It will now use src/config/database.js because of .sequelizerc.
Bash
npx sequelize-cli model:generate --name User --attributes phoneNumber:string,isVerified:boolean
Then, edit the generated migration file in src/migrations/ to match the model precisely (UUID primary key, constraints, timestamps, index). The generated file will have a timestamp in its name (e.g., YYYYMMDDHHMMSS-create-user.js).
(f) Run Migrations:
The script added to package.json earlier (db:migrate) will work. Ensure your database exists and credentials in .env are correct for the NODE_ENV environment (defaults to development).
Bash
npm run db:migrate
To run migrations for production: NODE_ENV=production npm run db:migrate
(g) Create User Service (src/services/user.service.js):
JavaScript
// src/services/user.service.jsconstUser = require('../models/user.model'); // Might be null if DB not configuredconst logger = require('../config/logger');
/**
* Finds or creates a user by phone number.
* @param {string} phoneNumber - User's phone number in E.164 format.
* @returns {Promise<User|null>} - The found or newly created user instance, or null if DB is not configured.
* @throws {Error} - If a database operation fails.
*/constfindOrCreateUser = async (phoneNumber) => {
if (!User) {
logger.warn('User model not available, skipping findOrCreateUser.');
returnnull; // Skip if DB model is not loaded
}
try {
const [user, created] = awaitUser.findOrCreate({
where: { phoneNumber: phoneNumber },
defaults: {
phoneNumber: phoneNumber,
isVerified: false,
},
});
if (created) {
logger.info(`New user created for ${phoneNumber}`);
} else {
logger.info(`Existing user found for ${phoneNumber}`);
}
return user;
} catch (error) {
logger.error(`Error finding or creating user for ${phoneNumber}:`, error);
// Re-throw a more specific error or handle as neededthrownewError(`Database error while processing user ${phoneNumber}.`);
}
};
/**
* Updates a user's verification status.
* @param {string} phoneNumber - The phone number of the user to update.
* @param {boolean} isVerified - The new verification status.
* @returns {Promise<boolean>} - True if the update was successful (at least one row affected), false otherwise or if DB not configured.
* @throws {Error} - If a database operation fails.
*/constupdateUserVerificationStatus = async (phoneNumber, isVerified) => {
if (!User) {
logger.warn('User model not available, skipping updateUserVerificationStatus.');
returnfalse;
}
try {
const [affectedRows] = awaitUser.update(
{ isVerified: isVerified },
{ where: { phoneNumber: phoneNumber } }
);
if (affectedRows > 0) {
logger.info(`Updated verification status for ${phoneNumber} to ${isVerified}`);
returntrue;
} else {
logger.warn(`No user found with phone number ${phoneNumber} to update verification status.`);
returnfalse;
}
} catch (error) {
logger.error(`Error updating verification status for ${phoneNumber}:`, error);
thrownewError(`Database error while updating verification status for ${phoneNumber}.`);
}
};
module.exports = {
findOrCreateUser,
updateUserVerificationStatus,
// Add other user-related functions here (e.g., findUserByPhoneNumber)
};
Frequently Asked Questions About SMS OTP Implementation
What is the Sinch Verification API and how does SMS OTP work?
The Sinch Verification API is a service that enables SMS-based phone number verification through One-Time Passwords (OTP). It works by sending a unique code via SMS to a user's phone number, which they must enter to verify their identity. The API handles the SMS delivery, code generation, and verification logic, supporting multiple methods including SMS, voice calls, and flash calls. You integrate it via REST API calls using Basic Authentication (for testing) or Application Signed Requests (for production).
Which Node.js version should I use for SMS OTP in 2025?
Use Node.js 20.x LTS or Node.js 22.x LTS for production applications as of 2025. Node.js 20 "Iron" receives LTS support until April 2026, while Node.js 22 "Jod" entered Active LTS in October 2024 with support until April 2027. Express 5.1.x (the latest version) requires Node.js 18 or higher, making these LTS versions the recommended choice for new projects.
Should I use Basic Auth or Application Signed Requests for Sinch API?
Basic Authentication uses your application key and secret directly in API requests (as HTTP Basic Auth). It's quick to implement and suitable for testing and prototyping. Application Signed Requests provide enhanced security through cryptographic request signing and are recommended for production environments. You can configure the minimum required authentication level in the Sinch Dashboard. For production deployments, always migrate to Application Signed Requests to protect your credentials.
How do I prevent SMS OTP abuse with rate limiting?
Use the express-rate-limit middleware (v8.1.x as of 2025) to protect OTP endpoints from brute-force attacks. Configure stricter limits for OTP request endpoints (e.g., 3 requests per 15 minutes per phone number) and verification endpoints (e.g., 5 attempts per 15 minutes). Implement both IP-based and phone number-based rate limiting. Store rate limit data in Redis for distributed systems. Always log rate limit violations for security monitoring.
Should I use Sequelize or another ORM for storing user verification data?
Sequelize (v6.x or v7.x) is a mature, feature-rich ORM that works well for PostgreSQL user data storage. It provides migration support, model validation, and relationship management. Alternatives include Prisma (modern TypeScript-first ORM with excellent DX), TypeORM (good TypeScript support), or Drizzle (lightweight, type-safe). Choose based on your team's expertise and project requirements. For simple OTP verification, you only need to store phone numbers, verification status, and timestamps.
How should I handle expired OTP codes and verification failures?
Sinch API returns specific status codes for different failure scenarios. For 4xx client errors (invalid code, expired verification, verification not found), treat these as verification failures and return false to the user with appropriate error messages. For 5xx server errors or network issues, implement retry logic with exponential backoff. Set reasonable OTP expiration times (typically 5-10 minutes) and limit verification attempts (3-5 attempts) before requiring a new OTP request.
What are the best security practices for implementing SMS 2FA?
Implement these security measures: (1) Use rate limiting on both request and verify endpoints, (2) Store sensitive credentials in environment variables, never in code, (3) Use HTTPS for all API communications, (4) Implement audit logging for all OTP requests and verifications, (5) Set reasonable OTP expiration times, (6) Limit verification attempts per OTP, (7) Use Application Signed Requests in production, (8) Validate phone numbers in E.164 format, (9) Implement GDPR-compliant data handling, and (10) Monitor for suspicious patterns and account takeover attempts.
How do I migrate from Basic Authentication to Application Signed Requests in production?
Consult the official Sinch documentation at https://developers.sinch.com/docs/verification/api-reference/authentication/ for migration details. Application Signed Requests require generating request signatures using your application key and secret. Most official Sinch SDKs (Node.js, Python, Java, .NET) handle signing automatically. Update your Sinch API client configuration to use signed requests, test thoroughly in a staging environment, then configure the minimum authentication level in the Sinch Dashboard to enforce signed requests before deploying to production.
Frequently asked questions
Implement SMS OTP in Node.js using Express, the Sinch Verification API, and Axios. Create an Express app, integrate the Sinch API for sending and verifying OTPs via SMS, and optionally store user data in a database like PostgreSQL using Sequelize. This setup enables secure user authentication flows, suitable for login verification and password resets. This guide recommends rate limiting for security best practices.
The Sinch Verification API is a service for sending and verifying one-time passwords (OTPs) through various channels, primarily SMS in this context. It's integrated into Node.js applications using an HTTP client like Axios to make API calls for requesting and verifying OTPs, enhancing user authentication security. This API forms the core of secure 2FA implementation, managing the entire OTP lifecycle from generation to validation.
Using two-factor authentication (2FA) with SMS OTP adds an extra layer of security, protecting user accounts even if passwords are compromised. By requiring a code sent via SMS, it verifies the user's control of their phone number. This measure effectively mitigates unauthorized access and safeguards sensitive data. The guide covers using SMS OTP with Node, Express, and the Sinch API to achieve this.
Set up Sinch API in Node.js by storing your Key ID and Secret from the Sinch Dashboard in a .env file. Then install the dotenv package (npm install dotenv). Load these variables in your project with require('dotenv').config(); at the beginning of your main server file (server.js). Never commit your .env file to version control. Use sinchConfig object to securely hold your credentials. The Sinch API base URL should be also added to the .env file.
Request an OTP with Sinch by making a POST request to the Sinch Verification API's /verifications endpoint using Axios. The request body should include the user's phone number (in E.164 format) and the verification method ('sms'). The Sinch service then sends the OTP code to the specified phone number via SMS. Ensure Axios is configured with your Sinch API credentials for authorization.
Verify an OTP by sending the verification ID and the user-entered OTP code to the Sinch API using a PUT request. Make a request to the /verifications/id/{verificationId} endpoint, including the OTP code in the request body. Ensure you're using the correct verification method in your API call. Sinch returns a success/failure response based on the verification result.
A database is recommended for OTP verification when you need to manage persistent user data, such as verification status. Storing user information facilitates more complex flows like password resets and maintains verification records. This guide uses PostgreSQL with Sequelize as an example, though other databases can be employed. Always ensure compliance with data privacy regulations.
Handle Sinch API errors gracefully by using try-catch blocks around API calls. Log errors thoroughly using a logger like Winston. Distinguish between client errors (e.g., incorrect OTP) and server errors. Implement proper error responses to inform the user or retry the operation. The provided error handling in sinch.service.js distinguishes 4xx and 5xx errors for better diagnostics.
Yes, implement rate limiting to protect your application against brute-force attacks. Use a middleware like express-rate-limit to control the number of OTP requests from a single IP address within a specific timeframe. This enhances security by thwarting malicious attempts to guess OTP codes. Configure appropriate limits based on your application's security requirements.
Prerequisites include Node.js and npm (or yarn), a Sinch account, and basic understanding of JavaScript, Node.js, Express, and REST APIs. For database persistence, PostgreSQL and a code editor are recommended. Optionally, API testing tools like Postman or curl can be beneficial. Ensure all software is installed and configured properly before starting implementation.
Test the Sinch integration using tools like Jest and Supertest. Write unit tests for the Sinch service functions and integration tests for the API endpoints. Implement test cases for successful and failed OTP requests and verifications. Test error handling to confirm that the application responds appropriately to Sinch API errors.
The project structure includes directories for controllers, services, routes, config, models, migrations, seeders, and utils. server.js handles server setup, while .env stores environment variables. Maintain this structure for clear organization and scalability. Refer to step 5 of section 1 in the guide for details on directory and file placement.
SMS OTP verification is essential for securing user authentication in modern web applications. This comprehensive tutorial shows you how to implement two-factor authentication (2FA) using the Sinch Verification API with Node.js and Express. You'll learn to build a production-ready SMS verification system with proper security controls, rate limiting, and database integration.
By following this guide, you'll create an Express application that sends SMS one-time passwords to users' phones and securely verifies their identity—perfect for login authentication, password resets, and transaction confirmations.
Goals:
Technologies Used:
.envfile (v16.x).Prerequisites:
curl.Flow:
/request-otp)./verify-otp). Backend uses the HTTP client to call the Sinch Verification API to verify the code. Optional: Backend updates user verification status in Database. Backend sends success/failure response to Frontend.Final Outcome:
By the end of this guide, you will have a functional Node.js Express application capable of sending SMS OTPs via Sinch and verifying them using direct API calls, forming the basis of a secure 2FA system.
Setting Up Your Node.js OTP Project
Let's start by creating our project directory and initializing it with npm.
Step 1: Create Project Directory
Open your terminal and create a new directory for the project, then navigate into it:
mkdir node-sinch-otp cd node-sinch-otpStep 2: Initialize npm
Initialize the project using npm. The
-yflag accepts default settings.This creates a
package.jsonfile.Step 3: Install Dependencies
We need Express for our server,
dotenvto manage environment variables, andaxiosto interact with the Sinch REST API.# Core dependencies npm install express dotenv axios # Optional: Database (Sequelize for Postgres) npm install pg sequelize sequelize-cli # Optional: Security npm install express-rate-limit # Optional: Development dependency npm install --save-dev nodemon jest supertest winstonexpress: Web framework.dotenv: Loads environment variables from.env.axios: HTTP client to make requests to the Sinch API.pg: PostgreSQL client for Node.js (used by Sequelize).sequelize: Promise-based Node.js ORM for Postgres, MySQL, etc.sequelize-cli: Command-line interface for Sequelize (migrations, seeding).express-rate-limit: Basic rate limiting middleware.winston: Logger library (used in later steps).nodemon: Utility that automatically restarts the server on file changes during development.jest,supertest: For unit and integration testing.Step 4: Configure
nodemonand Test Scripts (Optional)Open
package.jsonand add/update scripts:{ "name": "node-sinch-otp", "version": "1.0.0", "description": "Node.js Express app for Sinch SMS OTP", "main": "src/server.js", "scripts": { "start": "node src/server.js", "start:dev": "nodemon src/server.js", "test": "jest", "db:migrate": "npx sequelize-cli db:migrate", "db:seed:all": "npx sequelize-cli db:seed:all" }, "keywords": [ "node", "express", "sinch", "otp", "2fa", "sms" ], "author": "", "license": "ISC", "dependencies": { "axios": "^1.12.x", "dotenv": "^16.x.x", "express": "^5.1.x", "express-rate-limit": "^8.1.x", "pg": "^8.x.x", "sequelize": "^6.x.x", "sequelize-cli": "^6.x.x", "winston": "^3.18.x" }, "devDependencies": { "jest": "^29.x.x", "nodemon": "^3.x.x", "supertest": "^6.x.x" } }Note: Express 5.1.x requires Node.js 18 or higher. Ensure your Node.js version meets this requirement.
Step 5: Create Project Structure
Organize the project files for better maintainability:
Create these directories and empty files.
Step 6: Create
.gitignoreCreate a
.gitignorefile in the root directory:Step 7: Set up Environment Variables (
.env)Create a
.envfile in the root directory.CRITICAL: Replace
YOUR_SINCH_KEY_IDandYOUR_SINCH_KEY_SECRETwith your actual credentials obtained from the Sinch dashboard. The application will not work without them. Also, update database credentials if you are using the database option.Never commit your actual
.envfile to version control. Create a.env.examplefile with placeholder values to guide other developers.Step 8: Basic Server Setup (
src/server.js)// src/server.js require('dotenv').config(); // Load .env variables first const express = require('express'); const mainRouter = require('./routes'); // Assuming main router is in src/routes/index.js const { errorHandler } = require('./utils/response'); // Basic error handler const logger = require('./config/logger'); // Import logger const { connectDB } = require('./config/db.config'); // Optional DB connection const app = express(); const port = process.env.PORT || 3000; // Middlewares app.use(express.json()); // Parse JSON bodies app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies // API Routes app.use('/api', mainRouter); // Health Check endpoint app.get('/health', (req, res) => { res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() }); }); // Global Error Handler Middleware (Should be last) app.use(errorHandler); // Start the server const startServer = async () => { try { // Only attempt DB connection if DB host is configured if (process.env.DB_HOST) { await connectDB(); } app.listen(port, () => { logger.info(`Server running on http://localhost:${port}`); }); } catch (error) { logger.error('Failed to start server:', error); process.exit(1); } }; startServer(); module.exports = app; // Export for potential testingStep 9: Basic Response Utility (
src/utils/response.js)// src/utils/response.js const logger = require('../config/logger'); // Import logger /** * Sends a standardized success response. * @param {object} res - Express response object. * @param {number} statusCode - HTTP status code. * @param {object|array|string} data - Response payload. * @param {string} [message='Success'] - Optional message. */ const successResponse = (res, statusCode, data, message = 'Success') => { res.status(statusCode).json({ status: 'success', message, data, }); }; /** * Sends a standardized error response. * @param {object} res - Express response object. * @param {number} statusCode - HTTP status code. * @param {string} message - Error message. * @param {object} [errorDetails=null] - Optional additional error details (for logging) */ const errorResponse = (res, statusCode, message, errorDetails = null) => { res.status(statusCode).json({ status: 'error', message, }); // Log the detailed error internally if provided if (errorDetails) { logger.error(`Responding with ${statusCode}: ${message}`, errorDetails); } }; /** * Basic global error handler middleware. */ const errorHandler = (err, req, res, next) => { logger.error("ERROR:", err.stack || err); // Log the error stack // Default to 500 if no status code is set on the error const statusCode = err.statusCode || 500; const message = err.message || 'Internal Server Error'; // Avoid sending sensitive error details to the client in production const responseMessage = (process.env.NODE_ENV === 'production' && statusCode === 500) ? 'Internal Server Error' : message; res.status(statusCode).json({ status: 'error', message: responseMessage, }); }; module.exports = { successResponse, errorResponse, errorHandler, };Step 10: Set up Logger (
src/config/logger.js)Create the logger configuration file. Ensure you installed
winstonin Step 3.// src/config/logger.js const winston = require('winston'); const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), // Log stack traces winston.format.splat(), winston.format.json() // Log in JSON format ), defaultMeta: { service: 'node-sinch-otp' }, transports: [ // Write all logs with level `error` and below to `error.log` new winston.transports.File({ filename: 'error.log', level: 'error' }), // Write all logs with level `info` and below to `combined.log` new winston.transports.File({ filename: 'combined.log' }), ], }); // If we're not in production then log to the `console` with the format: // `${info.level}: ${info.message} JSON.stringify({ ...rest }) ` if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), // Add colors winston.format.simple() // Simple format: level: message ) })); } module.exports = logger;You should now have a runnable basic Express server structure.
Implementing Sinch SMS OTP Service Layer
Now we'll implement the core SMS OTP functionality by integrating the Sinch Verification API using axios for HTTP requests. This service layer handles sending OTP codes via SMS and verifying user-submitted codes.
Step 1: Configure Sinch API Authentication
First, create a secure configuration file to load your Sinch Verification API credentials from environment variables. This setup uses Basic Authentication for API requests.
// src/config/sinch.config.js require('dotenv').config(); // Validate essential Sinch environment variables const requiredSinchVars = ['SINCH_KEY_ID', 'SINCH_KEY_SECRET', 'SINCH_API_BASE_URL']; requiredSinchVars.forEach(key => { if (!process.env[key]) { console.error(`FATAL ERROR: Missing required Sinch environment variable: ${key}. Check your .env file.`); process.exit(1); // Exit if essential config is missing } }); const sinchConfig = { keyId: process.env.SINCH_KEY_ID, // Often used as username in Basic Auth keySecret: process.env.SINCH_KEY_SECRET, // Often used as password in Basic Auth apiBaseUrl: process.env.SINCH_API_BASE_URL, // Add other Sinch config if needed }; module.exports = sinchConfig;Next, create the service file to encapsulate Sinch API interactions using
axios.// src/services/sinch.service.js const axios = require('axios'); const { keyId, keySecret, apiBaseUrl } = require('../config/sinch.config'); const logger = require('../config/logger'); // Create an axios instance for Sinch API calls const sinchApiClient = axios.create({ baseURL: `${apiBaseUrl}/verification/v1`, // Assuming v1 verification endpoint base auth: { username: keyId, // Using Key ID as username for Basic Auth password: keySecret // Using Key Secret as password for Basic Auth }, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); <!-- GAP: Missing axios timeout configuration (Type: Substantive) --> <!-- GAP: No axios retry logic for network failures (Type: Substantive) --> /** * Initiates an SMS OTP verification request via Sinch API. * @param {string} phoneNumber - User's phone number in E.164 format (e.g., +1xxxxxxxxxx). * @returns {Promise<object>} - Promise resolving with the verification result details (e.g., { id: verificationId }) * @throws {Error} - Throws error with details if the Sinch API call fails. */ const requestOtp = async (phoneNumber) => { logger.info(`Requesting OTP for ${phoneNumber} via Sinch API`); const payload = { identity: { type: 'number', endpoint: phoneNumber }, method: 'sms' // Add other options like 'reference', 'custom', 'acceptedLanguages' as needed per Sinch docs // reference: 'your_internal_reference_id', // custom: 'Your custom message identifier or text' }; <!-- GAP: Missing phone number format validation before API call (Type: Substantive) --> <!-- EXPAND: Could include example of custom SMS message template (Type: Enhancement) --> try { const response = await sinchApiClient.post('/verifications', payload); logger.info(`Sinch verification started successfully for ${phoneNumber}`, response.data); // Return relevant info, typically the verification ID return { id: response.data.id }; // Adjust based on actual Sinch API response structure } catch (error) { logger.error('Sinch API Error - Request OTP:', { message: error.message, status: error.response?.status, data: error.response?.data, config: error.config // Log request details for debugging }); // Provide a more specific error message const errorMessage = `Failed to initiate OTP verification with Sinch. Status: ${error.response?.status || 'N/A'}. Reason: ${error.response?.data?.error?.message || error.message}`; const serviceError = new Error(errorMessage); serviceError.statusCode = error.response?.status || 500; // Pass status code up if available serviceError.details = error.response?.data; // Attach details throw serviceError; } }; /** * Verifies an SMS OTP code via Sinch API using the Verification ID method. * @param {string} verificationId - The ID received from the requestOtp call. * @param {string} code - The OTP code entered by the user. * @returns {Promise<boolean>} - Promise resolving with true if verification is successful, false otherwise. * @throws {Error} - Throws error if the Sinch API call fails unexpectedly (e.g., network error, server error). */ const verifyOtpById = async (verificationId, code) => { logger.info(`Verifying OTP for verification ID ${verificationId} with code ${code}`); const payload = { method: 'sms', sms: { code: code } }; <!-- GAP: Missing code format validation (length, numeric) (Type: Substantive) --> try { // Sinch uses PUT to report verification result against the ID const response = await sinchApiClient.put(`/verifications/id/${verificationId}`, payload); logger.info(`Sinch verification report result for ID ${verificationId}:`, response.data); // Check the actual success status field from the API response // Common success status is 'SUCCESSFUL', verify this in Sinch docs return response.data.status === 'SUCCESSFUL'; } catch (error) { logger.error(`Sinch API Error - Verify OTP by ID ${verificationId}:`, { message: error.message, status: error.response?.status, data: error.response?.data, }); // Specific handling for common failure cases (like invalid code) // Check if Sinch returns a specific status code or error message for invalid OTP // Example: Assuming 4xx status indicates client error (like wrong code) vs 5xx server error if (error.response && error.response.status >= 400 && error.response.status < 500) { // Common reasons: Invalid code, verification expired, verification already used, verification not found logger.warn(`Verification failed for ID ${verificationId}. Status: ${error.response.status}. Reason: ${error.response?.data?.error?.message || 'Client error'}`); return false; // Treat client-side errors (like wrong code) as verification failure } // For other errors (network, 5xx), re-throw a service error const errorMessage = `Failed to verify OTP code with Sinch for ID ${verificationId}. Status: ${error.response?.status || 'N/A'}. Reason: ${error.response?.data?.error?.message || error.message}`; const serviceError = new Error(errorMessage); serviceError.statusCode = error.response?.status || 500; serviceError.details = error.response?.data; throw serviceError; } }; /** * Verifies an SMS OTP code via Sinch API using the Phone Number method. * NOTE: This might require a different endpoint or payload structure depending on the Sinch API version. * Using the verification ID method (`verifyOtpById`) is generally preferred if the ID is available. * @param {string} phoneNumber - User's phone number in E.164 format. * @param {string} code - The OTP code entered by the user. * @returns {Promise<boolean>} - Promise resolving with true if verification is successful, false otherwise. * @throws {Error} - Throws error if the Sinch API call fails unexpectedly. */ const verifyOtpByNumber = async (phoneNumber, code) => { logger.info(`Verifying OTP for number ${phoneNumber} with code ${code}`); const payload = { method: 'sms', sms: { code: code } }; try { // Sinch often uses PUT to report verification result against the number const response = await sinchApiClient.put(`/verifications/number/${phoneNumber}`, payload); logger.info(`Sinch verification report result for number ${phoneNumber}:`, response.data); // Check the actual success status field from the API response return response.data.status === 'SUCCESSFUL'; } catch (error) { logger.error(`Sinch API Error - Verify OTP by Number ${phoneNumber}:`, { message: error.message, status: error.response?.status, data: error.response?.data, }); // Specific handling for common failure cases (like invalid code) if (error.response && error.response.status >= 400 && error.response.status < 500) { logger.warn(`Verification failed for number ${phoneNumber}. Status: ${error.response.status}. Reason: ${error.response?.data?.error?.message || 'Client error'}`); return false; // Treat client-side errors (like wrong code) as verification failure } // For other errors (network, 5xx), re-throw a service error const errorMessage = `Failed to verify OTP code with Sinch for number ${phoneNumber}. Status: ${error.response?.status || 'N/A'}. Reason: ${error.response?.data?.error?.message || error.message}`; const serviceError = new Error(errorMessage); serviceError.statusCode = error.response?.status || 500; serviceError.details = error.response?.data; throw serviceError; } }; module.exports = { requestOtp, // Choose one verification method based on your flow and Sinch API docs: verifyOtp: verifyOtpByNumber, // Or verifyOtpById if you store/pass the verification ID // verifyOtp: verifyOtpById };Step 2: Set Up PostgreSQL Database for User Verification Status
For production applications, you'll want to persist user verification status. We'll use PostgreSQL with Sequelize ORM to store phone numbers and track which users have completed SMS verification.
(a) Configure Sequelize CLI: Initialize Sequelize if you haven't:
This creates
config/config.json,models/index.js, etc. We wantsequelize-clito use our.envvariables. Create a.sequelizercfile in the project root:// .sequelizerc const path = require('path'); module.exports = { 'config': path.resolve('src', 'config', 'database.js'), // Path to JS config file 'models-path': path.resolve('src', 'models'), 'seeders-path': path.resolve('src', 'seeders'), 'migrations-path': path.resolve('src', 'migrations') };Now, create the JS configuration file referenced above (
src/config/database.js):// src/config/database.js require('dotenv').config(); // Load .env variables // Define config for different environments // Reads from process.env, ensuring sensitive creds aren't hardcoded module.exports = { development: { username: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, host: process.env.DB_HOST, port: process.env.DB_PORT || 5432, dialect: 'postgres', // Or your chosen dialect dialectOptions: { // Example: SSL for production Postgres // ssl: { require: true, rejectUnauthorized: false } // Adjust as needed } }, test: { // Configuration for test environment (e.g., separate test DB) username: process.env.DB_USER_TEST || process.env.DB_USER, password: process.env.DB_PASSWORD_TEST || process.env.DB_PASSWORD, database: process.env.DB_NAME_TEST || `${process.env.DB_NAME}_test`, host: process.env.DB_HOST_TEST || process.env.DB_HOST, port: process.env.DB_PORT_TEST || 5432, dialect: 'postgres', logging: false, // Disable logging for tests }, production: { // Configuration for production environment username: process.env.DB_USER_PROD || process.env.DB_USER, password: process.env.DB_PASSWORD_PROD || process.env.DB_PASSWORD, database: process.env.DB_NAME_PROD || process.env.DB_NAME, host: process.env.DB_HOST_PROD || process.env.DB_HOST, port: process.env.DB_PORT_PROD || 5432, dialect: 'postgres', logging: false, // Usually disable verbose logging in prod dialectOptions: { // ssl: { require: true, rejectUnauthorized: false } // Example for cloud DBs }, pool: { // Production pool settings max: 10, min: 1, acquire: 30000, idle: 10000 } } };(b) Configure Database Connection (
src/config/db.config.js): This file sets up the Sequelize instance for the application runtime.// src/config/db.config.js require('dotenv').config(); const { Sequelize } = require('sequelize'); const dbCliConfig = require('./database'); // Load the CLI config const logger = require('./logger'); // Determine the environment (default to development) const env = process.env.NODE_ENV || 'development'; const config = dbCliConfig[env]; // Get the config for the current environment let sequelizeInstance = null; let connectDBFunction = async () => logger.info('DB connection skipped (config incomplete).'); if (config && config.database && config.username && config.host) { sequelizeInstance = new Sequelize(config.database, config.username, config.password, { host: config.host, port: config.port, dialect: config.dialect, logging: (msg) => logger.debug(msg), // Log SQL via logger debug level pool: config.pool || { max: 5, min: 0, acquire: 30000, idle: 10000 }, dialectOptions: config.dialectOptions || {}, }); connectDBFunction = async () => { try { await sequelizeInstance.authenticate(); logger.info('Database connection established successfully.'); // DO NOT use sync() in production. Use migrations. // await sequelizeInstance.sync({ alter: process.env.NODE_ENV === 'development' }); } catch (error) { logger.error('Unable to connect to the database:', error); throw error; // Re-throw error to be caught by server start } }; } else { logger.warn('Database configuration is incomplete. Skipping DB connection setup.'); } module.exports = { sequelize: sequelizeInstance, connectDB: connectDBFunction };(c) Update
server.jsto connect DB: This was already handled in thesrc/server.jscode provided in Section 1, Step 8. It checksprocess.env.DB_HOSTbefore callingconnectDB.(d) Create User Model (
src/models/user.model.js):// src/models/user.model.js const { DataTypes } = require('sequelize'); const { sequelize } = require('../config/db.config'); // Use the configured instance const logger = require('../config/logger'); // Check if sequelize instance exists (it might be null if DB config is missing) if (!sequelize) { logger.warn('Sequelize instance is null. Skipping User model definition.'); module.exports = null; // Export null if DB is not configured } else { const User = sequelize.define('User', { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true, }, phoneNumber: { type: DataTypes.STRING, allowNull: false, unique: true, validate: { // Basic E.164 format check. Consider using google-libphonenumber for robust validation. isE164(value) { if(!/^\+[1-9]\d{1,14}$/.test(value)) { throw new Error('Phone number must be in E.164 format (e.g., +1xxxxxxxxxx)'); } } } }, isVerified: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, }, // Add other fields as needed: name, email, verificationId (optional) etc. // verificationId: { // Optionally store the latest Sinch verification ID // type: DataTypes.STRING, // allowNull: true, // }, }, { timestamps: true, // Automatically adds createdAt and updatedAt tableName: 'Users', // Explicitly define table name });(e) Create Migration File: Use Sequelize CLI. It will now use
src/config/database.jsbecause of.sequelizerc.Then, edit the generated migration file in
src/migrations/to match the model precisely (UUID primary key, constraints, timestamps, index). The generated file will have a timestamp in its name (e.g.,YYYYMMDDHHMMSS-create-user.js).// src/migrations/YYYYMMDDHHMMSS-create-user.js 'use strict'; /** @type {import('sequelize-cli').Migration} */ module.exports = { async up(queryInterface, Sequelize) { await queryInterface.createTable('Users', { id: { allowNull: false, primaryKey: true, type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, }, phoneNumber: { type: Sequelize.STRING, allowNull: false, unique: true, }, isVerified: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false, }, // verificationId: { // Add if you included in model // type: Sequelize.STRING, // allowNull: true, // }, createdAt: { allowNull: false, type: Sequelize.DATE, }, updatedAt: { allowNull: false, type: Sequelize.DATE, }, }); // Add an index on phoneNumber for faster lookups await queryInterface.addIndex('Users', ['phoneNumber']); }, async down(queryInterface, Sequelize) { await queryInterface.dropTable('Users'); }, };(f) Run Migrations: The script added to
package.jsonearlier (db:migrate) will work. Ensure your database exists and credentials in.envare correct for theNODE_ENVenvironment (defaults to development).To run migrations for production:
NODE_ENV=production npm run db:migratesrc/services/user.service.js):// src/services/user.service.js const User = require('../models/user.model'); // Might be null if DB not configured const logger = require('../config/logger'); /** * Finds or creates a user by phone number. * @param {string} phoneNumber - User's phone number in E.164 format. * @returns {Promise<User|null>} - The found or newly created user instance, or null if DB is not configured. * @throws {Error} - If a database operation fails. */ const findOrCreateUser = async (phoneNumber) => { if (!User) { logger.warn('User model not available, skipping findOrCreateUser.'); return null; // Skip if DB model is not loaded } try { const [user, created] = await User.findOrCreate({ where: { phoneNumber: phoneNumber }, defaults: { phoneNumber: phoneNumber, isVerified: false, }, }); if (created) { logger.info(`New user created for ${phoneNumber}`); } else { logger.info(`Existing user found for ${phoneNumber}`); } return user; } catch (error) { logger.error(`Error finding or creating user for ${phoneNumber}:`, error); // Re-throw a more specific error or handle as needed throw new Error(`Database error while processing user ${phoneNumber}.`); } }; /** * Updates a user's verification status. * @param {string} phoneNumber - The phone number of the user to update. * @param {boolean} isVerified - The new verification status. * @returns {Promise<boolean>} - True if the update was successful (at least one row affected), false otherwise or if DB not configured. * @throws {Error} - If a database operation fails. */ const updateUserVerificationStatus = async (phoneNumber, isVerified) => { if (!User) { logger.warn('User model not available, skipping updateUserVerificationStatus.'); return false; } try { const [affectedRows] = await User.update( { isVerified: isVerified }, { where: { phoneNumber: phoneNumber } } ); if (affectedRows > 0) { logger.info(`Updated verification status for ${phoneNumber} to ${isVerified}`); return true; } else { logger.warn(`No user found with phone number ${phoneNumber} to update verification status.`); return false; } } catch (error) { logger.error(`Error updating verification status for ${phoneNumber}:`, error); throw new Error(`Database error while updating verification status for ${phoneNumber}.`); } }; module.exports = { findOrCreateUser, updateUserVerificationStatus, // Add other user-related functions here (e.g., findUserByPhoneNumber) };Frequently Asked Questions About SMS OTP Implementation
What is the Sinch Verification API and how does SMS OTP work?
The Sinch Verification API is a service that enables SMS-based phone number verification through One-Time Passwords (OTP). It works by sending a unique code via SMS to a user's phone number, which they must enter to verify their identity. The API handles the SMS delivery, code generation, and verification logic, supporting multiple methods including SMS, voice calls, and flash calls. You integrate it via REST API calls using Basic Authentication (for testing) or Application Signed Requests (for production).
Which Node.js version should I use for SMS OTP in 2025?
Use Node.js 20.x LTS or Node.js 22.x LTS for production applications as of 2025. Node.js 20 "Iron" receives LTS support until April 2026, while Node.js 22 "Jod" entered Active LTS in October 2024 with support until April 2027. Express 5.1.x (the latest version) requires Node.js 18 or higher, making these LTS versions the recommended choice for new projects.
Should I use Basic Auth or Application Signed Requests for Sinch API?
Basic Authentication uses your application key and secret directly in API requests (as HTTP Basic Auth). It's quick to implement and suitable for testing and prototyping. Application Signed Requests provide enhanced security through cryptographic request signing and are recommended for production environments. You can configure the minimum required authentication level in the Sinch Dashboard. For production deployments, always migrate to Application Signed Requests to protect your credentials.
How do I prevent SMS OTP abuse with rate limiting?
Use the express-rate-limit middleware (v8.1.x as of 2025) to protect OTP endpoints from brute-force attacks. Configure stricter limits for OTP request endpoints (e.g., 3 requests per 15 minutes per phone number) and verification endpoints (e.g., 5 attempts per 15 minutes). Implement both IP-based and phone number-based rate limiting. Store rate limit data in Redis for distributed systems. Always log rate limit violations for security monitoring.
Should I use Sequelize or another ORM for storing user verification data?
Sequelize (v6.x or v7.x) is a mature, feature-rich ORM that works well for PostgreSQL user data storage. It provides migration support, model validation, and relationship management. Alternatives include Prisma (modern TypeScript-first ORM with excellent DX), TypeORM (good TypeScript support), or Drizzle (lightweight, type-safe). Choose based on your team's expertise and project requirements. For simple OTP verification, you only need to store phone numbers, verification status, and timestamps.
How should I handle expired OTP codes and verification failures?
Sinch API returns specific status codes for different failure scenarios. For 4xx client errors (invalid code, expired verification, verification not found), treat these as verification failures and return false to the user with appropriate error messages. For 5xx server errors or network issues, implement retry logic with exponential backoff. Set reasonable OTP expiration times (typically 5-10 minutes) and limit verification attempts (3-5 attempts) before requiring a new OTP request.
What are the best security practices for implementing SMS 2FA?
Implement these security measures: (1) Use rate limiting on both request and verify endpoints, (2) Store sensitive credentials in environment variables, never in code, (3) Use HTTPS for all API communications, (4) Implement audit logging for all OTP requests and verifications, (5) Set reasonable OTP expiration times, (6) Limit verification attempts per OTP, (7) Use Application Signed Requests in production, (8) Validate phone numbers in E.164 format, (9) Implement GDPR-compliant data handling, and (10) Monitor for suspicious patterns and account takeover attempts.
How do I migrate from Basic Authentication to Application Signed Requests in production?
Consult the official Sinch documentation at https://developers.sinch.com/docs/verification/api-reference/authentication/ for migration details. Application Signed Requests require generating request signatures using your application key and secret. Most official Sinch SDKs (Node.js, Python, Java, .NET) handle signing automatically. Update your Sinch API client configuration to use signed requests, test thoroughly in a staging environment, then configure the minimum authentication level in the Sinch Dashboard to enforce signed requests before deploying to production.