code examples
code examples
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:
+-------------+ +-----------------+ +-----------------+ +-----------------+
| End User | <---> | Frontend App | <--> | Fastify Backend | <--> | Sinch Platform |
| (Browser/ | | (React/Vue/etc.)| | (Node.js/TS) | | (Verification |
| Mobile App) | +-----------------+ +-------+---------+ | API via SDK) |
+-------------+ | +-----------------+
|
+-------+---------+
| Database |
| (PostgreSQL/ |
| Prisma) |
+-----------------+- User Action: User initiates login or a sensitive action requiring 2FA.
- Frontend Request: Frontend sends credentials (and potentially phone number) to the Fastify backend.
- Backend Logic (Login): Backend verifies username/password against the database.
- 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.
- Sinch Sends SMS: Sinch generates an OTP and sends it via SMS to the user's phone.
- User Enters OTP: User receives the SMS and enters the OTP into the frontend.
- Frontend Request (Verify): Frontend sends the entered OTP to the Fastify backend.
- Backend Logic (OTP Verify): Backend calls the Sinch SDK to report (verify) the entered OTP against the phone number.
- Sinch Verifies: Sinch checks if the OTP is correct for that number and initiation request.
- 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.
- 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).
curlor 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:
# 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 safe1.2 Configure TypeScript (tsconfig.json)
Create a tsconfig.json file in the project root:
// 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:CommonJSis 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:
// 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 JavaScriptstart: Run the compiled JavaScript application (for production)dev: Run the application usingts-nodeandnodemonfor automatic restarts during developmenttest: 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 definitionmigrations/# Database migration files (generated)
src/modules/# Feature modules (e.g., auth, users)auth/auth.controller.tsauth.routes.tsauth.schema.ts# Zod schemas & type definitionsauth.service.ts
plugins/# Fastify plugins (e.g., db client, auth)prisma.tssinch.tsjwtAuth.ts
config/# Configuration files/logicindex.ts
types/# Global or shared type definitionsfastify-jwt.d.ts# JWT type augmentations
utils/# Utility functionshash.ts
app.ts# Main Fastify application setupserver.ts# Entry point, starts the server
.env# Environment variables (ignored by git).env.example# Example environment variables.gitignorepackage.jsonpackage-lock.jsontsconfig.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
typesdirectory for global type definitions and augmentations (like for Fastify plugins).
1.5 Environment Variables (.env and .env.example)
Create .env.example with these placeholders:
# .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 lastsCreate a .env file (copy from .env.example) and fill in your actual development values. Add .env to your .gitignore file to prevent committing secrets.
# .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:
# 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/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 neededWhy this schema?
id: Standard unique primary key.email,password: Essential for basic authentication.phone: Required for Sinch SMS OTP. Making it@uniqueprevents 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
- Generate Client: Create the type-safe Prisma client based on your schema.
bash
npx prisma generate - Create Migration: Create the initial SQL migration file based on the schema changes. Use a descriptive name.
This command:bash
npx prisma migrate dev --name init- Creates the
prisma/migrationsdirectory 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
Usertable). - Ensures the Prisma client is up-to-date.
- Creates the
2.3 Create Prisma Plugin for Fastify
Create src/plugins/prisma.ts to make the Prisma client available throughout your Fastify application.
// 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-pluginto avoid encapsulation issues and makefastify.prismaavailable 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.
// 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.
// 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:
// 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.
// 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
PrismaClientmakes the service testable (you can pass a mock client). - Clear Methods: Each method has a single responsibility (register, login, find).
- Error Handling: Uses
http-errorsfor 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.
// 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.
What's the recommended JWT expiration time for OTP tokens?
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.