code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

Sinch SMS OTP 2FA with Fastify Node.js: Complete Implementation Guide 2025

Build production-ready SMS two-factor authentication using Sinch Verification API and Fastify. Step-by-step TypeScript tutorial with Prisma, JWT, and secure OTP implementation for Node.js applications.

Build SMS OTP Two-Factor Authentication with Sinch and Fastify

Build secure SMS-based two-factor authentication (2FA) using Sinch Verification API within a Fastify Node.js application. This guide covers project setup, Sinch SMS OTP integration, secure JWT authentication, error handling, and deployment best practices for production environments.

Important Security Note: SMS-based OTP has known vulnerabilities (SIM swapping, SMS interception, phishing). Per OWASP MFA guidelines, do not use SMS 2FA as the sole protection for applications containing Personally Identifiable Information (PII) or financial data. Consider TOTP authenticator apps or hardware tokens (e.g., FIDO2/WebAuthn) for high-security applications. This guide demonstrates SMS 2FA implementation for educational purposes and moderate-security use cases.

Target Audience: Developers familiar with Node.js and potentially Fastify, looking to add robust SMS OTP verification to their applications. Goal: Build a production-ready Fastify API that handles user registration, login, and secures sessions using Sinch SMS OTP verification. Outcome: A secure, documented, and testable API backend with SMS 2FA.

What You Will Build:

  • A Fastify API backend using TypeScript.
  • User registration and basic password-based login.
  • Integration with Sinch Verification API for sending and verifying SMS OTP codes.
  • Secure session management (e.g., using JWT) enhanced with 2FA status.
  • API endpoints for initiating and verifying OTPs.

Why This Approach?

  • Fastify: A high-performance, low-overhead Node.js web framework focused on developer experience and speed.
  • Sinch Verification API: A dedicated service for handling the complexities of OTP generation, delivery (SMS, voice, flashcall, data), and verification – simplifying implementation and improving reliability. Using a dedicated service offloads concerns like number formatting, carrier deliverability, and OTP lifecycle management.
  • TypeScript: Provides static typing for better code maintainability, scalability, and reduced runtime errors.
  • Database (Prisma): Simplifies database interactions with type safety and migrations (using PostgreSQL in this example).

System Architecture:

text
+-------------+       +-----------------+      +-----------------+      +-----------------+
| End User    | <---> | Frontend App    | <--> | Fastify Backend | <--> | Sinch Platform  |
| (Browser/   |       | (React/Vue/etc.)|      | (Node.js/TS)    |      | (Verification   |
| Mobile App) |       +-----------------+      +-------+---------+      | API via SDK)    |
+-------------+                                        |                +-----------------+
                                                       |
                                               +-------+---------+
                                               | Database        |
                                               | (PostgreSQL/    |
                                               |  Prisma)        |
                                               +-----------------+
  1. User Action: User initiates login or a sensitive action requiring 2FA.
  2. Frontend Request: Frontend sends credentials (and potentially phone number) to the Fastify backend.
  3. Backend Logic (Login): Backend verifies username/password against the database.
  4. Backend Logic (OTP Initiate): If credentials are valid (or upon registration confirmation), backend calls the Sinch SDK to initiate SMS verification for the user's phone number.
  5. Sinch Sends SMS: Sinch generates an OTP and sends it via SMS to the user's phone.
  6. User Enters OTP: User receives the SMS and enters the OTP into the frontend.
  7. Frontend Request (Verify): Frontend sends the entered OTP to the Fastify backend.
  8. Backend Logic (OTP Verify): Backend calls the Sinch SDK to report (verify) the entered OTP against the phone number.
  9. Sinch Verifies: Sinch checks if the OTP is correct for that number and initiation request.
  10. Backend Response: Backend receives success/failure from Sinch. If successful, it updates the user's session/state to indicate 2FA completion and grants access. If failed, it returns an error.
  11. Frontend Update: Frontend reflects the success or failure to the user.

Prerequisites:

  • Node.js (v20 or later required – v18 reached EOL April 30, 2025; current LTS versions are v20 "Iron" and v22 "Jod") and npm/yarn installed.
  • A Sinch Account: Sign up at Sinch.com and create a Verification App in your Sinch Dashboard.
  • A mobile phone number capable of receiving SMS messages for testing.
  • Basic understanding of TypeScript, REST APIs, and async/await.
  • Docker and Docker Compose (optional, for running a local PostgreSQL database).
  • A code editor (e.g., VS Code).
  • curl or a tool like Postman/Insomnia for API testing.

1. Fastify Project Setup with TypeScript and Sinch SDK

Initialize your Fastify project using TypeScript and set up the basic structure.

1.1 Initialize Project & Install Dependencies

Run these commands:

bash
# Create project directory and navigate into it
mkdir fastify-sinch-otp
cd fastify-sinch-otp

