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 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).
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.
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 ConfigurationNODE_ENV=development
PORT=3000HOST=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 AppSINCH_APPLICATION_KEY=YOUR_SINCH_APP_KEY
SINCH_APPLICATION_SECRET=YOUR_SINCH_APP_SECRET
# JWT ConfigurationJWT_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 timeoutJWT_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.
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.ymlversion:'3.8'services:postgres:image:postgres:15container_name:fastify_sinch_dbrestart:alwaysports:-"5432:5432"# Expose port 5432 locallyenvironment:POSTGRES_USER:user# Match your .env DATABASE_URLPOSTGRES_PASSWORD:password# Match your .env DATABASE_URLPOSTGRES_DB:mydatabase# Match your .env DATABASE_URLvolumes:-postgres_data:/var/lib/postgresql/datavolumes: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
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.
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.tsimport fp from'fastify-plugin';
import { PrismaClient } from'@prisma/client';
declaremodule'fastify' {
interfaceFastifyInstance {
prisma: PrismaClient;
}
}
exportdefaultfp(async (fastify) => {
const prisma = newPrismaClient();
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.tsimport dotenv from'dotenv';
import { z } from'zod';
dotenv.config(); // Load .env fileconst 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 timeoutJWT_OTP_EXPIRES_IN: z.string().default('5m'),
});
// Validate environment variables at startuptry {
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 partsenv: 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)}...`);
}
exportdefault 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.tsimport { z } from'zod';
// --- Input Schemas ---// Schema for user registration inputexportconst 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
});
exporttypeRegisterUserInput = z.infer<typeof registerUserSchema>;
// Schema for user login inputexportconst loginUserSchema = z.object({
email: z.string().email(),
password: z.string(),
});
exporttypeLoginUserInput = z.infer<typeof loginUserSchema>;
// Schema for initiating OTPexportconst 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}$/),
});
exporttypeInitiateOtpInput = z.infer<typeof initiateOtpSchema>; // May not be needed if using user context// Schema for verifying OTPexportconst 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" }),
});
exporttypeVerifyOtpInput = z.infer<typeof verifyOtpSchema>;
// --- Response Schemas (Examples) ---exportconst authResponseSchema = z.object({
accessToken: z.string()
});
exportconst userResponseSchema = z.object({
id: z.string(),
email: z.string(),
name: z.string().nullable(),
phone: z.string().nullable(),
createdAt: z.date(),
});
exportconst loginResponseSchema = z.object({
otpToken: z.string().optional(),
accessToken: z.string().optional(),
message: z.string().optional()
});
exportconst messageResponseSchema = z.object({
message: z.string()
});
// --- JWT Payload Types ---exportinterfaceAuthJWTPayload {
id: string;
email: string;
otpVerified: boolean; // Flag to track OTP status
}
exportinterfaceOtpJWTPayload {
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.tsimport bcrypt from'bcrypt';
constSALT_ROUNDS = 12; // Per OWASP 2024-2025 recommendations (minimum 10, recommended 12-14 for balance of security and performance)exportasyncfunctionhashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
exportasyncfunctioncomparePassword(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.tsimport { 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 errorsexportclassAuthService {
constructor(privateprisma: PrismaClient) {} // Inject Prisma clientasyncregisterUser(input: RegisterUserInput): Promise<Omit<User, 'password'>> {
const { email, password, name, phone } = input;
// Check if user or phone already existsconst existingUser = awaitthis.prisma.user.findFirst({
where: { OR: [{ email }, { phone }] }, // Check uniqueness for both email and phone
});
if (existingUser) {
if (existingUser.email === email) {
thrownewBadRequest('User with this email already exists.');
}
if (existingUser.phone === phone) {
thrownewBadRequest('User with this phone number already exists.');
}
}
const hashedPassword = awaithashPassword(password);
const user = awaitthis.prisma.user.create({
data: {
email,
password: hashedPassword,
name,
phone,
},
});
// eslint-disable-next-line @typescript-eslint/no-unused-varsconst { password: _, ...userWithoutPassword } = user; // Exclude password from returnreturn userWithoutPassword;
}
asyncloginUser(input: LoginUserInput): Promise<User> {
const { email, password } = input;
const user = awaitthis.prisma.user.findUnique({
where: { email },
});
if (!user) {
thrownewNotFound('User not found.'); // More specific than Unauthorized before checking password
}
const isPasswordValid = awaitcomparePassword(password, user.password);
if (!isPasswordValid) {
thrownewUnauthorized('Invalid password.');
}
// User found and password validreturn user;
}
// Find user by ID (useful for getting user details after JWT validation)asyncfindUserById(userId: string): Promise<Omit<User, 'password'> | null> {
const user = awaitthis.prisma.user.findUnique({
where: { id: userId },
select: { // Explicitly select fields to exclude passwordid: true,
email: true,
name: true,
phone: true,
createdAt: true,
updatedAt: true,
}
});
if (!user) {
thrownewNotFound('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.tsimport { FastifyRequest, FastifyReply, FastifyInstance } from'fastify';
import { AuthService } from'./auth.service';
import {
RegisterUserInput,
LoginUserInput,
VerifyOtpInput,
AuthJWTPayload, // Import JWT typesOtpJWTPayload// Import JWT types
} from'./auth.schema';
import config from'../../config'; // Import app configimport { BadRequest, Unauthorized } from'http-errors'; // Import error typesexportclassAuthController {
constructor(privateauthService: AuthService,
privatefastify: FastifyInstance// Need fastify instance for JWT signing) {}
asyncregisterHandler(request: FastifyRequest<{ Body: RegisterUserInput }>, reply: FastifyReply) {
try {
const user = awaitthis.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-errorsthrow error;
}
}
asyncloginHandler(request: FastifyRequest<{ Body: LoginUserInput }>, reply: FastifyReply) {
try {
const user = awaitthis.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 areasconsttokenPayload: 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.constotpTokenPayload: 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)asyncgetMeHandler(request: FastifyRequest, reply: FastifyReply) {
// Assumes JWT authentication middleware has run and populated request.userconst userId = (request.userasAuthJWTPayload).id; // Type assertion using imported typetry {
const user = awaitthis.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'asyncinitiateOtpHandler(request: FastifyRequest, reply: FastifyReply) {
// Assumes JWT ('otpToken') verification middleware has runconst otpPayload = request.userasOtpJWTPayload; // Contains id and phone, use imported typetry {
// NOTE: Assumes fastify.initiateSmsOtp is defined in a Sinch plugin (covered later)const sinchResponse = await (this.fastifyasany).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 hereif (error.response?.data?.errorCode) {
// Example: Map Sinch error to user-friendly messageconst sinchErrorCode = error.response.data.errorCode;
if (sinchErrorCode === 40003) { // Parameter validation failed (e.g., bad number format)thrownewBadRequest("Invalid phone number format provided to Sinch.");
}
// Add more mappings as needed based on Sinch docs/testing
}
thrownewBadRequest("Failed to send OTP. Please try again later."); // Generic fallback
}
}
// This endpoint also requires the short-lived 'otpToken'asyncverifyOtpHandler(request: FastifyRequest<{ Body: VerifyOtpInput }>, reply: FastifyReply) {
// Assumes JWT ('otpToken') verification middleware has runconst otpPayload = request.userasOtpJWTPayload; // Contains id and phone, use imported typeconst { otp } = request.body;
try {
// NOTE: Assumes fastify.verifySmsOtp is defined in a Sinch plugin (covered later)const sinchResponse = await (this.fastifyasany).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 = awaitthis.authService.findUserById(otpPayload.id);
if (!user) thrownewUnauthorized("User associated with token not found during OTP verification."); // Should not happen if token is validconstfinalTokenPayload: 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 tokenreturn 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'}`);
thrownewUnauthorized("Invalid or expired OTP.");
}
} catch (error: any) {
request.log.error(error, `Failed to verify OTP for user ${otpPayload.id}`);
if (error instanceofUnauthorized || error instanceofBadRequest) {
throw error; // Re-throw known HTTP errors
}
// Handle potential Sinch SDK errors or other unexpected issuesthrownewBadRequest("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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
Why This Approach?
System Architecture:
Prerequisites:
async/await.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.jsonfile 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 client1.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.tsplugins/# Fastify plugins (e.g., db client, auth)prisma.tssinch.tsjwtAuth.tsconfig/# Configuration files/logicindex.tstypes/# Global or shared type definitionsfastify-jwt.d.ts# JWT type augmentationsutils/# Utility functionshash.tsapp.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.jsonWhy this structure?
auth) into modules makes the codebase easier to navigate and maintain.typesdirectory for global type definitions and augmentations (like for Fastify plugins).1.5 Environment Variables (
.envand.env.example)Create
.env.examplewith 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
.envfile (copy from.env.example) and fill in your actual development values. Add.envto your.gitignorefile to prevent committing secrets.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.ymlfile:# 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 -dto 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:Why 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
prisma/migrationsdirectory if it doesn't exist.prisma/migrations/20250420000000_init/migration.sql).Usertable).2.3 Create Prisma Plugin for Fastify
Create
src/plugins/prisma.tsto 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?
fastify-pluginto avoid encapsulation issues and makefastify.prismaavailable globally within the Fastify instance.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.tsto 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.tsto 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?
PrismaClientmakes the service testable (you can pass a mock client).http-errorsfor standard, descriptive HTTP errors. Throws errors instead of returning complex objects.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/verificationSDK. 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=developmentand 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
datasourceprovider 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-retryor 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 likelibphonenumber-jsfor 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.