code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / Sinch

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:

LibraryVersionPurpose
dotenv16.3+Environment variable management from .env file
express-validator7.0+Input validation and sanitization middleware
helmet7.1+Security middleware for HTTP header protection
express-rate-limit7.1+Rate limiting middleware for brute-force attack prevention
axios1.6+Promise-based HTTP client for Sinch API requests
libphonenumber-js1.10+Phone number parsing, validation, and E.164 formatting
pino8.16+Fast, low-overhead JSON logger for production environments

System Architecture:

mermaid
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.
  • Code editor (VS Code, WebStorm, or similar).
  • API testing tool (Postman, Insomnia, or curl).

Install PostgreSQL (if needed):

Operating SystemInstallation Command
macOS (Homebrew)brew install postgresql@14 && brew services start postgresql@14
Ubuntu/Debiansudo apt update && sudo apt install postgresql-14
WindowsDownload installer from https://www.postgresql.org/download/windows/

Create PostgreSQL Database:

bash
# Using psql CLI
psql -U postgres
CREATE DATABASE sinch_otp_db;
\q

# Or using createdb utility
createdb -U postgres sinch_otp_db

What 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:

bash
mkdir node-sinch-otp-2fa
cd node-sinch-otp-2fa
npm init -y

This 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:

bash
npm install express pg sequelize bcrypt dotenv express-validator helmet express-rate-limit axios pino libphonenumber-js

Package Purposes:

  • express: Web framework
  • pg: PostgreSQL client for Node.js (used by Sequelize)
  • sequelize: ORM for Node.js
  • bcrypt: Password hashing library (uses CPU-intensive key derivation to resist brute-force attacks)
  • dotenv: Loads environment variables from .env
  • express-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 middleware
  • axios: HTTP client for Sinch API calls
  • pino: JSON logger
  • libphonenumber-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:

bash
npm install --save-dev nodemon sequelize-cli pino-pretty

Step 4: Configure package.json Scripts

Add scripts to your package.json:

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:

text
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.json

Create these directories:

bash
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.js

Step 6: Configure .gitignore

Add node_modules, .env, log files, build artifacts, and IDE configurations to .gitignore:

text
# .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.db

Step 7: Set Up Environment Variables (.env)

Create the .env file in the project root with initial configuration:

dotenv
# .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.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:

javascript
// 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 testing

CORS Configuration (if needed for frontend):

If you're building a separate frontend application, add CORS middleware:

bash
npm install cors

Then add to src/index.js after line 16:

javascript
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:

javascript
// 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)

javascript
// 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:

javascript
// 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:

bash
npm run dev

You should see log output: Server running on http://localhost:3000. Test the health check endpoint: http://localhost:3000/health.

Troubleshooting Common Startup Errors:

ErrorCauseSolution
Cannot find module './routes'Missing routes/index.js fileCreate src/routes/index.js (Step 10)
connect ECONNREFUSEDPostgreSQL not runningStart PostgreSQL: brew services start postgresql@14 (macOS) or sudo service postgresql start (Linux)
password authentication failedWrong DB credentials in .envUpdate DB_USER and DB_PASSWORD in .env
EADDRINUSEPort 3000 already in useChange 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:

javascript
// .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:

bash
npx sequelize-cli init

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:

javascript
// 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:

javascript
// .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:

bash
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/:

javascript
// 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:

javascript
// 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:

bash
npm run db:migrate

Verify migration success:

bash
# Connect to PostgreSQL
psql -U postgres -d sinch_otp_db

# List tables
\dt

# Describe Users table
\d "Users"

# Exit psql
\q

You 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)

javascript
// 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:

json
{
  "id": "1234567890abcdef",
  "method": "sms",
  "status": "PENDING"
}

Verification Report Response (Success):

json
{
  "id": "1234567890abcdef",
  "method": "sms",
  "status": "SUCCESSFUL",
  "reason": "Verification successful"
}

Verification Report Response (Failure):

json
{
  "id": "1234567890abcdef",
  "method": "sms",
  "status": "FAIL",
  "reason": "Invalid code"
}

Step 2: Update .env with Sinch Credentials

Add your Sinch API credentials to .env:

dotenv
# 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.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)

javascript
// 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)

javascript
// 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)

javascript
// 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

bash
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):

json
{
  "message": "User registered successfully. Verify your phone number to complete setup.",
  "userId": 1
}

Step 2: Login with Credentials

bash
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):

json
{
  "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:

bash
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):

json
{
  "message": "Phone number verified successfully. Access granted.",
  "userId": 1
}

Step 4: Request OTP Manually (Optional)

If OTP doesn't arrive or expires:

bash
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):

json
{
  "message": "OTP sent via sms to +14155552671."
}

Common Test Scenarios:

ScenarioExpected ResultStatus 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

bash
npm install jsonwebtoken

Step 2: Add JWT Secret to .env

dotenv
# Add to .env
JWT_SECRET=your_super_secret_jwt_key_minimum_32_characters_long
JWT_EXPIRES_IN=7d # Token validity period

Generate secure JWT secret:

bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Step 3: Create JWT Utility (src/utils/jwt.js)

javascript
// 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)

javascript
// 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:

javascript
// 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:

javascript
// 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:

bash
# 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:

dotenv
# 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

Deployment Steps:

  1. Run Database Migrations:

    bash
    NODE_ENV=production npm run db:migrate
  2. Start Production Server:

    bash
    NODE_ENV=production npm start
  3. Use Process Manager (PM2):

    bash
    npm 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:

bash
# 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_db

Issue: "Sinch verification failed" or "401 Unauthorized"

Causes:

  • Invalid Sinch credentials
  • Incorrect API endpoint
  • Network/firewall blocking requests

Solutions:

  • Verify SINCH_APP_KEY and SINCH_APP_SECRET in .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_ROUNDS set 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_MS and RATE_LIMIT_MAX_REQUESTS in .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:

FeatureLibraryPurpose
Email sendingNodemailerTransactional emails
Job queuesBullBackground OTP processing
Session storageioredisRedis client for sessions
API documentationSwagger/OpenAPIAuto-generated API docs
TestingJest + SupertestUnit 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:

For questions or issues, refer to the troubleshooting section or consult the official documentation for each technology.