# Initialize Node.js project
npm init -y

# Install Fastify and core dependencies (Fastify v5 current as of 2025)
npm install fastify @fastify/sensible

# Install TypeScript and related development dependencies
npm install --save-dev typescript @types/node ts-node nodemon @tsconfig/node20

# Install Prisma (ORM) and PostgreSQL driver
npm install @prisma/client
npm install --save-dev prisma pg # pg is the Node.js driver for PostgreSQL

# Install Sinch Verification SDK
npm install @sinch/verification

# Install libraries for environment variables and validation
npm install dotenv fastify-type-provider-zod zod

# Install libraries for JWT authentication and password hashing
npm install @fastify/jwt bcrypt
npm install --save-dev @types/bcrypt @types/jsonwebtoken # @types/jsonwebtoken might be implicitly handled by @fastify/jwt, but explicit install is safe

1.2 Configure TypeScript (tsconfig.json)

Create a tsconfig.json file in the project root:

json
// tsconfig.json
{
  "extends": "@tsconfig/node20/tsconfig.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src",
    "module": "CommonJS",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "sourceMap": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "**/*.spec.ts"]
}

Why these settings?

  • extends: Inherits sensible defaults for Node.js 20 (current LTS).
  • outDir, rootDir: Define project structure for source and compiled files.
  • module: CommonJS is standard for Node.js unless you're specifically setting up ES Modules.
  • strict: Catches more potential errors during development.
  • esModuleInterop, forceConsistentCasingInFileNames, skipLibCheck: Common settings for compatibility and faster builds.
  • sourceMap: Crucial for debugging compiled code.

1.3 Configure Development Scripts (package.json)

Add these scripts to your package.json:

json
// package.json (add or modify the "scripts" section)
"scripts": {
  "build": "tsc",
  "start": "node dist/server.js",
  "dev": "nodemon --watch src --ext ts --exec ts-node src/server.ts",
  "test": "vitest run",
  "prisma:dev:deploy": "prisma migrate deploy",
  "prisma:dev:migrate": "prisma migrate dev",
  "prisma:generate": "prisma generate",
  "prisma:studio": "prisma studio"
},
  • build: Compile TypeScript to JavaScript
  • start: Run the compiled JavaScript application (for production)
  • dev: Run the application using ts-node and nodemon for automatic restarts during development
  • test: Run automated tests (example uses Vitest; replace with your test runner like Jest)
  • prisma:*: Manage database migrations and generate the Prisma client

1.4 Project Structure

Create this directory structure:

  • fastify-sinch-otp/
    • prisma/
      • schema.prisma # Prisma schema definition
      • migrations/ # Database migration files (generated)
    • src/
      • modules/ # Feature modules (e.g., auth, users)
        • auth/
          • auth.controller.ts
          • auth.routes.ts
          • auth.schema.ts # Zod schemas & type definitions
          • auth.service.ts
      • plugins/ # Fastify plugins (e.g., db client, auth)
        • prisma.ts
        • sinch.ts
        • jwtAuth.ts
      • config/ # Configuration files/logic
        • index.ts
      • types/ # Global or shared type definitions
        • fastify-jwt.d.ts # JWT type augmentations
      • utils/ # Utility functions
        • hash.ts
      • app.ts # Main Fastify application setup
      • server.ts # Entry point, starts the server
    • .env # Environment variables (ignored by git)
    • .env.example # Example environment variables
    • .gitignore
    • package.json
    • package-lock.json
    • tsconfig.json

Why this structure?

  • Modularity: Grouping features (like auth) into modules makes the codebase easier to navigate and maintain.
  • Separation of Concerns: Routes handle HTTP requests/responses, controllers orchestrate, services contain business logic, schemas define data shapes, and plugins encapsulate reusable setup (like DB connection).
  • Configuration: Centralizing configuration makes it easier to manage settings across different environments.
  • Types: Dedicated types directory for global type definitions and augmentations (like for Fastify plugins).

1.5 Environment Variables (.env and .env.example)

Create .env.example with these placeholders:

ini
# .env.example

# Application Configuration
NODE_ENV=development
PORT=3000
HOST=0.0.0.0 # Listen on all available network interfaces

# Database Configuration (PostgreSQL example)
DATABASE_URL="postgresql://user:password@localhost:5432/mydatabase?schema=public"

# Sinch Configuration
# Get these from your Sinch Dashboard -> Verification -> Your App
SINCH_APPLICATION_KEY=YOUR_SINCH_APP_KEY
SINCH_APPLICATION_SECRET=YOUR_SINCH_APP_SECRET

