Build secure SMS-based two-factor authentication using Sinch Verification API, Node.js, and Express. Complete tutorial with OTP verification, bcrypt security, Sequelize ORM, and production deployment.
Implement SMS-based two-factor authentication (2FA) in your Node.js application using Sinch's Verification API and Express framework. Two-factor authentication adds a critical security layer to user logins by combining password credentials with phone verification, reducing unauthorized access risk by up to 99.9% according to Microsoft Security research.
SMS-based 2FA offers advantages over app-based authenticators: no additional app installation required, works on any mobile device, and provides immediate accessibility. While app-based TOTP (Time-based One-Time Password) offers stronger security against SIM-swapping attacks, SMS 2FA strikes the optimal balance between security and user convenience for most applications.
One-time passwords (OTP) delivered via SMS or voice call provide user-friendly 2FA that balances security and convenience. This comprehensive guide demonstrates step-by-step integration of Sinch's Verification API into a Node.js and Express backend for robust OTP-based authentication. You'll build a complete system where users register with phone numbers, log in with credentials, and verify their identity using Sinch-delivered OTP before gaining full access.
What You'll Build
Create a production-ready authentication system with these features:
Node.js and Express REST API backend application
User registration with phone number capture and E.164 validation
Login process requiring email and password credentials
Second-factor verification using Sinch SMS or voice OTP
Complete API endpoints for registration, login, OTP request, and OTP verification
Secure password hashing with bcrypt 5.0.0+
PostgreSQL database with Sequelize ORM for user data persistence
Rate limiting and security middleware for production readiness
This implementation addresses password-only authentication vulnerabilities by adding a possession factor (the user's verified phone number) to the authentication process. Multi-factor authentication prevents common attack vectors including credential stuffing (using leaked passwords), phishing attacks (stolen passwords alone cannot grant access), and brute-force attacks (rate limiting combined with OTP requirement).
Required Technologies
Core Technologies (Verified January 2025):
Node.js 20+ LTS: JavaScript runtime environment. Use Node.js 20 LTS "Iron" (Maintenance LTS until April 2026) or Node.js 22 LTS "Jod" (Active LTS until October 2027). Node.js 18 LTS "Hydrogen" ended support March 2025.
Express 4.19+: Minimalist web framework for Node.js. Version 4.19.0+ required for security patches.
Sinch Verification API: Cloud service for sending and verifying OTP codes via SMS and voice, handling global delivery complexities and secure code generation.
PostgreSQL 14+ with Sequelize 6.35+: Relational database and ORM for persistent user data storage. PostgreSQL 14+ recommended for performance improvements and extended support.
bcrypt 5.1.1+: Password hashing library. Version 5.1.1 or higher required. Bcrypt < 5.0.0 suffers from wrap-around bug (truncates passwords >= 255 characters) and NUL character handling issues.
Supporting Libraries:
Library
Version
Purpose
dotenv
16.3+
Environment variable management from .env file
express-validator
7.0+
Input validation and sanitization middleware
helmet
7.1+
Security middleware for HTTP header protection
express-rate-limit
7.1+
Rate limiting middleware for brute-force attack prevention
axios
1.6+
Promise-based HTTP client for Sinch API requests
libphonenumber-js
1.10+
Phone number parsing, validation, and E.164 formatting
pino
8.16+
Fast, low-overhead JSON logger for production environments
System Architecture:
graph LR
A[User Browser/Client] -- HTTP Request --> B(Node.js/Express API);
B -- Register/Login Data --> C{Database (PostgreSQL)};
B -- Request OTP --> D(Sinch Verification API);
D -- Send OTP --> E[User's Phone (SMS/Voice)];
A -- Submit OTP --> B;
B -- Verify OTP --> D;
D -- Verification Result --> B;
B -- Update User Status/Session --> C;
B -- Success/Failure Response --> A;
style B fill:#f9f,stroke:#333,stroke-width:2px
style D fill:#ccf,stroke:#333,stroke-width:2px
style C fill:#9cf,stroke:#333,stroke-width:2px
Prerequisites
Before starting, ensure you have:
Node.js 20+ and npm installed. Verify with node --version and npm --version.
PostgreSQL 14+ server running locally or accessible remotely. You must create the database specified in your .env file before running migrations.
A Sinch account with API credentials (Application Key and Application Secret). Sign up at https://www.sinch.com/ and navigate to Dashboard → APIs → Verification to retrieve your credentials.
Basic understanding of JavaScript, Node.js, Express, REST APIs, and SQL databases.
Create the .env file in the project root with initial configuration:
.env
# .env
# Server Configuration
NODE_ENV=development
PORT=3000
BASE_URL=http://localhost:3000
LOG_LEVEL=debug # Use 'info' or 'warn' in production
# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=your_postgres_password
DB_NAME=sinch_otp_db
DB_DIALECT=postgres
# Security
PASSWORD_SALT_ROUNDS=10 # bcrypt salt rounds (10 = ~100ms per hash)
# JWT_SECRET=your_super_secret_jwt_key_here # Add when implementing JWT
# Rate Limiting (100 requests per 15 minutes per IP)
RATE_LIMIT_WINDOW_MS=900000 # 15 * 60 * 1000
RATE_LIMIT_MAX_REQUESTS=100
# Sinch API Credentials (Add after creating Sinch account)
# SINCH_APP_KEY=your_sinch_application_key
# SINCH_APP_SECRET=your_sinch_application_secret
# SINCH_VERIFICATION_METHOD=sms # or 'callout' for voice
Important: Replace your_postgres_password with your actual PostgreSQL password. You must manually create the database sinch_otp_db before running migrations (see Prerequisites section).
bcrypt Salt Rounds Explained: The PASSWORD_SALT_ROUNDS value determines computational cost. Each increment doubles the time:
10 rounds = ~100ms per hash (recommended minimum)
12 rounds = ~400ms per hash (recommended for high-security applications)
14 rounds = ~1.6s per hash (may impact user experience)
Balance security with performance based on your server capacity.
Step 8: Configure Logger (src/utils/logger.js)
Set up Pino logger:
JavaScript
// src/utils/logger.jsconst pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
// Use default JSON output in production, pretty print in development via package.json script
});
module.exports = logger;
Step 9: Basic Server Setup (src/index.js)
Create a minimal Express server with security middleware:
JavaScript
// src/index.jsrequire('dotenv').config(); // Load .env variables firstconst express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const logger = require('./utils/logger');
const mainRouter = require('./routes'); // Assuming routes/index.js existsconst errorHandler = require('./middleware/errorHandler'); // Assuming middleware/errorHandler.js existsconst { sequelize } = require('./database/models'); // Import sequelize instanceconst app = express();
const port = process.env.PORT || 3000;
// --- Middleware ---// Security Headers
app.use(helmet());
// Rate Limitingconst limiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10),
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10),
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headerslegacyHeaders: false, // Disable the `X-RateLimit-*` headersmessage: 'Too many requests from this IP. Try again in 15 minutes.',
});
app.use(limiter);
// Body Parsers
app.use(express.json()); // Parse JSON bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies// --- Routes ---
app.get('/health', (req, res) => res.status(200).json({ status: 'UP', timestamp: newDate().toISOString() }));
app.use('/api/v1', mainRouter); // Mount main router under /api/v1// --- Error Handling ---
app.use(errorHandler); // Central error handler// --- Server Startup with DB Connection ---asyncfunctionstartServer() {
try {
await sequelize.authenticate();
logger.info('Database connection established successfully.');
// Graceful shutdown handlersconstshutdown = async (signal) => {
logger.info(`${signal} received. Closing server gracefully...`);
await sequelize.close();
process.exit(0);
};
process.on('SIGTERM', () =>shutdown('SIGTERM'));
process.on('SIGINT', () =>shutdown('SIGINT'));
app.listen(port, () => {
logger.info(`Server running on ${process.env.BASE_URL}`);
logger.info(`Current environment: ${process.env.NODE_ENV}`);
});
} catch (error) {
logger.error('Unable to connect to the database:', error);
process.exit(1); // Exit if DB connection fails
}
}
startServer();
module.exports = app; // Export for testing
CORS Configuration (if needed for frontend):
If you're building a separate frontend application, add CORS middleware:
npm run db:model:create -- --name User --attributes email:string,password:string,phoneNumber:string,isVerified:boolean,sinchVerificationId:string,otpSecret:string,lastOtpSentAt:date
Step 4: Configure Migration
Edit the generated migration file in src/database/migrations/:
# Sinch API Credentials
SINCH_APP_KEY=your_actual_sinch_application_key
SINCH_APP_SECRET=your_actual_sinch_application_secret
SINCH_VERIFICATION_METHOD=sms # or 'callout' for voice calls
Replace your_actual_sinch_application_key and your_actual_sinch_application_secret with credentials from your Sinch dashboard.
Implement Authentication Service
Create the authentication service layer that handles user registration, login, OTP request, and OTP verification.
Step 1: Create Auth Service (src/services/auth.service.js)
JavaScript
// src/services/auth.service.jsconst { User } = require('../database/models');
const { Op } = require('sequelize');
const logger = require('../utils/logger');
const sinchService = require('./sinch.service');
const { sequelize } = require('../database/models');
const { parsePhoneNumberFromString } = require('libphonenumber-js');
classAuthService {
asyncregisterUser({ email, password, phoneNumber }) {
try {
// Input validationif (!email || !password || !phoneNumber) {
const error = newError('Email, password, and phone number are required.');
error.statusCode = 400;
throw error;
}
// Validate and format phone numberconst parsedNumber = parsePhoneNumberFromString(phoneNumber);
if (!parsedNumber || !parsedNumber.isValid()) {
const error = newError('Invalid phone number format. Use international format (e.g., +14155552671).');
error.statusCode = 400;
throw error;
}
const formattedPhoneNumber = parsedNumber.format('E.164');
// Check if user already existsconst existingUser = awaitUser.findOne({
where: {
[Op.or]: [{ email }, { phoneNumber: formattedPhoneNumber }]
}
});
if (existingUser) {
const field = existingUser.email === email ? 'Email' : 'Phone number';
const error = newError(`${field} is already registered.`);
error.statusCode = 409; // Conflictthrow error;
}
// Create user (password hashing handled by model hook)const newUser = awaitUser.create({
email,
password,
phoneNumber: formattedPhoneNumber
});
// Remove sensitive fields from responseconst userJson = newUser.toJSON();
delete userJson.password;
delete userJson.sinchVerificationId;
delete userJson.otpSecret;
logger.info(`User registered: ${userJson.email} (ID: ${userJson.id})`);
return userJson;
} catch (error) {
logger.error(`Error registering user ${email}:`, error);
if (!error.statusCode) error.statusCode = 500;
throw error;
}
}
asyncloginUser({ email, password }) {
try {
const user = awaitUser.findOne({ where: { email } });
if (!user) {
const error = newError('Invalid email or password.');
error.statusCode = 401; // Unauthorizedthrow error;
}
const isPasswordValid = await user.isValidPassword(password);
if (!isPasswordValid) {
const error = newError('Invalid email or password.');
error.statusCode = 401;
throw error;
}
// Remove sensitive fieldsconst userJson = user.toJSON();
delete userJson.password;
delete userJson.sinchVerificationId;
delete userJson.otpSecret;
logger.info(`User login successful (first factor): ${user.email} (ID: ${user.id})`);
return userJson;
} catch (error) {
logger.error(`Error logging in user ${email}:`, error);
if (!error.statusCode) error.statusCode = 500;
throw error;
}
}
asyncrequestOtpForUser(identifier) {
const findCondition = identifier.email
? { email: identifier.email }
: { phoneNumber: identifier.phoneNumber };
const user = awaitUser.findOne({ where: findCondition });
if (!user) {
const error = newError('User not found.');
error.statusCode = 404;
throw error;
}
// Rate limiting: 1 OTP request per 2 minutes per userconst now = newDate();
const twoMinutesAgo = newDate(now.getTime() - 2 * 60 * 1000);
if (user.lastOtpSentAt && user.lastOtpSentAt > twoMinutesAgo) {
const error = newError('Wait 2 minutes before requesting another OTP.');
error.statusCode = 429; // Too Many Requeststhrow error;
}
const verificationMethod = process.env.SINCH_VERIFICATION_METHOD || 'sms';
const transaction = await sequelize.transaction();
try {
const sinchResponse = await sinchService.startVerification(
user.phoneNumber,
verificationMethod
);
if (!sinchResponse || !sinchResponse.id) {
logger.error('Failed to initiate Sinch verification.', { sinchResponse });
thrownewError('Failed to initiate phone verification.');
}
// Store Sinch verification ID and timestampawaitUser.update(
{
sinchVerificationId: sinchResponse.id,
lastOtpSentAt: newDate()
},
{ where: { id: user.id }, transaction }
);
await transaction.commit();
logger.info(`OTP sent to user ${user.id} via ${verificationMethod}. Sinch ID: ${sinchResponse.id}`);
return {
message: `OTP sent via ${verificationMethod} to ${user.phoneNumber}.`,
};
} catch (error) {
await transaction.rollback();
logger.error(`Error requesting OTP for user ${user?.id}:`, error);
if (!error.statusCode) error.statusCode = 500;
throw error;
}
}
asyncverifyOtpForUser(identifier, otpCode) {
const findCondition = identifier.email
? { email: identifier.email }
: { phoneNumber: identifier.phoneNumber };
const user = awaitUser.findOne({ where: findCondition });
if (!user) {
const error = newError('User not found.');
error.statusCode = 404;
throw error;
}
const verificationMethod = process.env.SINCH_VERIFICATION_METHOD || 'sms';
try {
let sinchResponse;
// Verify using stored ID if recent (within 15 minutes)const fifteenMinutesAgo = newDate(Date.now() - 15 * 60 * 1000);
if (user.sinchVerificationId && user.lastOtpSentAt > fifteenMinutesAgo) {
logger.info(`Verifying OTP for user ${user.id} using Sinch ID: ${user.sinchVerificationId}`);
sinchResponse = await sinchService.reportVerificationById(
user.sinchVerificationId,
otpCode,
verificationMethod
);
} else {
// Fallback: verify using phone number
logger.info(`Verifying OTP for user ${user.id} using phone: ${user.phoneNumber}`);
sinchResponse = await sinchService.reportVerificationByNumber(
user.phoneNumber,
otpCode,
verificationMethod
);
}
// Check verification resultif (sinchResponse && sinchResponse.status === 'SUCCESSFUL') {
const transaction = await sequelize.transaction();
try {
awaitUser.update(
{
isVerified: true,
sinchVerificationId: null
},
{ where: { id: user.id }, transaction }
);
await transaction.commit();
logger.info(`User ${user.id} successfully verified phone number.`);
const updatedUser = awaitUser.findByPk(user.id, {
attributes: {
exclude: ['password', 'sinchVerificationId', 'otpSecret', 'lastOtpSentAt']
}
});
return updatedUser.toJSON();
} catch (dbError) {
await transaction.rollback();
logger.error(`Database error after successful Sinch verification for user ${user.id}:`, dbError);
const error = newError('Verification passed but failed to update account status.');
error.statusCode = 500;
throw error;
}
} else {
// Verification failed
logger.warn(`OTP verification failed for user ${user.id}. Status: ${sinchResponse?.status}, Reason: ${sinchResponse?.reason}`);
const error = newError('Invalid or expired OTP code.');
error.statusCode = 401;
error.details = {
sinchStatus: sinchResponse?.status,
reason: sinchResponse?.reason
};
throw error;
}
} catch (error) {
logger.error(`Error verifying OTP for user ${user?.id}:`, error);
if (!error.statusCode) error.statusCode = 500;
throw error;
}
}
}
module.exports = newAuthService();
Rate Limiting Explanation: The 2-minute delay between OTP requests prevents abuse while maintaining usability. Users who don't receive the first OTP can retry after 2 minutes. This protects against:
SMS flooding attacks
Excessive Sinch API costs
User phone number harassment
For high-security applications, consider increasing to 3–5 minutes.
Implement Controllers and Routes
Connect the authentication service to HTTP endpoints with input validation.
# Login to get token
curl -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"SecurePass123"}'# Copy token from response, then access protected route
curl http://localhost:3000/api/v1/profile \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
Production Deployment Checklist
Prepare your application for production deployment.
Security Hardening:
Set strong JWT_SECRET (32+ characters, randomly generated)
Set PASSWORD_SALT_ROUNDS to 12 or higher
Use environment-specific .env files (never commit to Git)
Enable HTTPS/TLS (use Let's Encrypt or cloud provider certificates)
Configure CORS with specific origins (avoid * wildcard)
Implement request logging for audit trails
Add input sanitization to prevent XSS attacks
Set up database connection pooling
Enable PostgreSQL SSL connections
Implement account lockout after failed login attempts
Environment Configuration:
.env
# Production .env
NODE_ENV=production
PORT=8080
BASE_URL=https://yourdomain.com
LOG_LEVEL=info
# Use separate production database
DB_HOST=prod-db-server.example.com
DB_USER=prod_user
DB_PASSWORD=strong_production_password
DB_NAME=sinch_otp_prod
# Stricter rate limiting in production
RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
RATE_LIMIT_MAX_REQUESTS=50 # Reduce from 100
JWT_SECRET=your_64_character_production_jwt_secret_generated_randomly
JWT_EXPIRES_IN=1d # Shorter expiry for production
PASSWORD_SALT_ROUNDS=12 # Increased from 10
Support for multiple 2FA methods (SMS, voice, authenticator app)
Backup codes for account recovery
Device fingerprinting and trusted devices
Geographic login anomaly detection
WebAuthn/FIDO2 passwordless authentication
Recommended Libraries:
Feature
Library
Purpose
Email sending
Nodemailer
Transactional emails
Job queues
Bull
Background OTP processing
Session storage
ioredis
Redis client for sessions
API documentation
Swagger/OpenAPI
Auto-generated API docs
Testing
Jest + Supertest
Unit and integration tests
Security Best Practices:
Implement account lockout after 5 failed attempts
Log all authentication events for audit
Hash verification IDs before storing in database
Implement CSRF protection for web clients
Use prepared statements for all SQL queries (Sequelize handles this)
Regularly rotate API keys and secrets
Conduct security audits with npm audit
Conclusion
You've built a production-ready SMS-based two-factor authentication system using Sinch, Node.js, Express, and PostgreSQL. This implementation provides:
✅ User registration with email, password, and phone number
✅ Secure password hashing with bcrypt
✅ SMS OTP delivery via Sinch Verification API
✅ Complete authentication flow with verification
✅ Rate limiting and security middleware
✅ Structured logging for production monitoring
✅ Database persistence with Sequelize ORM
✅ Optional JWT token-based sessions
Key Takeaways:
SMS 2FA significantly improves security by adding a possession factor to authentication
Sinch Verification API handles delivery complexities across global carriers
Rate limiting prevents abuse of OTP endpoints and reduces costs
Proper error handling and logging are essential for production systems
JWT tokens provide stateless authentication suitable for distributed systems
For questions or issues, refer to the troubleshooting section or consult the official documentation for each technology.
Frequently asked questions
Implement OTP 2FA using Sinch's Verification API with Node.js and Express. This involves capturing user phone numbers during registration, sending OTPs via Sinch during login, and verifying the OTP before granting full access.
The Sinch Verification API is a service that simplifies the process of sending and verifying one-time passwords (OTPs). It supports delivery through both SMS and voice calls, managing international delivery and code generation complexities.
Two-factor authentication (2FA) improves login security by adding a 'possession factor' – something the user has (their phone). This makes it significantly harder for attackers to gain access even if they compromise a user's password.
OTP-based 2FA is ideal when you need a strong, yet user-friendly second authentication factor. It's suitable for various applications, from securing online accounts to verifying financial transactions.
Yes, the example uses PostgreSQL with Sequelize, an ORM that supports other SQL databases as well. You can adapt the configuration to use MySQL, SQLite, MSSQL, and other compatible databases.
Initialize a Node.js project, install required dependencies (Express, Sequelize, Sinch API client, bcrypt, etc.), create project directories (controllers, models, routes, services), and set environment variables.
bcrypt is used for securely hashing passwords before they are stored in the database. This protects sensitive user data even if the database is compromised.
Use the libphonenumber-js library to validate and format phone numbers during user registration. This ensures phone numbers are in a consistent, internationally recognized format.
The main problem solved is the vulnerability of relying solely on passwords, which can be easily guessed, phished, or leaked. 2FA adds a second layer of defense by requiring a code sent to the user's phone.
The project leverages Node.js, Express, Sinch Verification API, PostgreSQL, bcrypt, dotenv, express-validator, Helmet, express-rate-limit, axios, libphonenumber-js, and Pino.
Use express-rate-limit middleware to limit the number of login attempts from a single IP address within a time window. This helps prevent brute-force attacks.
The diagram visually represents the flow of requests and data during registration, login, and OTP verification. It shows how the user, the Node.js/Express backend, the database, and the Sinch API interact.
Tools like Postman or curl are recommended for sending test requests to the API endpoints. This lets you simulate registration, login, OTP requests, and verification scenarios.
You'll have a working backend API that registers users, securely logs them in with passwords and OTP verification via Sinch, and manages basic security measures.
Introduction
Implement SMS-based two-factor authentication (2FA) in your Node.js application using Sinch's Verification API and Express framework. Two-factor authentication adds a critical security layer to user logins by combining password credentials with phone verification, reducing unauthorized access risk by up to 99.9% according to Microsoft Security research.
SMS-based 2FA offers advantages over app-based authenticators: no additional app installation required, works on any mobile device, and provides immediate accessibility. While app-based TOTP (Time-based One-Time Password) offers stronger security against SIM-swapping attacks, SMS 2FA strikes the optimal balance between security and user convenience for most applications.
One-time passwords (OTP) delivered via SMS or voice call provide user-friendly 2FA that balances security and convenience. This comprehensive guide demonstrates step-by-step integration of Sinch's Verification API into a Node.js and Express backend for robust OTP-based authentication. You'll build a complete system where users register with phone numbers, log in with credentials, and verify their identity using Sinch-delivered OTP before gaining full access.
What You'll Build
Create a production-ready authentication system with these features:
Authentication Flow:
Security Problem Solved:
This implementation addresses password-only authentication vulnerabilities by adding a possession factor (the user's verified phone number) to the authentication process. Multi-factor authentication prevents common attack vectors including credential stuffing (using leaked passwords), phishing attacks (stolen passwords alone cannot grant access), and brute-force attacks (rate limiting combined with OTP requirement).
Required Technologies
Core Technologies (Verified January 2025):
Supporting Libraries:
dotenv.envfileexpress-validatorhelmetexpress-rate-limitaxioslibphonenumber-jspinoSystem Architecture:
Prerequisites
Before starting, ensure you have:
node --versionandnpm --version..envfile before running migrations.Install PostgreSQL (if needed):
brew install postgresql@14 && brew services start postgresql@14sudo apt update && sudo apt install postgresql-14Create PostgreSQL Database:
# Using psql CLI psql -U postgres CREATE DATABASE sinch_otp_db; \q # Or using createdb utility createdb -U postgres sinch_otp_dbWhat You'll Learn:
By completing this guide, you will have a functional backend API capable of:
Technology Verification Date: All technology versions and requirements verified as of January 2025. Check official documentation for latest releases.
Set Up Your Node.js Project
Initialize your Node.js project and install all required dependencies for SMS-based two-factor authentication.
Step 1: Create Project Directory and Initialize
Open your terminal and run:
mkdir node-sinch-otp-2fa cd node-sinch-otp-2fa npm init -yThis creates a new directory, navigates into it, and initializes a
package.jsonfile with default settings.Step 2: Install Core Dependencies
Install Express, database interaction, security, environment variables, HTTP requests, logging, and phone number validation:
Package Purposes:
express: Web frameworkpg: PostgreSQL client for Node.js (used by Sequelize)sequelize: ORM for Node.jsbcrypt: Password hashing library (uses CPU-intensive key derivation to resist brute-force attacks)dotenv: Loads environment variables from.envexpress-validator: Input validation middleware (prevents SQL injection and XSS attacks)helmet: Security header middleware (sets CSP, HSTS, X-Frame-Options, etc.)express-rate-limit: Request rate limiting middlewareaxios: HTTP client for Sinch API callspino: JSON loggerlibphonenumber-js: Phone number validation/formattingStep 3: Install Development Dependencies
Install
nodemonfor automatic server restarts,sequelize-clifor migrations, andpino-prettyfor human-readable logs during development:Step 4: Configure
package.jsonScriptsAdd scripts to your
package.json:{ "name": "node-sinch-otp-2fa", "version": "1.0.0", "description": "SMS-based 2FA with Sinch, Node.js, and Express", "main": "src/index.js", "scripts": { "start": "node src/index.js", "dev": "nodemon src/index.js | pino-pretty", "db:migrate": "npx sequelize-cli db:migrate --config src/config/config.json --migrations-path src/database/migrations --seeders-path src/database/seeders", "db:migrate:undo": "npx sequelize-cli db:migrate:undo --config src/config/config.json --migrations-path src/database/migrations --seeders-path src/database/seeders", "db:model:create": "npx sequelize-cli model:generate --config src/config/config.json --migrations-path src/database/migrations --models-path src/database/models", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": ["2fa", "otp", "sms", "sinch", "authentication"], "author": "", "license": "ISC", "dependencies": { "axios": "^1.6.0", "bcrypt": "^5.1.1", "dotenv": "^16.3.0", "express": "^4.19.0", "express-rate-limit": "^7.1.0", "express-validator": "^7.0.0", "helmet": "^7.1.0", "libphonenumber-js": "^1.10.0", "pg": "^8.11.0", "pino": "^8.16.0", "sequelize": "^6.35.0" }, "devDependencies": { "nodemon": "^3.0.0", "pino-pretty": "^10.3.0", "sequelize-cli": "^6.6.0" } }Note: npm automatically installs the latest compatible versions matching these semver ranges.
Step 5: Create Project Structure
Organize your project with this folder structure:
Create these directories:
mkdir -p src/config src/controllers src/database/migrations src/database/models src/database/seeders src/middleware src/routes src/services src/utils touch src/index.js .env .gitignore src/utils/logger.jsStep 6: Configure
.gitignoreAdd
node_modules,.env, log files, build artifacts, and IDE configurations to.gitignore:Step 7: Set Up Environment Variables (
.env)Create the
.envfile in the project root with initial configuration:Important: Replace
your_postgres_passwordwith your actual PostgreSQL password. You must manually create the databasesinch_otp_dbbefore running migrations (see Prerequisites section).bcrypt Salt Rounds Explained: The
PASSWORD_SALT_ROUNDSvalue determines computational cost. Each increment doubles the time:Balance security with performance based on your server capacity.
Step 8: Configure Logger (
src/utils/logger.js)Set up Pino logger:
// src/utils/logger.js const pino = require('pino'); const logger = pino({ level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'), // Use default JSON output in production, pretty print in development via package.json script }); module.exports = logger;Step 9: Basic Server Setup (
src/index.js)Create a minimal Express server with security middleware:
// src/index.js require('dotenv').config(); // Load .env variables first const express = require('express'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const logger = require('./utils/logger'); const mainRouter = require('./routes'); // Assuming routes/index.js exists const errorHandler = require('./middleware/errorHandler'); // Assuming middleware/errorHandler.js exists const { sequelize } = require('./database/models'); // Import sequelize instance const app = express(); const port = process.env.PORT || 3000; // --- Middleware --- // Security Headers app.use(helmet()); // Rate Limiting const limiter = rateLimit({ windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10), max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10), standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers message: 'Too many requests from this IP. Try again in 15 minutes.', }); app.use(limiter); // Body Parsers app.use(express.json()); // Parse JSON bodies app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies // --- Routes --- app.get('/health', (req, res) => res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() })); app.use('/api/v1', mainRouter); // Mount main router under /api/v1 // --- Error Handling --- app.use(errorHandler); // Central error handler // --- Server Startup with DB Connection --- async function startServer() { try { await sequelize.authenticate(); logger.info('Database connection established successfully.'); // Graceful shutdown handlers const shutdown = async (signal) => { logger.info(`${signal} received. Closing server gracefully...`); await sequelize.close(); process.exit(0); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); app.listen(port, () => { logger.info(`Server running on ${process.env.BASE_URL}`); logger.info(`Current environment: ${process.env.NODE_ENV}`); }); } catch (error) { logger.error('Unable to connect to the database:', error); process.exit(1); // Exit if DB connection fails } } startServer(); module.exports = app; // Export for testingCORS Configuration (if needed for frontend):
If you're building a separate frontend application, add CORS middleware:
Then add to
src/index.jsafter line 16:const cors = require('cors'); app.use(cors({ origin: process.env.FRONTEND_URL || 'http://localhost:3001', credentials: true }));Step 10: Set Up Main Router (
src/routes/index.js)Create the main router:
// src/routes/index.js const express = require('express'); const authRouter = require('./auth.routes'); const logger = require('../utils/logger'); const router = express.Router(); router.use('/auth', authRouter); // Add other resource routers here (e.g., router.use('/users', userRouter);) // Catch-all for undefined API routes within /api/v1 router.use('*', (req, res) => { logger.warn(`404 Not Found: ${req.method} ${req.originalUrl}`); res.status(404).json({ message: 'API endpoint not found' }); }); module.exports = router;Step 11: Set Up Placeholder Auth Routes (
src/routes/auth.routes.js)// src/routes/auth.routes.js const express = require('express'); const router = express.Router(); // Placeholder routes – implementation comes later router.post('/register', (req, res) => res.status(501).json({ message: 'Register endpoint not implemented yet' })); router.post('/login', (req, res) => res.status(501).json({ message: 'Login endpoint not implemented yet' })); router.post('/request-otp', (req, res) => res.status(501).json({ message: 'Request OTP endpoint not implemented yet' })); router.post('/verify-otp', (req, res) => res.status(501).json({ message: 'Verify OTP endpoint not implemented yet' })); module.exports = router;Step 12: Basic Error Handler (
src/middleware/errorHandler.js)Create a centralized error handler:
// src/middleware/errorHandler.js const logger = require('../utils/logger'); const errorHandler = (err, req, res, next) => { // Log the full error for debugging logger.error({ message: err.message, stack: err.stack, statusCode: err.statusCode, url: req.originalUrl, method: req.method, ip: req.ip, }, 'Unhandled Error'); const statusCode = err.statusCode || 500; const message = err.message || 'Internal Server Error'; // Send error response to client res.status(statusCode).json({ status: 'error', statusCode, message: process.env.NODE_ENV === 'production' && statusCode === 500 ? 'An unexpected error occurred' // Don't leak details in production : message, }); }; module.exports = errorHandler;Step 13: Run the Server
Start the development server:
You should see log output:
Server running on http://localhost:3000. Test the health check endpoint:http://localhost:3000/health.Troubleshooting Common Startup Errors:
Cannot find module './routes'src/routes/index.js(Step 10)connect ECONNREFUSEDbrew services start postgresql@14(macOS) orsudo service postgresql start(Linux)password authentication failed.envDB_USERandDB_PASSWORDin.envEADDRINUSEPORTin.envor stop conflicting processImplement Database Models
Set up Sequelize ORM with PostgreSQL and create the User model for storing authentication data.
Step 1: Initialize Sequelize
Configure Sequelize to use custom directory structure:
// .sequelizerc const path = require('path'); module.exports = { 'config': path.resolve('src', 'config', 'config.json'), 'models-path': path.resolve('src', 'database', 'models'), 'seeders-path': path.resolve('src', 'database', 'seeders'), 'migrations-path': path.resolve('src', 'database', 'migrations') };Initialize Sequelize:
This creates the directory structure defined in
.sequelizerc.Step 2: Configure Database Connection
Sequelize CLI requires a JSON config file but cannot execute JavaScript. Create a JavaScript config module:
// src/config/database.js require('dotenv').config(); 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, dialect: process.env.DB_DIALECT }, test: { username: process.env.DB_USER, password: process.env.DB_PASSWORD, database: `${process.env.DB_NAME}_test`, host: process.env.DB_HOST, port: process.env.DB_PORT, dialect: process.env.DB_DIALECT, logging: false }, production: { 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 || process.env.DB_PORT, dialect: process.env.DB_DIALECT, logging: false } };Update
.sequelizercto reference the JS file:// .sequelizerc const path = require('path'); module.exports = { 'config': path.resolve('src', 'config', 'database.js'), 'models-path': path.resolve('src', 'database', 'models'), 'seeders-path': path.resolve('src', 'database', 'seeders'), 'migrations-path': path.resolve('src', 'database', 'migrations') };Step 3: Create User Model and Migration
Generate the User model and migration:
npm run db:model:create -- --name User --attributes email:string,password:string,phoneNumber:string,isVerified:boolean,sinchVerificationId:string,otpSecret:string,lastOtpSentAt:dateStep 4: Configure Migration
Edit the generated migration file in
src/database/migrations/:// src/database/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, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER }, email: { type: Sequelize.STRING(255), allowNull: false, unique: true, validate: { isEmail: true, } }, password: { type: Sequelize.STRING(255), allowNull: false }, phoneNumber: { type: Sequelize.STRING(20), // E.164 format: +[country code][number] (max 15 digits + prefix) allowNull: false, unique: true }, isVerified: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false // Users start unverified }, sinchVerificationId: { type: Sequelize.STRING(100), // Store temp ID from Sinch verification request allowNull: true }, otpSecret: { type: Sequelize.STRING(255), // Reserved for future TOTP authenticator app support allowNull: true }, lastOtpSentAt: { type: Sequelize.DATE, // Rate limit OTP requests per user allowNull: true }, createdAt: { allowNull: false, type: Sequelize.DATE, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') }, updatedAt: { allowNull: false, type: Sequelize.DATE, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') } }); // Add indexes for frequently queried columns await queryInterface.addIndex('Users', ['email'], { name: 'users_email_idx' }); await queryInterface.addIndex('Users', ['phoneNumber'], { name: 'users_phone_number_idx' }); }, async down(queryInterface, Sequelize) { await queryInterface.dropTable('Users'); } };Field Explanations:
Step 5: Configure User Model
Edit
src/database/models/user.jsto add password comparison and hashing hooks:// src/database/models/user.js 'use strict'; const { Model } = require('sequelize'); const bcrypt = require('bcrypt'); const logger = require('../../utils/logger'); module.exports = (sequelize, DataTypes) => { class User extends Model { // Instance method to compare password async isValidPassword(password) { return bcrypt.compare(password, this.password); } static associate(models) { // Define associations here if needed } } User.init({ email: { type: DataTypes.STRING(255), allowNull: false, unique: true, validate: { isEmail: true, } }, password: { type: DataTypes.STRING(255), allowNull: false }, phoneNumber: { type: DataTypes.STRING(20), allowNull: false, unique: true }, isVerified: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, sinchVerificationId: { type: DataTypes.STRING(100), allowNull: true }, otpSecret: { type: DataTypes.STRING(255), allowNull: true }, lastOtpSentAt: { type: DataTypes.DATE, allowNull: true } }, { sequelize, modelName: 'User', hooks: { // Hash password before creating user beforeCreate: async (user, options) => { if (user.password) { try { const saltRounds = parseInt(process.env.PASSWORD_SALT_ROUNDS, 10) || 10; if (isNaN(saltRounds) || saltRounds < 10) { throw new Error('Invalid PASSWORD_SALT_ROUNDS. Must be integer >= 10.'); } user.password = await bcrypt.hash(user.password, saltRounds); } catch (error) { logger.error('Error hashing password during beforeCreate hook:', error); throw error; // Prevent user creation if hashing fails } } }, // Hash password on update if changed beforeUpdate: async (user, options) => { if (user.changed('password')) { try { const saltRounds = parseInt(process.env.PASSWORD_SALT_ROUNDS, 10) || 10; if (isNaN(saltRounds) || saltRounds < 10) { throw new Error('Invalid PASSWORD_SALT_ROUNDS. Must be integer >= 10.'); } user.password = await bcrypt.hash(user.password, saltRounds); } catch (error) { logger.error('Error hashing password during beforeUpdate hook:', error); throw error; // Prevent update if hashing fails } } } } }); return User; };Step 6: Run Migration
Apply the migration to create the
Userstable:Verify migration success:
# Connect to PostgreSQL psql -U postgres -d sinch_otp_db # List tables \dt # Describe Users table \d "Users" # Exit psql \qYou should see the
Userstable with all columns defined in the migration.Implement Sinch Verification Service
Create the service layer that communicates with Sinch's Verification API to send and verify OTP codes.
Step 1: Create Sinch Service (
src/services/sinch.service.js)// src/services/sinch.service.js const axios = require('axios'); const logger = require('../utils/logger'); class SinchService { constructor() { this.appKey = process.env.SINCH_APP_KEY; this.appSecret = process.env.SINCH_APP_SECRET; this.baseUrl = 'https://verificationapi-v1.sinch.com/verification/v1'; if (!this.appKey || !this.appSecret) { logger.error('Sinch credentials not configured. Set SINCH_APP_KEY and SINCH_APP_SECRET in .env'); } } /** * Start verification process – sends OTP to user * @param {string} phoneNumber - E.164 formatted phone number * @param {string} method - Verification method: 'sms' or 'callout' * @returns {Promise<Object>} Sinch verification response with ID */ async startVerification(phoneNumber, method = 'sms') { try { const url = `${this.baseUrl}/verifications`; const payload = { identity: { type: 'number', endpoint: phoneNumber }, method: method }; logger.info(`Initiating Sinch verification for ${phoneNumber} via ${method}`); const response = await axios.post(url, payload, { auth: { username: this.appKey, password: this.appSecret }, headers: { 'Content-Type': 'application/json' } }); logger.info(`Sinch verification initiated. ID: ${response.data.id}`); return response.data; } catch (error) { logger.error('Error starting Sinch verification:', { message: error.message, response: error.response?.data, status: error.response?.status }); const customError = new Error( error.response?.data?.message || 'Failed to send OTP via Sinch' ); customError.statusCode = error.response?.status || 500; throw customError; } } /** * Verify OTP using verification ID * @param {string} verificationId - Sinch verification ID from startVerification * @param {string} otpCode - User-submitted OTP code * @param {string} method - Verification method: 'sms' or 'callout' * @returns {Promise<Object>} Verification result */ async reportVerificationById(verificationId, otpCode, method = 'sms') { try { const url = `${this.baseUrl}/verifications/id/${verificationId}`; const payload = { method: method, [method]: { code: otpCode } }; logger.info(`Verifying OTP for Sinch ID: ${verificationId}`); const response = await axios.put(url, payload, { auth: { username: this.appKey, password: this.appSecret }, headers: { 'Content-Type': 'application/json' } }); logger.info(`Sinch verification result: ${response.data.status}`); return response.data; } catch (error) { logger.error('Error verifying OTP by ID:', { message: error.message, response: error.response?.data, status: error.response?.status }); const customError = new Error( error.response?.data?.message || 'OTP verification failed' ); customError.statusCode = error.response?.status || 400; throw customError; } } /** * Verify OTP using phone number (alternative to ID-based verification) * @param {string} phoneNumber - E.164 formatted phone number * @param {string} otpCode - User-submitted OTP code * @param {string} method - Verification method: 'sms' or 'callout' * @returns {Promise<Object>} Verification result */ async reportVerificationByNumber(phoneNumber, otpCode, method = 'sms') { try { const url = `${this.baseUrl}/verifications/number/${encodeURIComponent(phoneNumber)}`; const payload = { method: method, [method]: { code: otpCode } }; logger.info(`Verifying OTP for phone: ${phoneNumber}`); const response = await axios.put(url, payload, { auth: { username: this.appKey, password: this.appSecret }, headers: { 'Content-Type': 'application/json' } }); logger.info(`Sinch verification result: ${response.data.status}`); return response.data; } catch (error) { logger.error('Error verifying OTP by number:', { message: error.message, response: error.response?.data, status: error.response?.status }); const customError = new Error( error.response?.data?.message || 'OTP verification failed' ); customError.statusCode = error.response?.status || 400; throw customError; } } } module.exports = new SinchService();Sinch API Response Formats:
Start Verification Response:
{ "id": "1234567890abcdef", "method": "sms", "status": "PENDING" }Verification Report Response (Success):
{ "id": "1234567890abcdef", "method": "sms", "status": "SUCCESSFUL", "reason": "Verification successful" }Verification Report Response (Failure):
{ "id": "1234567890abcdef", "method": "sms", "status": "FAIL", "reason": "Invalid code" }Step 2: Update
.envwith Sinch CredentialsAdd your Sinch API credentials to
.env:Replace
your_actual_sinch_application_keyandyour_actual_sinch_application_secretwith credentials from your Sinch dashboard.Implement Authentication Service
Create the authentication service layer that handles user registration, login, OTP request, and OTP verification.
Step 1: Create Auth Service (
src/services/auth.service.js)// src/services/auth.service.js const { User } = require('../database/models'); const { Op } = require('sequelize'); const logger = require('../utils/logger'); const sinchService = require('./sinch.service'); const { sequelize } = require('../database/models'); const { parsePhoneNumberFromString } = require('libphonenumber-js'); class AuthService { async registerUser({ email, password, phoneNumber }) { try { // Input validation if (!email || !password || !phoneNumber) { const error = new Error('Email, password, and phone number are required.'); error.statusCode = 400; throw error; } // Validate and format phone number const parsedNumber = parsePhoneNumberFromString(phoneNumber); if (!parsedNumber || !parsedNumber.isValid()) { const error = new Error('Invalid phone number format. Use international format (e.g., +14155552671).'); error.statusCode = 400; throw error; } const formattedPhoneNumber = parsedNumber.format('E.164'); // Check if user already exists const existingUser = await User.findOne({ where: { [Op.or]: [{ email }, { phoneNumber: formattedPhoneNumber }] } }); if (existingUser) { const field = existingUser.email === email ? 'Email' : 'Phone number'; const error = new Error(`${field} is already registered.`); error.statusCode = 409; // Conflict throw error; } // Create user (password hashing handled by model hook) const newUser = await User.create({ email, password, phoneNumber: formattedPhoneNumber }); // Remove sensitive fields from response const userJson = newUser.toJSON(); delete userJson.password; delete userJson.sinchVerificationId; delete userJson.otpSecret; logger.info(`User registered: ${userJson.email} (ID: ${userJson.id})`); return userJson; } catch (error) { logger.error(`Error registering user ${email}:`, error); if (!error.statusCode) error.statusCode = 500; throw error; } } async loginUser({ email, password }) { try { const user = await User.findOne({ where: { email } }); if (!user) { const error = new Error('Invalid email or password.'); error.statusCode = 401; // Unauthorized throw error; } const isPasswordValid = await user.isValidPassword(password); if (!isPasswordValid) { const error = new Error('Invalid email or password.'); error.statusCode = 401; throw error; } // Remove sensitive fields const userJson = user.toJSON(); delete userJson.password; delete userJson.sinchVerificationId; delete userJson.otpSecret; logger.info(`User login successful (first factor): ${user.email} (ID: ${user.id})`); return userJson; } catch (error) { logger.error(`Error logging in user ${email}:`, error); if (!error.statusCode) error.statusCode = 500; throw error; } } async requestOtpForUser(identifier) { const findCondition = identifier.email ? { email: identifier.email } : { phoneNumber: identifier.phoneNumber }; const user = await User.findOne({ where: findCondition }); if (!user) { const error = new Error('User not found.'); error.statusCode = 404; throw error; } // Rate limiting: 1 OTP request per 2 minutes per user const now = new Date(); const twoMinutesAgo = new Date(now.getTime() - 2 * 60 * 1000); if (user.lastOtpSentAt && user.lastOtpSentAt > twoMinutesAgo) { const error = new Error('Wait 2 minutes before requesting another OTP.'); error.statusCode = 429; // Too Many Requests throw error; } const verificationMethod = process.env.SINCH_VERIFICATION_METHOD || 'sms'; const transaction = await sequelize.transaction(); try { const sinchResponse = await sinchService.startVerification( user.phoneNumber, verificationMethod ); if (!sinchResponse || !sinchResponse.id) { logger.error('Failed to initiate Sinch verification.', { sinchResponse }); throw new Error('Failed to initiate phone verification.'); } // Store Sinch verification ID and timestamp await User.update( { sinchVerificationId: sinchResponse.id, lastOtpSentAt: new Date() }, { where: { id: user.id }, transaction } ); await transaction.commit(); logger.info(`OTP sent to user ${user.id} via ${verificationMethod}. Sinch ID: ${sinchResponse.id}`); return { message: `OTP sent via ${verificationMethod} to ${user.phoneNumber}.`, }; } catch (error) { await transaction.rollback(); logger.error(`Error requesting OTP for user ${user?.id}:`, error); if (!error.statusCode) error.statusCode = 500; throw error; } } async verifyOtpForUser(identifier, otpCode) { const findCondition = identifier.email ? { email: identifier.email } : { phoneNumber: identifier.phoneNumber }; const user = await User.findOne({ where: findCondition }); if (!user) { const error = new Error('User not found.'); error.statusCode = 404; throw error; } const verificationMethod = process.env.SINCH_VERIFICATION_METHOD || 'sms'; try { let sinchResponse; // Verify using stored ID if recent (within 15 minutes) const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000); if (user.sinchVerificationId && user.lastOtpSentAt > fifteenMinutesAgo) { logger.info(`Verifying OTP for user ${user.id} using Sinch ID: ${user.sinchVerificationId}`); sinchResponse = await sinchService.reportVerificationById( user.sinchVerificationId, otpCode, verificationMethod ); } else { // Fallback: verify using phone number logger.info(`Verifying OTP for user ${user.id} using phone: ${user.phoneNumber}`); sinchResponse = await sinchService.reportVerificationByNumber( user.phoneNumber, otpCode, verificationMethod ); } // Check verification result if (sinchResponse && sinchResponse.status === 'SUCCESSFUL') { const transaction = await sequelize.transaction(); try { await User.update( { isVerified: true, sinchVerificationId: null }, { where: { id: user.id }, transaction } ); await transaction.commit(); logger.info(`User ${user.id} successfully verified phone number.`); const updatedUser = await User.findByPk(user.id, { attributes: { exclude: ['password', 'sinchVerificationId', 'otpSecret', 'lastOtpSentAt'] } }); return updatedUser.toJSON(); } catch (dbError) { await transaction.rollback(); logger.error(`Database error after successful Sinch verification for user ${user.id}:`, dbError); const error = new Error('Verification passed but failed to update account status.'); error.statusCode = 500; throw error; } } else { // Verification failed logger.warn(`OTP verification failed for user ${user.id}. Status: ${sinchResponse?.status}, Reason: ${sinchResponse?.reason}`); const error = new Error('Invalid or expired OTP code.'); error.statusCode = 401; error.details = { sinchStatus: sinchResponse?.status, reason: sinchResponse?.reason }; throw error; } } catch (error) { logger.error(`Error verifying OTP for user ${user?.id}:`, error); if (!error.statusCode) error.statusCode = 500; throw error; } } } module.exports = new AuthService();Rate Limiting Explanation: The 2-minute delay between OTP requests prevents abuse while maintaining usability. Users who don't receive the first OTP can retry after 2 minutes. This protects against:
For high-security applications, consider increasing to 3–5 minutes.
Implement Controllers and Routes
Connect the authentication service to HTTP endpoints with input validation.
Step 1: Create Input Validation Middleware (
src/middleware/validation.js)// src/middleware/validation.js const { body, validationResult } = require('express-validator'); const { parsePhoneNumberFromString } = require('libphonenumber-js'); // Validation rules const registerValidation = [ body('email') .trim() .isEmail() .normalizeEmail() .withMessage('Provide a valid email address.'), body('password') .isLength({ min: 8 }) .withMessage('Password must be at least 8 characters long.') .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) .withMessage('Password must contain at least one uppercase letter, one lowercase letter, and one number.'), body('phoneNumber') .trim() .custom((value) => { const parsed = parsePhoneNumberFromString(value); if (!parsed || !parsed.isValid()) { throw new Error('Provide a valid international phone number (e.g., +14155552671).'); } return true; }) ]; const loginValidation = [ body('email') .trim() .isEmail() .normalizeEmail() .withMessage('Provide a valid email address.'), body('password') .notEmpty() .withMessage('Password is required.') ]; const requestOtpValidation = [ body('email') .optional() .trim() .isEmail() .normalizeEmail() .withMessage('Provide a valid email address.'), body('phoneNumber') .optional() .trim() .custom((value) => { if (!value) return true; // Skip if not provided (email might be used instead) const parsed = parsePhoneNumberFromString(value); if (!parsed || !parsed.isValid()) { throw new Error('Provide a valid international phone number.'); } return true; }), body() .custom((value, { req }) => { if (!req.body.email && !req.body.phoneNumber) { throw new Error('Provide either email or phone number.'); } return true; }) ]; const verifyOtpValidation = [ body('email') .optional() .trim() .isEmail() .normalizeEmail() .withMessage('Provide a valid email address.'), body('phoneNumber') .optional() .trim() .custom((value) => { if (!value) return true; const parsed = parsePhoneNumberFromString(value); if (!parsed || !parsed.isValid()) { throw new Error('Provide a valid international phone number.'); } return true; }), body('otpCode') .trim() .notEmpty() .withMessage('OTP code is required.') .isLength({ min: 4, max: 6 }) .withMessage('OTP code must be 4-6 digits.') .isNumeric() .withMessage('OTP code must contain only numbers.'), body() .custom((value, { req }) => { if (!req.body.email && !req.body.phoneNumber) { throw new Error('Provide either email or phone number.'); } return true; }) ]; // Middleware to check validation results const validate = (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ status: 'error', message: 'Validation failed.', errors: errors.array().map(err => ({ field: err.path, message: err.msg })) }); } next(); }; module.exports = { registerValidation, loginValidation, requestOtpValidation, verifyOtpValidation, validate };Step 2: Implement Auth Controller (
src/controllers/auth.controller.js)// src/controllers/auth.controller.js const authService = require('../services/auth.service'); const logger = require('../utils/logger'); class AuthController { async register(req, res, next) { try { const { email, password, phoneNumber } = req.body; const user = await authService.registerUser({ email, password, phoneNumber }); res.status(201).json({ message: 'User registered successfully. Verify your phone number to complete setup.', userId: user.id }); } catch (error) { next(error); } } async login(req, res, next) { try { const { email, password } = req.body; const user = await authService.loginUser({ email, password }); // Check if user needs OTP verification if (!user.isVerified) { // Automatically request OTP for unverified users try { await authService.requestOtpForUser({ email: user.email }); logger.info(`OTP requested automatically for unverified user ${user.email} after login.`); return res.status(200).json({ message: 'Login successful. OTP sent for verification.', userId: user.id, requiresOtp: true }); } catch (otpError) { logger.error(`Failed to automatically request OTP for ${user.email}:`, otpError); return res.status(200).json({ message: 'Login successful. Failed to send OTP automatically. Request manually.', userId: user.id, requiresOtp: true, otpError: otpError.message }); } } else { // User already verified – grant full access logger.info(`Verified user ${user.email} logged in successfully.`); // TODO: Implement JWT token generation here return res.status(200).json({ message: 'Login successful.', userId: user.id, requiresOtp: false, // token: generateJWT(user) // Implement JWT generation }); } } catch (error) { next(error); } } async requestOtp(req, res, next) { try { const { email, phoneNumber } = req.body; const identifier = email ? { email } : { phoneNumber }; const result = await authService.requestOtpForUser(identifier); res.status(200).json(result); } catch (error) { next(error); } } async verifyOtp(req, res, next) { try { const { email, phoneNumber, otpCode } = req.body; const identifier = email ? { email } : { phoneNumber }; const verifiedUser = await authService.verifyOtpForUser(identifier, otpCode); logger.info(`User ${verifiedUser.email} completed OTP verification.`); // TODO: Implement JWT token generation here res.status(200).json({ message: 'Phone number verified successfully. Access granted.', userId: verifiedUser.id, // token: generateJWT(verifiedUser) // Implement JWT generation }); } catch (error) { next(error); } } } module.exports = new AuthController();Step 3: Update Auth Routes (
src/routes/auth.routes.js)// src/routes/auth.routes.js const express = require('express'); const authController = require('../controllers/auth.controller'); const { registerValidation, loginValidation, requestOtpValidation, verifyOtpValidation, validate } = require('../middleware/validation'); const router = express.Router(); router.post('/register', registerValidation, validate, authController.register); router.post('/login', loginValidation, validate, authController.login); router.post('/request-otp', requestOtpValidation, validate, authController.requestOtp); router.post('/verify-otp', verifyOtpValidation, validate, authController.verifyOtp); module.exports = router;Test Your Implementation
Test the complete authentication flow using Postman, Insomnia, or curl.
Base URL:
http://localhost:3000/api/v1/authStep 1: Register a New User
curl -X POST http://localhost:3000/api/v1/auth/register \ -H "Content-Type: application/json" \ -d '{ "email": "user@example.com", "password": "SecurePass123", "phoneNumber": "+14155552671" }'Expected Response (201 Created):
{ "message": "User registered successfully. Verify your phone number to complete setup.", "userId": 1 }Step 2: Login with Credentials
curl -X POST http://localhost:3000/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{ "email": "user@example.com", "password": "SecurePass123" }'Expected Response (200 OK):
{ "message": "Login successful. OTP sent for verification.", "userId": 1, "requiresOtp": true }The system automatically sends an OTP to your phone number.
Step 3: Verify OTP
Check your phone for the OTP code, then submit:
curl -X POST http://localhost:3000/api/v1/auth/verify-otp \ -H "Content-Type: application/json" \ -d '{ "email": "user@example.com", "otpCode": "123456" }'Expected Response (200 OK):
{ "message": "Phone number verified successfully. Access granted.", "userId": 1 }Step 4: Request OTP Manually (Optional)
If OTP doesn't arrive or expires:
curl -X POST http://localhost:3000/api/v1/auth/request-otp \ -H "Content-Type: application/json" \ -d '{ "email": "user@example.com" }'Expected Response (200 OK):
{ "message": "OTP sent via sms to +14155552671." }Common Test Scenarios:
Implement JWT Authentication (Optional)
Add stateless session management using JSON Web Tokens.
Step 1: Install JWT Library
Step 2: Add JWT Secret to
.envGenerate secure JWT secret:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Step 3: Create JWT Utility (
src/utils/jwt.js)// src/utils/jwt.js const jwt = require('jsonwebtoken'); const logger = require('./logger'); const JWT_SECRET = process.env.JWT_SECRET; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; if (!JWT_SECRET || JWT_SECRET.length < 32) { logger.error('JWT_SECRET not configured or too short. Set in .env (min 32 characters).'); } /** * Generate JWT token for authenticated user * @param {Object} user - User object * @returns {string} JWT token */ function generateToken(user) { const payload = { userId: user.id, email: user.email, isVerified: user.isVerified }; return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, issuer: 'sinch-otp-2fa-app' }); } /** * Verify and decode JWT token * @param {string} token - JWT token * @returns {Object} Decoded payload */ function verifyToken(token) { try { return jwt.verify(token, JWT_SECRET, { issuer: 'sinch-otp-2fa-app' }); } catch (error) { logger.error('JWT verification failed:', error.message); throw error; } } module.exports = { generateToken, verifyToken };Step 4: Create Authentication Middleware (
src/middleware/auth.js)// src/middleware/auth.js const { verifyToken } = require('../utils/jwt'); const logger = require('../utils/logger'); /** * Middleware to authenticate JWT token */ function authenticateToken(req, res, next) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; // Extract "Bearer TOKEN" if (!token) { return res.status(401).json({ status: 'error', message: 'Access token required.' }); } try { const decoded = verifyToken(token); req.user = decoded; // Attach user info to request next(); } catch (error) { logger.warn('Invalid or expired token:', error.message); return res.status(403).json({ status: 'error', message: 'Invalid or expired token.' }); } } /** * Middleware to check if user is verified */ function requireVerified(req, res, next) { if (!req.user || !req.user.isVerified) { return res.status(403).json({ status: 'error', message: 'Phone verification required.' }); } next(); } module.exports = { authenticateToken, requireVerified };Step 5: Update Auth Controller to Issue Tokens
Modify
src/controllers/auth.controller.js:// Add to top of file const { generateToken } = require('../utils/jwt'); // Update login method - replace TODO comment with: const token = generateToken(user); return res.status(200).json({ message: 'Login successful.', userId: user.id, requiresOtp: false, token: token }); // Update verifyOtp method - replace TODO comment with: const token = generateToken(verifiedUser); res.status(200).json({ message: 'Phone number verified successfully. Access granted.', userId: verifiedUser.id, token: token });Step 6: Create Protected Route Example
Add protected endpoint to test authentication:
// src/routes/index.js const { authenticateToken, requireVerified } = require('../middleware/auth'); // Add protected route router.get('/profile', authenticateToken, requireVerified, (req, res) => { res.json({ message: 'Access granted to protected resource.', user: req.user }); });Test JWT Authentication:
# Login to get token curl -X POST http://localhost:3000/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"user@example.com","password":"SecurePass123"}' # Copy token from response, then access protected route curl http://localhost:3000/api/v1/profile \ -H "Authorization: Bearer YOUR_TOKEN_HERE"Production Deployment Checklist
Prepare your application for production deployment.
Security Hardening:
JWT_SECRET(32+ characters, randomly generated)PASSWORD_SALT_ROUNDSto 12 or higher.envfiles (never commit to Git)*wildcard)Environment Configuration:
Deployment Steps:
Run Database Migrations:
Start Production Server:
Use Process Manager (PM2):
Monitoring and Logging:
Scaling Considerations:
Troubleshooting Common Issues
Issue: "Unable to connect to the database"
Causes:
.envSolutions:
# Check PostgreSQL status brew services list # macOS sudo service postgresql status # Linux # Start PostgreSQL brew services start postgresql@14 # macOS sudo service postgresql start # Linux # Verify database exists psql -U postgres -l # Create database if missing createdb -U postgres sinch_otp_dbIssue: "Sinch verification failed" or "401 Unauthorized"
Causes:
Solutions:
SINCH_APP_KEYandSINCH_APP_SECRETin.envcurl -u "YOUR_APP_KEY:YOUR_APP_SECRET" \ https://verificationapi-v1.sinch.com/verification/v1/verificationsIssue: "Password hashing too slow"
Causes:
PASSWORD_SALT_ROUNDSset too highSolutions:
Issue: "OTP not received"
Causes:
Solutions:
Issue: "Too many requests" error
Causes:
Solutions:
RATE_LIMIT_WINDOW_MSandRATE_LIMIT_MAX_REQUESTSin.envNext Steps and Enhancements
Immediate Improvements:
Advanced Features:
Recommended Libraries:
Security Best Practices:
npm auditConclusion
You've built a production-ready SMS-based two-factor authentication system using Sinch, Node.js, Express, and PostgreSQL. This implementation provides:
✅ User registration with email, password, and phone number ✅ Secure password hashing with bcrypt ✅ SMS OTP delivery via Sinch Verification API ✅ Complete authentication flow with verification ✅ Rate limiting and security middleware ✅ Structured logging for production monitoring ✅ Database persistence with Sequelize ORM ✅ Optional JWT token-based sessions
Key Takeaways:
Additional Resources:
For questions or issues, refer to the troubleshooting section or consult the official documentation for each technology.