code examples
code examples
Sinch SMS 2FA with Node.js & Express: Complete OTP Implementation Guide
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.
How to Implement SMS Two-Factor Authentication with Sinch, Node.js & Express
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:
- 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
Authentication Flow:
User → Register (email + password + phone) → Login (email + password)
→ Request OTP → Receive SMS → Submit OTP → Verify → Access Granted
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):
- 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:2pxPrerequisites
Before starting, ensure you have:
- Node.js 20+ and npm installed. Verify with
node --versionandnpm --version. - PostgreSQL 14+ server running locally or accessible remotely. You must create the database specified in your
.envfile 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.
- Code editor (VS Code, WebStorm, or similar).
- API testing tool (Postman, Insomnia, or curl).
Install PostgreSQL (if needed):
| Operating System | Installation Command |
|---|---|
| macOS (Homebrew) | brew install postgresql@14 && brew services start postgresql@14 |
| Ubuntu/Debian | sudo apt update && sudo apt install postgresql-14 |
| Windows | Download installer from https://www.postgresql.org/download/windows/ |
Create 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:
- Registering users with validated phone numbers
- Authenticating users with email and password
- Requiring Sinch-powered OTP verification for enhanced security
- Implementing error handling, security middleware, structured logging, and database management
- Following production-ready patterns for Node.js authentication systems
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.json file with default settings.
Step 2: Install Core Dependencies
Install Express, database interaction, security, environment variables, HTTP requests, logging, and phone number validation:
npm install express pg sequelize bcrypt dotenv express-validator helmet express-rate-limit axios pino libphonenumber-jsPackage 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/formatting
Step 3: Install Development Dependencies
Install nodemon for automatic server restarts, sequelize-cli for migrations, and pino-pretty for human-readable logs during development:
npm install --save-dev nodemon sequelize-cli pino-prettyStep 4: Configure package.json Scripts
Add 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:
node-sinch-otp-2fa/
├── node_modules/
├── src/
│ ├── config/ # Configuration files (DB, etc.)
│ ├── controllers/ # Request handlers
│ ├── database/ # Migrations, seeders, models
│ │ ├── migrations/
│ │ ├── models/
│ │ └── seeders/ # Optional: for sample data
│ ├── middleware/ # Custom middleware (auth, validation, error)
│ ├── routes/ # API route definitions
│ ├── services/ # Business logic
│ ├── utils/ # Utility functions (logging, etc.)
│ └── index.js # Main application entry point
├── .env # Environment variables (DO NOT COMMIT)
├── .gitignore # Files/folders to ignore in Git
├── package-lock.json
└── package.jsonCreate 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 .gitignore
Add node_modules, .env, log files, build artifacts, and IDE configurations to .gitignore:
# .gitignore
# Dependencies
node_modules/
# Environment variables
.env
.env.local
.env.*.local
# Logs
*.log
logs/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist/
build/
# IDE configurations
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.dbStep 7: Set Up Environment Variables (.env)
Create the .env file in the project root with initial configuration:
# .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 voiceImportant: 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:
// 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:
npm install corsThen add to src/index.js after 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:
npm run devYou should see log output: Server running on http://localhost:3000. Test the health check endpoint: http://localhost:3000/health.
Troubleshooting Common Startup Errors:
| Error | Cause | Solution |
|---|---|---|
Cannot find module './routes' | Missing routes/index.js file | Create src/routes/index.js (Step 10) |
connect ECONNREFUSED | PostgreSQL not running | Start PostgreSQL: brew services start postgresql@14 (macOS) or sudo service postgresql start (Linux) |
password authentication failed | Wrong DB credentials in .env | Update DB_USER and DB_PASSWORD in .env |
EADDRINUSE | Port 3000 already in use | Change PORT in .env or stop conflicting process |
Implement 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:
npx sequelize-cli initThis 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 .sequelizerc to 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:
- email: User's email address (unique identifier for login)
- password: Bcrypt-hashed password (never stored in plain text)
- phoneNumber: E.164 formatted phone number (e.g., +14155552671)
- isVerified: Boolean flag indicating successful OTP verification
- sinchVerificationId: Temporary storage for Sinch API verification session ID
- otpSecret: Reserved field for potential future TOTP app support (unused in this tutorial)
- lastOtpSentAt: Timestamp of last OTP request (enables rate limiting per user)
Step 5: Configure User Model
Edit src/database/models/user.js to 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 Users table:
npm run db:migrateVerify 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 Users table 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 .env with Sinch Credentials
Add your Sinch API credentials to .env:
# 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 callsReplace 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)
// 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:
- 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.
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/auth
Step 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:
| Scenario | Expected Result | Status Code |
|---|---|---|
| Register with duplicate email | "Email is already registered." | 409 |
| Login with wrong password | "Invalid email or password." | 401 |
| Request OTP within 2 minutes | "Wait 2 minutes before requesting another OTP." | 429 |
| Submit invalid OTP | "Invalid or expired OTP code." | 401 |
| Submit OTP for non-existent user | "User not found." | 404 |
Implement JWT Authentication (Optional)
Add stateless session management using JSON Web Tokens.
Step 1: Install JWT Library
npm install jsonwebtokenStep 2: Add JWT Secret to .env
# Add to .env
JWT_SECRET=your_super_secret_jwt_key_minimum_32_characters_long
JWT_EXPIRES_IN=7d # Token validity periodGenerate 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:
- Set strong
JWT_SECRET(32+ characters, randomly generated) - Set
PASSWORD_SALT_ROUNDSto 12 or higher - Use environment-specific
.envfiles (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:
# 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 10Deployment Steps:
-
Run Database Migrations:
bashNODE_ENV=production npm run db:migrate -
Start Production Server:
bashNODE_ENV=production npm start -
Use Process Manager (PM2):
bashnpm install -g pm2 pm2 start src/index.js --name sinch-otp-app pm2 startup pm2 save
Monitoring and Logging:
- Configure log aggregation (e.g., Datadog, LogRocket, Sentry)
- Set up health check monitoring
- Monitor Sinch API usage and costs
- Track authentication failure rates
- Set up alerts for error spikes
Scaling Considerations:
- Use Redis for session storage (if not using JWT)
- Implement database read replicas for high traffic
- Use CDN for static assets
- Configure horizontal scaling with load balancer
- Implement queue system for OTP requests (e.g., Bull with Redis)
Troubleshooting Common Issues
Issue: "Unable to connect to the database"
Causes:
- PostgreSQL not running
- Wrong credentials in
.env - Database doesn't exist
Solutions:
# 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:
- Invalid Sinch credentials
- Incorrect API endpoint
- Network/firewall blocking requests
Solutions:
- Verify
SINCH_APP_KEYandSINCH_APP_SECRETin.env - Check Sinch dashboard for correct credentials
- Test API credentials with curl:
bash
curl -u "YOUR_APP_KEY:YOUR_APP_SECRET" \ https://verificationapi-v1.sinch.com/verification/v1/verifications
Issue: "Password hashing too slow"
Causes:
PASSWORD_SALT_ROUNDSset too high
Solutions:
- Lower to 10 for development
- Use 12 for production (acceptable 400ms delay)
- Never go below 10 rounds
Issue: "OTP not received"
Causes:
- Invalid phone number format
- Phone number not SMS-capable
- Carrier blocking
- Sinch account balance depleted
Solutions:
- Verify phone number uses E.164 format (+14155552671)
- Test with different phone number
- Check Sinch dashboard for delivery logs
- Verify Sinch account has sufficient balance
Issue: "Too many requests" error
Causes:
- Hitting rate limit (user or IP)
- Multiple OTP requests within 2 minutes
Solutions:
- Wait for rate limit window to expire
- Adjust
RATE_LIMIT_WINDOW_MSandRATE_LIMIT_MAX_REQUESTSin.env - Implement per-user rate limiting separate from IP limiting
Next Steps and Enhancements
Immediate Improvements:
- Implement email verification alongside phone verification
- Add password reset flow with SMS OTP
- Create user profile management endpoints
- Implement refresh token rotation for JWT
- Add email notifications for security events
Advanced Features:
- 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
Additional Resources:
- Sinch Verification API Documentation
- OWASP Authentication Cheat Sheet
- Node.js Security Best Practices
- Express.js Security Best Practices
For questions or issues, refer to the troubleshooting section or consult the official documentation for each technology.