# JWT Configuration
JWT_SECRET=a_very_strong_and_long_secret_key_please_change_me # Use a secure random string (minimum 64 characters per OWASP)
JWT_EXPIRES_IN=30m # Standard access token lifetime per OWASP: 15-30 min idle timeout
JWT_OTP_EXPIRES_IN=5m # How long a temporary pre-OTP token lasts

Create a .env file (copy from .env.example) and fill in your actual development values. Add .env to your .gitignore file to prevent committing secrets.

text
# .gitignore
node_modules
dist
.env
npm-debug.log*
yarn-debug.log*
yarn-error.log*

Why .env? Keeps sensitive information (API keys, database URLs, secrets) out of your codebase and makes configuration environment-specific.

Security Best Practice: For production, use a secrets management service (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault) instead of .env files. Never commit secrets to version control.

1.6 Docker Setup for PostgreSQL (Optional)

If you don't have PostgreSQL running locally, create this docker-compose.yml file:

yaml
# docker-compose.yml
version: '3.8'
services:
  postgres:
    image: postgres:15
    container_name: fastify_sinch_db
    restart: always
    ports:
      - "5432:5432" # Expose port 5432 locally
    environment:
      POSTGRES_USER: user        # Match your .env DATABASE_URL
      POSTGRES_PASSWORD: password # Match your .env DATABASE_URL
      POSTGRES_DB: mydatabase   # Match your .env DATABASE_URL
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Run docker-compose up -d to start the database container in the background.


2. Database Schema and User Model (Prisma)

Use Prisma to define your database schema and interact with the database.

2.1 Define Prisma Schema

Edit prisma/schema.prisma:

prisma
// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL") // Loads from .env file
}

model User {
  id        String   @id @default(cuid()) // Unique identifier
  email     String   @unique             // User's email, must be unique
  password  String                       // Hashed password
  name      String?                      // Optional user name
  phone     String?  @unique             // User's phone number, unique if present
                                         // Make required if needed for your flow

  // Timestamps
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

// You could add more models here (e.g., Session, AuditLog) if needed

Why this schema?

  • id: Standard unique primary key.
  • email, password: Essential for basic authentication.
  • phone: Required for Sinch SMS OTP. Making it @unique prevents multiple accounts using the same phone number for verification. Decide if it should be nullable or required based on your signup flow.
  • createdAt, updatedAt: Standard practice for tracking record changes.

2.2 Generate Prisma Client and Run Initial Migration

  1. Generate Client: Create the type-safe Prisma client based on your schema.
    bash
    npx prisma generate
  2. Create Migration: Create the initial SQL migration file based on the schema changes. Use a descriptive name.
    bash
    npx prisma migrate dev --name init
    This command:
    • Creates the prisma/migrations directory if it doesn't exist.
    • Generates a new SQL migration file inside a timestamped folder (e.g., prisma/migrations/20250420000000_init/migration.sql).
    • Applies the migration to your database (creating the User table).
    • Ensures the Prisma client is up-to-date.

2.3 Create Prisma Plugin for Fastify

Create src/plugins/prisma.ts to make the Prisma client available throughout your Fastify application.

typescript
// src/plugins/prisma.ts
import fp from 'fastify-plugin';
import { PrismaClient } from '@prisma/client';

declare module 'fastify' {
  interface FastifyInstance {
    prisma: PrismaClient;
  }
}

export default fp(async (fastify) => {
  const prisma = new PrismaClient();

  await prisma.$connect(); // Connect to the database when the plugin loads

  // Make Prisma Client available through the fastify instance: fastify.prisma
  fastify.decorate('prisma', prisma);

  // Add a hook to disconnect Prisma Client when the server shuts down
  fastify.addHook('onClose', async (instance) => {
    await instance.prisma.$disconnect();
  });
});

Why this plugin?

  • Encapsulates Prisma client initialization.
  • Uses fastify-plugin to avoid encapsulation issues and make fastify.prisma available globally within the Fastify instance.
  • Handles connecting and disconnecting gracefully.

3. Implementing Core Functionality (Auth Service & Controller)

Build the core logic for user registration, login, and OTP preparation.

3.1 Configuration Setup

Create src/config/index.ts to load environment variables safely.

typescript
// src/config/index.ts
import dotenv from 'dotenv';
import { z } from 'zod';

dotenv.config(); // Load .env file

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.coerce.number().default(3000),
  HOST: z.string().default('0.0.0.0'),
  DATABASE_URL: z.string().url(),
  SINCH_APPLICATION_KEY: z.string().min(1, "Sinch Application Key is required"),
  SINCH_APPLICATION_SECRET: z.string().min(1, "Sinch Application Secret is required"),
  JWT_SECRET: z.string().min(64, "JWT Secret must be at least 64 characters long per OWASP recommendations"),
  JWT_EXPIRES_IN: z.string().default('30m'), // Per OWASP: 15-30 min idle timeout
  JWT_OTP_EXPIRES_IN: z.string().default('5m'),
});

// Validate environment variables at startup
try {
  const config = envSchema.parse(process.env);
  console.log("✓ Environment variables loaded successfully.");

  // Make config available globally (or export selectively)
  // Option 1: Simple export
  // export default config;

  // Option 2: More structured export (if needed)
  const appConfig = {
      env: config.NODE_ENV,
      port: config.PORT,
      host: config.HOST,
      databaseUrl: config.DATABASE_URL,
      sinch: {
          appKey: config.SINCH_APPLICATION_KEY,
          appSecret: config.SINCH_APPLICATION_SECRET,
      },
      jwt: {
          secret: config.JWT_SECRET,
          expiresIn: config.JWT_EXPIRES_IN,
          otpExpiresIn: config.JWT_OTP_EXPIRES_IN,
      }
  };
  console.log("✓  App Config:", { // Log non-sensitive parts
    env: appConfig.env,
    port: appConfig.port,
    host: appConfig.host,
    jwt: { expiresIn: appConfig.jwt.expiresIn, otpExpiresIn: appConfig.jwt.otpExpiresIn }
  });
  // Export the structured config
  // (Remove sensitive details like full DB URL, secrets from logs in production)
  if (appConfig.env !== 'development') {
      console.log("✓ Sinch App Key: [Loaded]"); // Avoid logging keys in prod
  } else {
      console.log(`✓ Sinch App Key: ${appConfig.sinch.appKey.substring(0, 5)}...`);
  }
  export default appConfig;


} catch (error) {
  if (error instanceof z.ZodError) {
    console.error("✗ Invalid environment variables:", error.format());
  } else {
    console.error("✗ Error loading environment variables:", error);
  }
  process.exit(1); // Exit if config is invalid
}

Why Zod for config? Validates required variables and enforces correct types at application startup, preventing runtime errors.

3.2 Zod Schemas and Type Definitions

Create src/modules/auth/auth.schema.ts to define input shapes using Zod and centralize JWT payload types.

typescript
// src/modules/auth/auth.schema.ts
import { z } from 'zod';

// --- Input Schemas ---

// Schema for user registration input
export const registerUserSchema = z.object({
  email: z.string().email({ message: "Invalid email address" }),
  password: z.string().min(8, { message: "Password must be at least 8 characters long" }),
  name: z.string().optional(),
  phone: z.string().min(10, { message: "Phone number seems too short" })
             .max(15, { message: "Phone number seems too long"})
             .regex(/^\+?[1-9]\d{1,14}$/, { message: "Invalid phone number format (E.164 expected: +[country code][number], max 15 digits total, e.g., +14155552671)"}), // E.164 format per ITU-T Recommendation E.164
});
export type RegisterUserInput = z.infer<typeof registerUserSchema>;

// Schema for user login input
export const loginUserSchema = z.object({
  email: z.string().email(),
  password: z.string(),
});
export type LoginUserInput = z.infer<typeof loginUserSchema>;

// Schema for initiating OTP
export const initiateOtpSchema = z.object({
    // Typically, the phone number comes from the logged-in user's profile (via JWT)
    // If allowing arbitrary numbers, add validation here:
    // phone: z.string().min(10).max(15).regex(/^\+?[1-9]\d{1,14}$/),
});
export type InitiateOtpInput = z.infer<typeof initiateOtpSchema>; // May not be needed if using user context


// Schema for verifying OTP
export const verifyOtpSchema = z.object({
  otp: z.string().length(4, { message: "OTP must be 4 digits" }) // Adjust length based on Sinch config (default 4-6 digits)
           .regex(/^\d+$/, { message: "OTP must contain only digits" }),
});
export type VerifyOtpInput = z.infer<typeof verifyOtpSchema>;

// --- Response Schemas (Examples) ---

export const authResponseSchema = z.object({
    accessToken: z.string()
});

export const userResponseSchema = z.object({
    id: z.string(),
    email: z.string(),
    name: z.string().nullable(),
    phone: z.string().nullable(),
    createdAt: z.date(),
});

export const loginResponseSchema = z.object({
    otpToken: z.string().optional(),
    accessToken: z.string().optional(),
    message: z.string().optional()
});

export const messageResponseSchema = z.object({
    message: z.string()
});


// --- JWT Payload Types ---

export interface AuthJWTPayload {
  id: string;
  email: string;
  otpVerified: boolean; // Flag to track OTP status
}

export interface OtpJWTPayload {
    id: string;
    phone: string; // Include phone needed for OTP verification
}

Why Zod schemas? Provides runtime validation of request bodies and parameters, ensuring data integrity before it reaches your service logic. Enables type inference for request handlers and improves consistency by centralizing JWT types.

3.3 Password Hashing Utility

Create src/utils/hash.ts:

typescript
// src/utils/hash.ts
import bcrypt from 'bcrypt';

const SALT_ROUNDS = 12; // Per OWASP 2024-2025 recommendations (minimum 10, recommended 12-14 for balance of security and performance)

export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS);
}

export async function comparePassword(plain: string, hash: string): Promise<boolean> {
  return bcrypt.compare(plain, hash);
}

Why bcrypt? The standard and secure way to hash passwords per OWASP Password Storage Cheat Sheet. Never store plain text passwords. bcrypt handles salting automatically and resists brute-force attacks.

3.4 Auth Service Logic

Create src/modules/auth/auth.service.ts. This handles the core business logic, interacting with the database and hashing.

typescript
// src/modules/auth/auth.service.ts
import { PrismaClient, User } from '@prisma/client';
import { hashPassword, comparePassword } from '../../utils/hash';
import { RegisterUserInput, LoginUserInput } from './auth.schema';
import { BadRequest, NotFound, Unauthorized } from 'http-errors'; // Use http-errors for standard HTTP errors

export class AuthService {
  constructor(private prisma: PrismaClient) {} // Inject Prisma client

  async registerUser(input: RegisterUserInput): Promise<Omit<User, 'password'>> {
    const { email, password, name, phone } = input;

    // Check if user or phone already exists
    const existingUser = await this.prisma.user.findFirst({
      where: { OR: [{ email }, { phone }] }, // Check uniqueness for both email and phone
    });

    if (existingUser) {
        if (existingUser.email === email) {
            throw new BadRequest('User with this email already exists.');
        }
        if (existingUser.phone === phone) {
             throw new BadRequest('User with this phone number already exists.');
        }
    }

    const hashedPassword = await hashPassword(password);

    const user = await this.prisma.user.create({
      data: {
        email,
        password: hashedPassword,
        name,
        phone,
      },
    });

    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const { password: _, ...userWithoutPassword } = user; // Exclude password from return
    return userWithoutPassword;
  }

  async loginUser(input: LoginUserInput): Promise<User> {
    const { email, password } = input;

    const user = await this.prisma.user.findUnique({
      where: { email },
    });

    if (!user) {
      throw new NotFound('User not found.'); // More specific than Unauthorized before checking password
    }

    const isPasswordValid = await comparePassword(password, user.password);

    if (!isPasswordValid) {
      throw new Unauthorized('Invalid password.');
    }

    // User found and password valid
    return user;
  }

  // Find user by ID (useful for getting user details after JWT validation)
  async findUserById(userId: string): Promise<Omit<User, 'password'> | null> {
    const user = await this.prisma.user.findUnique({
        where: { id: userId },
        select: { // Explicitly select fields to exclude password
            id: true,
            email: true,
            name: true,
            phone: true,
            createdAt: true,
            updatedAt: true,
        }
    });
    if (!user) {
        throw new NotFound('User not found.');
    }
    return user;
  }

}

Why this structure?

  • Dependency Injection: Injecting PrismaClient makes the service testable (you can pass a mock client).
  • Clear Methods: Each method has a single responsibility (register, login, find).
  • Error Handling: Uses http-errors for standard, descriptive HTTP errors. Throws errors instead of returning complex objects.
  • Security: Explicitly removes the password hash before returning user data. Handles existing user checks.

3.5 Auth Controller (Request/Response Handling)

Create src/modules/auth/auth.controller.ts. This connects HTTP requests to the service logic.

typescript
// src/modules/auth/auth.controller.ts
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
import { AuthService } from './auth.service';
import {
    RegisterUserInput,
    LoginUserInput,
    VerifyOtpInput,
    AuthJWTPayload, // Import JWT types
    OtpJWTPayload   // Import JWT types
} from './auth.schema';
import config from '../../config'; // Import app config
import { BadRequest, Unauthorized } from 'http-errors'; // Import error types

export class AuthController {
  constructor(
      private authService: AuthService,
      private fastify: FastifyInstance // Need fastify instance for JWT signing
    ) {}

  async registerHandler(request: FastifyRequest<{ Body: RegisterUserInput }>, reply: FastifyReply) {
    try {
      const user = await this.authService.registerUser(request.body);
      // Decide on registration flow:
      // 1. Log in directly?
      // 2. Require email verification?
      // 3. Require OTP verification immediately?
      // For this guide, return the user data (excluding password)
      // and require them to log in separately to start the OTP flow.
      return reply.code(201).send(user);
    } catch (error: any) {
        request.log.error(error, "Registration failed");
        // Let the global error handler (added later) handle http-errors
        throw error;
    }
  }

  async loginHandler(request: FastifyRequest<{ Body: LoginUserInput }>, reply: FastifyReply) {
    try {
      const user = await this.authService.loginUser(request.body);

      if (!user.phone) {
         // Handle cases where user doesn't have a phone number registered yet
         // Option: return an error, prompt user to add phone, or skip OTP
         request.log.warn(`User ${user.email} logged in but has no phone number for OTP.`);
         // Generate a standard token but they can't proceed to sensitive areas
          const tokenPayload: AuthJWTPayload = { id: user.id, email: user.email, otpVerified: false };
          const accessToken = this.fastify.jwt.sign(tokenPayload, { expiresIn: config.jwt.expiresIn });
          return reply.send({ accessToken });
         // Alternatively: throw new BadRequest("Phone number required for 2FA.");
      }

      // Credentials valid, but OTP not yet verified.
      // Issue a short-lived "pre-OTP" token.
      const otpTokenPayload: OtpJWTPayload = { id: user.id, phone: user.phone };
      const otpToken = this.fastify.jwt.sign(otpTokenPayload, { expiresIn: config.jwt.otpExpiresIn });

      // Send this short-lived token back. The client must use this
      // token to call the /auth/otp/send endpoint.
      return reply.send({ otpToken, message: "Credentials verified. Proceed to OTP verification." });

    } catch (error: any) {
       request.log.error(error, "Login failed");
       throw error; // Let error handler manage response
    }
  }

  // Handler to get current user details (requires standard JWT)
  async getMeHandler(request: FastifyRequest, reply: FastifyReply) {
    // Assumes JWT authentication middleware has run and populated request.user
    const userId = (request.user as AuthJWTPayload).id; // Type assertion using imported type
    try {
        const user = await this.authService.findUserById(userId);
        return reply.send(user);
    } catch (error) {
        request.log.error(error, `Failed to get user details for ID: ${userId}`);
        throw error;
    }
  }

  // --- OTP Handlers ---

  // This endpoint requires the short-lived 'otpToken'
  async initiateOtpHandler(request: FastifyRequest, reply: FastifyReply) {
    // Assumes JWT ('otpToken') verification middleware has run
    const otpPayload = request.user as OtpJWTPayload; // Contains id and phone, use imported type

    try {
      // NOTE: Assumes fastify.initiateSmsOtp is defined in a Sinch plugin (covered later)
      const sinchResponse = await (this.fastify as any).initiateSmsOtp(otpPayload.phone);
      // Sinch handles sending the SMS. Confirm initiation only.
      // The 'id' in sinchResponse is Sinch's internal verification ID;
      // you don't typically need to store or use it directly when using reportByIdentity.
      request.log.info(`OTP initiated successfully via Sinch for user ${otpPayload.id}, phone ${otpPayload.phone}`);

      // Confirm success. The user checks their phone for the SMS.
      return reply.code(200).send({ message: `OTP sent successfully to ${otpPayload.phone.substring(0, otpPayload.phone.length - 4)}****.` });

    } catch (error: any) {
      request.log.error(error, `Failed to initiate OTP for user ${otpPayload.id}`);
      // Handle potential Sinch errors (e.g., invalid number format, rate limits)
      // The sinch plugin might throw, or handle specific Sinch error codes here
      if (error.response?.data?.errorCode) {
          // Example: Map Sinch error to user-friendly message
          const sinchErrorCode = error.response.data.errorCode;
           if (sinchErrorCode === 40003) { // Parameter validation failed (e.g., bad number format)
               throw new BadRequest("Invalid phone number format provided to Sinch.");
           }
           // Add more mappings as needed based on Sinch docs/testing
      }
      throw new BadRequest("Failed to send OTP. Please try again later."); // Generic fallback
    }
  }

  // This endpoint also requires the short-lived 'otpToken'
  async verifyOtpHandler(request: FastifyRequest<{ Body: VerifyOtpInput }>, reply: FastifyReply) {
     // Assumes JWT ('otpToken') verification middleware has run
     const otpPayload = request.user as OtpJWTPayload; // Contains id and phone, use imported type
     const { otp } = request.body;

     try {
         // NOTE: Assumes fastify.verifySmsOtp is defined in a Sinch plugin (covered later)
         const sinchResponse = await (this.fastify as any).verifySmsOtp(otpPayload.phone, otp);

         if (sinchResponse.status === 'SUCCESSFUL') {
             request.log.info(`OTP verification successful for user ${otpPayload.id}`);

             // OTP is correct! Issue the final, long-lived access token.
             // Fetch the user's email for the final payload.
             const user = await this.authService.findUserById(otpPayload.id);
             if (!user) throw new Unauthorized("User associated with token not found during OTP verification."); // Should not happen if token is valid

             const finalTokenPayload: AuthJWTPayload = {
                 id: user.id,
                 email: user.email,
                 otpVerified: true, // Mark as OTP verified
             };
             const accessToken = this.fastify.jwt.sign(finalTokenPayload, { expiresIn: config.jwt.expiresIn });

             // Send back the final access token
             return reply.send({ accessToken });
         } else {
             // Handle other Sinch statuses (e.g., 'FAIL', 'DENIED', 'ERROR')
             request.log.warn(`OTP verification failed for user ${otpPayload.id}. Sinch status: ${sinchResponse.status}, Reason: ${sinchResponse.reason || 'N/A'}`);
             throw new Unauthorized("Invalid or expired OTP.");
         }

     } catch (error: any) {
         request.log.error(error, `Failed to verify OTP for user ${otpPayload.id}`);
         if (error instanceof Unauthorized || error instanceof BadRequest) {
             throw error; // Re-throw known HTTP errors
         }
         // Handle potential Sinch SDK errors or other unexpected issues
         throw new BadRequest("Failed to verify OTP. Please try again later."); // Generic fallback
     }
  }
}

Frequently Asked Questions About Sinch SMS OTP with Fastify

How does Sinch Verification API work with Fastify?

Sinch Verification API integrates with Fastify through the official @sinch/verification SDK. Create a Fastify plugin that initializes the Sinch client with your application credentials (Application Key and Secret from your Sinch Dashboard), then expose methods for initiating SMS OTP (sending verification codes) and verifying user-entered codes. The SDK handles communication with Sinch's platform via Application Signed Request authentication, including SMS delivery, OTP generation, and verification status tracking.

What are the Sinch SMS pricing and rate limits?

Sinch SMS OTP pricing varies by country and volume, typically ranging from $0.005 to $0.10 per verification attempt depending on destination. Free trial accounts include limited verification credits for testing. Rate limits depend on your account tier but generally allow 1–5 verification attempts per phone number per hour to prevent abuse. Check your Sinch Dashboard for specific pricing and implement exponential backoff for rate-limited requests. Contact Sinch support for enterprise pricing and higher rate limits.

How secure is SMS-based OTP compared to other 2FA methods?

SMS OTP provides moderate security but has known vulnerabilities including SIM swapping attacks, SS7 protocol interception, and SMS phishing. Per OWASP MFA guidelines, do not use SMS 2FA as the sole protection for high-value applications containing PII or financial data. For stronger security, consider TOTP authenticator apps (Google Authenticator, Authy), hardware tokens (YubiKey), or FIDO2/WebAuthn passkeys. SMS OTP works well for moderate-security use cases like account recovery or secondary verification. NIST SP 800-63B also restricts SMS for authenticators due to these vulnerabilities.

What Node.js version is required for Fastify v5?

Fastify v5 requires Node.js v20 or later. Node.js v18 reached end-of-life on April 30, 2025 and no longer receives security updates. Current LTS versions are v20 "Iron" (Maintenance LTS until April 30, 2026) and v22 "Jod" (Active LTS until October 21, 2025, then Maintenance until April 30, 2027). Always use actively supported Node.js versions in production to ensure security patches and optimal performance. See the official Node.js release schedule for details.

How do I test SMS OTP locally without sending real messages?

For local testing, use Sinch's verification callback simulation or implement a development mode that bypasses actual SMS sending. Create a test endpoint that returns mock OTP codes, check if NODE_ENV=development and use hardcoded test codes (like "1234"), or use Sinch's test phone numbers if available in your region. Always validate production flows with real SMS to ensure carrier compatibility. Consider implementing a flag in your configuration to toggle between real Sinch API calls and mock responses during development.

For OTP-specific tokens (issued after password verification but before OTP entry), use short expiration times of 5–10 minutes per security best practices. This limits the window for token theft while giving users enough time to receive and enter their SMS code. For standard access tokens (issued after successful OTP verification), OWASP recommends 15–30 minute idle timeouts for low-risk applications. High-value applications should use shorter timeouts (2–5 minutes). Implement refresh token rotation for longer sessions and absolute timeouts (4–8 hours) regardless of activity.

How do I handle Sinch API errors in production?

Implement comprehensive error handling for Sinch API responses including network failures, invalid phone formats (error code 40003 per Sinch API docs), rate limiting (HTTP 429), and verification failures. Log all Sinch interactions with correlation IDs for debugging, use circuit breakers to prevent cascade failures, and provide user-friendly error messages without exposing internal details. Monitor your Sinch Dashboard for service status and set up alerts for elevated error rates. Common Sinch error codes include 40001 (invalid credentials), 40003 (parameter validation failure), and 40301 (insufficient funds).

Can I use Sinch SMS OTP with other databases besides PostgreSQL?

Yes, Sinch SMS OTP integration is database-agnostic. While this guide uses PostgreSQL with Prisma, you can use any database supported by Prisma including MySQL (5.6+), MariaDB (10.0+), SQLite, MongoDB (4.2+), Microsoft SQL Server (2017+), and CockroachDB (21.2.4+). You can also use other ORMs like TypeORM, Sequelize, Mongoose, or Knex.js. The core requirement is storing user phone numbers and authentication state. Adjust your Prisma schema datasource provider and connection string accordingly.

How do I implement retry logic for failed SMS deliveries?

Implement exponential backoff with maximum retry limits (typically 3 attempts) using libraries like p-retry or custom retry logic. Track verification attempts in your database to prevent abuse, add delays between retries (1s, 2s, 4s), and provide users with alternative delivery methods (voice call via Sinch FlashCall or Phone Call verification) after multiple failures. Log all retry attempts and failure reasons for debugging carrier-specific issues. Sinch automatically handles some retry logic, but you should implement application-level tracking to prevent users from requesting excessive OTPs and to detect potential abuse patterns.

What are E.164 phone format requirements for Sinch?

E.164 is the international phone number format required by Sinch as defined in ITU-T Recommendation E.164: +[country code][subscriber number] with maximum 15 digits total and no spaces or special characters. Examples: +14155552671 (US), +442071838750 (UK), +61291234567 (Australia). Validate phone numbers using the regex /^\+?[1-9]\d{1,14}$/ and use libraries like libphonenumber-js for parsing, formatting, and validating user input before sending to Sinch. The leading + is required when sending to Sinch API, and country code must be 1–3 digits.

Frequently Asked Questions

How to implement Sinch SMS OTP in Node.js?

Integrate Sinch's Verification API into your Node.js application using their provided SDK. This guide demonstrates a step-by-step implementation using Fastify, a high-performance Node.js framework, and TypeScript for improved code maintainability.

What is Sinch Verification API used for?

Sinch Verification API simplifies OTP generation, delivery via SMS or voice, and verification, offloading tasks like number formatting and carrier deliverability. It enhances security by managing the complexities of OTP lifecycles, improving implementation reliability.

Why use Fastify for Sinch OTP integration?

Fastify is a performant and developer-friendly Node.js framework. Its speed and efficiency make it an ideal choice for building a robust and scalable 2FA system with Sinch. The guide leverages Fastify's plugin system for cleaner code organization.

How to set up a Fastify project with TypeScript for Sinch?

Initialize a Node.js project, install Fastify, TypeScript dependencies, the Sinch SDK, Prisma ORM, and relevant type definitions. Configure a tsconfig.json file and define npm scripts for building, starting, development, and testing.

What database is used in the Sinch Fastify example?

The guide uses PostgreSQL as the database and Prisma as an ORM for simplified database interactions. Prisma's type safety and migration features contribute to better code maintainability and reduced errors.

When should I use Sinch's dedicated verification service?

Leverage Sinch's dedicated service for OTP generation, delivery, and verification anytime you need to implement 2FA (Two-Factor Authentication) in your Node.js applications to streamline the implementation and enhance security.

What is the system architecture for Fastify Sinch integration?

The architecture involves the end-user, a frontend app, a Fastify backend, the Sinch platform, and a database. The user interacts with the frontend, which communicates with the Fastify backend. The backend integrates with Sinch for OTP delivery and verification, and Prisma connects to the database.

How does the Sinch OTP flow work with Fastify?

The user initiates login, the backend verifies credentials, and then calls Sinch to initiate SMS verification. Sinch sends the OTP, the user enters it, and the backend verifies it with Sinch. Upon successful verification, the backend updates the user's session and grants access.

Can I use a different database with Fastify and Sinch?

While the example uses PostgreSQL with Prisma, you can adapt the guide to use other databases. Ensure you have the appropriate database driver and adjust the Prisma schema and database connection settings accordingly.

How to verify Sinch OTP on the backend?

The Fastify backend calls the Sinch SDK's `report` (verify) function to verify the OTP entered by the user against the phone number and initial verification request. The backend receives a success or failure response from Sinch.

Why is Zod used in the Fastify Sinch guide?

Zod is used for schema validation and type safety. It ensures that all environment variables and request bodies are correctly formatted, minimizing runtime errors by catching type mismatches early in the development process.

How to handle Sinch errors in a Fastify app?

The Sinch plugin might throw errors directly, or the guide recommends using a try-catch block to catch specific Sinch error codes within the verification handler. Mapping Sinch error codes to user-friendly messages improves the user experience.

How to hash passwords in Node.js with Fastify?

Use bcrypt, a robust password hashing library, to securely store user passwords. The example provides utility functions for hashing and comparing passwords. Never store passwords in plain text.

What are the prerequisites for implementing Sinch SMS OTP with Fastify?

You need Node.js v18 or later, npm/yarn, a Sinch account with a Verification App, a mobile phone for testing, basic understanding of TypeScript and REST APIs, and optionally, Docker and Docker Compose for a local PostgreSQL database.