Receive Inbound SMS with Sinch, Next.js, and NextAuth: Two-Way Messaging Tutorial
Learn how to receive and process inbound SMS messages in Next.js using Sinch webhooks with NextAuth authentication. Complete guide with HMAC-SHA256 verification, E.164 phone validation, and production deployment.
Learn how to build a Next.js application that receives and processes inbound SMS messages using Sinch webhooks with NextAuth authentication. This comprehensive tutorial covers webhook security with HMAC-SHA256 verification, E.164 phone number validation, user authentication, and production deployment.
You'll create a secure web application where authenticated users receive SMS messages sent to a dedicated Sinch phone number, automatically linked to their account via their registered phone number.
What you'll build:
NextAuth.js authentication system with email/password and phone numbers
Secure Sinch webhook endpoint with HMAC-SHA256 signature verification
E.164 phone number validation and user-message association
Inbound SMS processing with PostgreSQL database storage
Protected user dashboard displaying received messages
Production-ready deployment configuration for Vercel or similar platforms
User identification – Matches sender phone numbers to registered users
Message storage – Stores SMS content in PostgreSQL, linked to users
Extensible foundation – Ready for automated replies and two-way conversations
Why receive inbound SMS with Next.js and Sinch?
Traditional web applications can't directly receive SMS messages from users. This guide shows you how to programmatically receive and process SMS messages through Sinch webhooks, linking them to authenticated user accounts within a modern Next.js framework. Use this foundation for:
Healthcare applications: Appointment confirmations and prescription reminders
Delivery services: Real-time order updates and driver communication
Customer support systems: Instant query responses and issue tracking
Notification systems: Critical alerts and time-sensitive communications
Technologies Used:
Next.js (v15.5): React framework for building server-rendered applications (using App Router). As of October 2025, Next.js 15.5 is the latest version with React 19 support and Turbopack builds.
NextAuth.js (v5 Beta / Auth.js): Authentication library for Next.js, simplifying login, logout, and session management. (Disclaimer: This guide uses next-auth@beta (v5.0.0-beta.25), now branded as Auth.js. While feature-complete and widely used, beta versions may contain bugs or breaking changes. For maximum stability in production, consider using the latest stable v4 release or carefully test v5 before deploying. NextAuth v5 requires Next.js 14.0 or higher.)
Sinch SMS API: Communications Platform as a Service (CPaaS) provider for SMS functionality (specifically receiving inbound messages via webhooks with HMAC-SHA256 signature verification).
PostgreSQL: Relational database for storing user and message data.
Prisma: Next-generation ORM for database access and migrations with support for optimized indexing strategies.
TypeScript: For static typing and improved code quality.
bcryptjs: For securely hashing user passwords (10 salt rounds recommended for production).
Zod: For data validation (credentials, webhook payloads, E.164 phone format).
Tailwind CSS: For styling (optional, included with create-next-app).
System Architecture:
graph TD
A[User Browser] -- HTTPS --> B(Next.js App / Vercel);
B -- Login Request --> C{NextAuth.js Middleware};
C -- Verify Credentials --> D[Database (PostgreSQL)];
D -- User Record --> C;
C -- Session Cookie --> A;
E[User's Phone] -- Sends SMS --> F(Sinch Platform);
F -- Inbound SMS Webhook (POST) --> G[Next.js API Route (/api/webhooks/sinch)];
G -- Verify Signature --> F;
G -- Lookup User by Phone (E.164) --> D;
G -- Store Message --> D;
D -- User/Message Data --> G;
G -- 200 OK --> F;
style G fill:#f9f,stroke:#333,stroke-width:2px
style F fill:#ccf,stroke:#333,stroke-width:2px
style D fill:#ff9,stroke:#333,stroke-width:2px
Prerequisites:
Node.js (v18 or later required for Next.js 15; v20 LTS recommended for optimal compatibility) and npm/pnpm/yarn.
Access to a PostgreSQL database instance (v12+ recommended).
A Sinch account with API credentials and a provisioned SMS-enabled phone number.
Basic understanding of React, TypeScript, and REST APIs.
openssl command line tool for generating secrets. (Alternatives for Windows include Git Bash, Windows Subsystem for Linux (WSL), or online generation tools – use trusted tools for security-sensitive secrets).
A tool for testing webhooks locally (e.g., ngrok, Vercel CLI, or Cloudflare Tunnel). Production alternatives include public IP addresses or other tunneling services.
Final Outcome:
A deployed Next.js application where users can register and login with phone number authentication. Incoming SMS messages sent to the configured Sinch number are securely received, verified via HMAC-SHA256, associated with the correct user based on their registered phone number (using consistent E.164 format), and stored in the PostgreSQL database.
1. Set Up Your Next.js Project
Initialize your Next.js application and install the necessary dependencies for receiving inbound SMS.
1.1 Create Next.js Application
Open your terminal and create a new Next.js project:
Enable TypeScript, ESLint, Tailwind CSS, the src/ directory, and the App Router when prompted. These options create a production-ready project structure with modern Next.js features.
1.2 Install Required Dependencies
Install authentication, database, and Sinch-related packages:
next-auth@beta (v5.0.0-beta.25) – Authentication library, now branded as Auth.js
@next-auth/prisma-adapter – Connects NextAuth to your Prisma database
prisma and @prisma/client – ORM for type-safe database access
pg – PostgreSQL driver required by Prisma
bcryptjs and @types/bcryptjs – Secure password hashing (10 salt rounds)
zod – Runtime validation for credentials, webhooks, and phone numbers
@sinch/sdk-core – Optional Sinch SDK for sending replies (not required for receiving)
1.3 Initialize Prisma
Set up Prisma in your project:
Bash
npx prisma init
This creates a prisma directory with schema.prisma and a .env file at your project root.
1.4 Configure Environment Variables
Open the .env file created by Prisma and add these configuration variables. Never commit this file to version control – it contains sensitive credentials.
.env
# .env
# Database Connection
# Replace with your actual PostgreSQL credentials
DATABASE_URL=postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
# NextAuth Secret
# Generate using: openssl rand -base64 32
# This secret encrypts session cookies and JWT tokens
AUTH_SECRET=YOUR_AUTH_SECRET_GENERATED_WITH_OPENSSL
# Sinch API Credentials
# Get these from your Sinch Dashboard: https://dashboard.sinch.com
# Required if you plan to send outbound SMS messages
SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID
SINCH_APP_KEY=YOUR_SINCH_APP_KEY
SINCH_APP_SECRET=YOUR_SINCH_APP_SECRET
# Sinch Webhook Secret
# Create a strong secret YOU define: openssl rand -base64 32
# Configure this same secret in the Sinch portal for webhook signature verification
SINCH_WEBHOOK_SECRET=YOUR_STRONG_RANDOM_WEBHOOK_SECRET
# Application URL
# Development: http://localhost:3000
# Production: https://yourdomain.com
# Required for NextAuth redirects and callbacks
NEXTAUTH_URL=http://localhost:3000
Security requirements:
AUTH_SECRET – Generate with openssl rand -base64 32. Never use a weak or hardcoded value. This protects all user sessions.
SINCH_WEBHOOK_SECRET – Generate with openssl rand -base64 32. You create this secret and configure it in both your code and the Sinch dashboard. Sinch uses it to sign webhook requests.
DATABASE_URL – Use SSL in production: add ?sslmode=require to your connection string.
NEXTAUTH_URL – Must match your deployment URL exactly, including protocol (http:// or https://).
1.5 Define Database Schema
Open prisma/schema.prisma and define your data models. Store phone numbers in E.164 format (+15551234567) for consistent international number handling.
Prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// NextAuth required models for session management
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
// User model with phone number for SMS association
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
password String? // Hashed with bcryptjs (10 salt rounds)
phoneNumber String? @unique // E.164 format (e.g., +15551234567)
image String?
accounts Account[]
sessions Session[]
messages Message[] // All SMS messages received by this user
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// SMS message storage with user association
model Message {
id String @id @default(cuid())
sinchMessageId String @unique // Sinch's unique message identifier
fromNumber String // Sender's phone (E.164 format from Sinch)
toNumber String // Your Sinch number (E.164 format)
body String? @db.Text // Message text content
direction String // 'inbound' or 'outbound'
timestamp DateTime // When Sinch received the message
userId String? // Link to User (null if sender not registered)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) // When we stored it
// Explicit indexes for query optimization (B-tree indexes for range/equality operations)
// These indexes significantly improve query performance on large datasets (thousands+ records)
@@index([userId]) // Optimizes: WHERE userId = ? and JOIN operations
@@index([fromNumber]) // Optimizes: WHERE fromNumber = ? for sender lookups
@@index([timestamp]) // Optimizes: ORDER BY timestamp and date range queries
}
Key design decisions:
E.164 phone format – International standard ensures consistent number matching
Unique constraints – Prevent duplicate users and messages
Explicit indexes – B-tree indexes speed up queries by 10-100× on large datasets
Optional user link – Messages stored even if sender isn't registered
Cascade deletes – Removing a user also removes their sessions (not their messages)
1.6 Apply Database Migrations
Sync your Prisma schema with your PostgreSQL database:
Bash
npx prisma db push
This command creates all tables, indexes, and constraints defined in your schema. Use db push for rapid development iteration.
For production deployments, use Prisma's migration system for version control:
Bash
# Create a migration file
npx prisma migrate dev --name init
# Deploy migrations to production
npx prisma migrate deploy
Set up NextAuth.js (Auth.js v5) for secure user registration and login with email, password, and phone number validation.
2.1 Create Prisma Client Instance
Create a singleton Prisma client to prevent connection exhaustion in development:
TypeScript
// src/lib/prisma.tsimport { PrismaClient } from'@prisma/client';
declareglobal {
// eslint-disable-next-line no-varvarprisma: PrismaClient | undefined;
}
// Configure logging based on environmentconst prisma =
global.prisma ||
newPrismaClient({
log: process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn'] // Verbose logging in development
: ['error'], // Only errors in production
});
// Prevent multiple instances in development hot-reloadif (process.env.NODE_ENV !== 'production') {
global.prisma = prisma;
}
exportdefault prisma;
Why use a global variable? Next.js hot-reloading in development creates new module instances on each save. Without the global variable, you'd create dozens of database connections, eventually exhausting your connection pool.
2.2 NextAuth Base Configuration (auth.config.ts)
Define basic configurations like custom pages and the authorization callback used by the middleware.
TypeScript
// src/auth.config.tsimporttype { NextAuthConfig } from'next-auth';
exportconst authConfig = {
// trustHost: true, // Consider uncommenting if deploying behind a proxy and facing issues, but understand security implications.pages: {
signIn: '/login', // Redirect users to /login if they need to authenticate// error: '/auth/error', // Optional: Custom error page
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
const isOnAuthPage = nextUrl.pathname === '/login' || nextUrl.pathname === '/signup';
if (isOnDashboard) {
if (isLoggedIn) returntrue; // Allow access if logged inreturnfalse; // Redirect unauthenticated users to login page
} elseif (isLoggedIn && isOnAuthPage) {
// If logged in and trying to access login/signup, redirect to dashboardconst dashboardUrlBase = process.env.NEXTAUTH_URL;
if (dashboardUrlBase) {
// Prefer absolute redirect if NEXTAUTH_URL is setconsole.log(`Redirecting logged-in user from ${nextUrl.pathname} to dashboard.`);
returnResponse.redirect(newURL('/dashboard', dashboardUrlBase));
} else {
// Fallback to relative redirect if NEXTAUTH_URL is missing (less ideal)console.warn('NEXTAUTH_URL is not set. Attempting relative redirect to /dashboard.');
// Note: Relative redirects in middleware might not work as expected in all environments.// Consider ensuring NEXTAUTH_URL is always set.// For this example, we attempt it, but it might require returning false to trigger default redirect.returnResponse.redirect(newURL('/dashboard', nextUrl.origin)); // Try constructing absolute from origin
}
}
// Allow access to all other pages (home, public routes, auth pages if not logged in)returntrue;
},
// JWT and Session callbacks are defined in auth.ts
},
providers: [
// Provider definitions will go in auth.ts
],
} satisfiesNextAuthConfig;
2.3 NextAuth Main Configuration (auth.ts)
This file includes the provider logic (Credentials), database interactions, and password handling.
TypeScript
// src/auth.tsimportNextAuthfrom'next-auth';
importCredentialsfrom'next-auth/providers/credentials';
import { z } from'zod';
import bcrypt from'bcryptjs';
import { PrismaAdapter } from'@next-auth/prisma-adapter';
import { authConfig } from'./auth.config';
import prisma from'@/lib/prisma';
importtype { User } from'@prisma/client';
// Helper function to find user by emailasyncfunctiongetUserByEmail(email: string): Promise<User | null> {
try {
// In production, replace console.log with structured logging// logger.info({ email }, 'Attempting to fetch user by email');returnawait prisma.user.findUnique({ where: { email } });
} catch (error) {
// Log error to a monitoring service in productionconsole.error('Failed to fetch user by email:', error);
// logger.error({ email, error }, 'Database error while fetching user');thrownewError('Database error while fetching user.'); // Re-throw for NextAuth to handle
}
}
exportconst {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth({
...authConfig, // Spread the base configadapter: PrismaAdapter(prisma), // Use Prisma for session, account, etc. storagesession: { strategy: 'jwt' }, // Use JWT strategy for sessionsproviders: [
Credentials({
asyncauthorize(credentials) {
// 1. Validate credentials using Zodconst parsedCredentials = z
.object({
email: z.string().email({ message: 'Invalid email format.' }),
password: z.string().min(6, { message: 'Password must be at least 6 characters.' }),
})
.safeParse(credentials);
if (!parsedCredentials.success) {
console.log('Invalid credentials format:', parsedCredentials.error.flatten().fieldErrors);
returnnull; // Returning null triggers CredentialsSignin error type
}
const { email, password } = parsedCredentials.data;
// 2. Find user in the databaseconst user = awaitgetUserByEmail(email);
if (!user || !user.password) {
console.log(`No user found for email: ${email} or user has no password set.`);
returnnull; // User not found or password not set
}
// 3. Compare passwordsconst passwordsMatch = await bcrypt.compare(password, user.password);
if (passwordsMatch) {
console.log(`User ${email} authenticated successfully.`);
// Return the user object required by NextAuth// Ensure the returned object matches NextAuth's expected User shape for the session/JWTreturn { id: user.id, email: user.email, name: user.name, image: user.image };
}
// 4. If passwords don't matchconsole.log(`Password mismatch for user: ${email}`);
returnnull; // Invalid password
},
}),
// Add other providers like Google, GitHub, etc. here if needed
],
callbacks: {
...authConfig.callbacks, // Include callbacks from authConfig (like `authorized`)asyncjwt({ token, user }) {
// Add custom claims to JWT (e.g., user ID) after sign inif (user) {
token.id = user.id;
// You could add roles or other data here if needed// token.role = user.role; // Assuming user object has role
}
return token;
},
asyncsession({ session, token }) {
// Add custom data to the session object from the JWTif (token && session.user) {
session.user.id = token.idasstring; // Add user ID to session.user// session.user.role = token.role; // Add role if present in token
}
return session;
},
}
});
2.4 Middleware for Route Protection
Create the middleware file to protect routes based on authentication status using the authorized callback defined in auth.config.ts.
TypeScript
// src/middleware.tsimportNextAuthfrom'next-auth';
import { authConfig } from'./auth.config';
// Initialize NextAuth using the configuration that contains the 'authorized' callbackexportdefaultNextAuth(authConfig).auth;
exportconst config = {
// Matcher specifies routes where the middleware should run.// It uses a negative lookahead to exclude API routes, static files, images, etc.// Adjust the matcher if you have other top-level routes to exclude or include.// Example: ['/dashboard/:path*', '/settings']matcher: ['/((?!api|_next/static|_next/image|.*\\.png$|favicon.ico).*)'],
};
2.5 Server Actions for Auth (actions/auth.ts)
Create Server Actions for handling sign-up and login form submissions.
TypeScript
// src/lib/actions/auth.ts'use server';
import { z } from'zod';
import bcrypt from'bcryptjs';
import { AuthError } from'next-auth';
import { signIn, signOut } from'@/auth'; // Import signIn/signOut from auth.tsimport prisma from'@/lib/prisma';
// --- Login Action ---constLoginSchema = z.object({
email: z.string().email({ message: 'Please enter a valid email.' }),
password: z.string().min(1, { message: 'Password is required.' }), // Basic check, NextAuth authorize does deeper check
});
exporttypeLoginState = {
errors?: {
email?: string[];
password?: string[];
credentials?: string[]; // For general sign-in errors
};
message?: string | null;
};
exportasyncfunctionauthenticate(prevState: LoginState | undefined,
formData: FormData,
): Promise<LoginState> {
const validatedFields = LoginSchema.safeParse(
Object.fromEntries(formData.entries()),
);
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Invalid fields. Failed to Login.',
};
}
const { email, password } = validatedFields.data;
try {
// In production, replace console.log with structured loggingconsole.log(`Attempting sign in for user: ${email}`);
awaitsignIn('credentials', {
email,
password,
redirect: false, // Handle redirect via middleware or explicitly on success return
});
// If signIn succeeds without throwing, middleware will likely handle redirect.console.log(`Sign in successful for: ${email}. Redirect should occur via middleware.`);
// Return success state, though middleware might redirect before this is processed client-sidereturn { message: 'Login successful! Redirecting…' };
} catch (error) {
if (error instanceofAuthError) {
// Log specific AuthError types for debuggingswitch (error.type) {
case'CredentialsSignin':
console.error('CredentialsSignin error:', error.cause?.err?.message);
return { message: 'Invalid email or password.', errors: { credentials: ['Invalid email or password.'] } };
case'CallbackRouteError':
console.error('CallbackRouteError:', error.cause?.err?.message);
return { message: `Authentication callback failed: ${error.cause?.err?.message || 'Unknown callback error'}`, errors: { credentials: ['Authentication callback failed.'] } };
default:
console.error('Unknown NextAuth error:', error);
return { message: 'Something went wrong during authentication.', errors: { credentials: ['An unexpected error occurred.'] } };
}
}
// Handle non-AuthError exceptions (e.g., database connection issues caught before signIn is called)console.error('Non-AuthError during authenticate:', error);
// Consider re-throwing if it's a critical, unexpected error that should halt execution.// For now, return a generic server error message.// throw error; // Uncomment if critical errors should propagate and potentially crash the requestreturn { message: 'An unexpected server error occurred during login.', errors: { credentials: ['Server error during login.'] } };
}
}
// --- Sign Up Action ---constSignUpSchema = z.object({
email: z.string().email({ message: 'Please enter a valid email.' }),
password: z.string().min(6, { message: 'Password must be at least 6 characters.' }),
name: z.string().min(1, { message: 'Name is required.' }),
// E.164 format validation: + followed by 1-15 digits, first digit 1-9 (country codes don't start with 0)// Recommended pattern from ITU E.164 specificationphoneNumber: z.string()
.min(8, { message: 'Please enter a valid phone number (e.g., +15551234567).' })
.regex(/^\+[1-9]\d{1,14}$/, { message: "Invalid phone number format. Must be E.164 format: +[country code][number] (e.g., +15551234567). Total 2-15 digits." }),
});
exporttypeSignUpState = {
errors?: {
email?: string[];
password?: string[];
name?: string[];
phoneNumber?: string[];
server?: string[]; // For general server/database errors
};
message?: string | null;
success?: boolean;
};
exportasyncfunctionregisterUser(prevState: SignUpState | undefined,
formData: FormData,
): Promise<SignUpState> {
const validatedFields = SignUpSchema.safeParse(
Object.fromEntries(formData.entries()),
);
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Invalid fields. Failed to Sign Up.',
success: false,
};
}
const { email, password, name, phoneNumber } = validatedFields.data;
// Ensure phone number starts with '+' for consistent E.164 storage, if not already presentconst formattedPhoneNumber = phoneNumber.startsWith('+') ? phoneNumber : `+${phoneNumber}`;
// Check if user already existstry {
const existingUserByEmail = await prisma.user.findUnique({ where: { email } });
if (existingUserByEmail) {
return { message: 'Email already in use.', errors: { email: ['Email already exists.'] }, success: false };
}
const existingUserByPhone = await prisma.user.findUnique({ where: { phoneNumber: formattedPhoneNumber } });
if (existingUserByPhone) {
return { message: 'Phone number already in use.', errors: { phoneNumber: ['Phone number already exists.'] }, success: false };
}
// Hash the passwordconst hashedPassword = await bcrypt.hash(password, 10); // Salt rounds = 10// Create the user in the databaseawait prisma.user.create({
data: {
email,
password: hashedPassword,
name,
phoneNumber: formattedPhoneNumber, // Store consistently formatted number
},
});
// In production, replace console.log with structured loggingconsole.log(`User registered successfully: ${email}`);
// Optionally: Automatically sign in the user after registration// await signIn('credentials', { email, password, redirect: false });return { message: 'Registration successful! Please log in.', success: true };
} catch (error) {
console.error('Registration error:', error);
// Log to monitoring service in production// Check for specific Prisma unique constraint violation errors (race condition safety)if (error instanceofError && 'code'in error && (error asany).code === 'P2002') {
const target = (error asany).meta?.target;
if (target?.includes('email')) {
return { message: 'Email already in use.', errors: { email: ['Email already exists.'] }, success: false };
}
if (target?.includes('phoneNumber')) {
return { message: 'Phone number already in use.', errors: { phoneNumber: ['Phone number already exists.'] }, success: false };
}
}
return { message: 'Database error: Failed to register user.', errors: { server: ['Could not create user account.'] }, success: false };
}
}
// --- Sign Out Action ---exportasyncfunctionlogout() {
try {
// In production, replace console.log with structured loggingconsole.log("Attempting user sign out.");
awaitsignOut({ redirectTo: '/login' }); // Redirect to login after sign out
} catch (error) {
console.error("Sign out error:", error);
// Log error to monitoring service in production// Handle potential errors during sign out, though typically straightforward// Maybe redirect to an error page or show a message if signOut itself fails// For now, the redirect might still happen client-side even if server action errors.
}
}
2.6 UI Components (Login and Sign Up Forms)
Create basic form components. Note: These examples assume you have basic Button, Input, and Label components. Install shadcn/ui for pre-built components:
This guide provides a step-by-step process to integrate Sinch's inbound SMS capabilities into your Next.js application. It utilizes NextAuth.js for user authentication and Prisma for database interactions, allowing you to securely receive and process SMS messages sent to a dedicated Sinch number linked to user accounts.
NextAuth.js simplifies the implementation of user authentication in your Next.js application. This is crucial for associating incoming SMS messages with specific authenticated user accounts based on their registered phone numbers.
E.164 format (+15551234567) ensures consistent handling of phone numbers, which is essential for reliably linking inbound SMS messages from Sinch to the corresponding user account. It is a globally recognized standard that enables phone number validation, verification and compatibility across different systems, preventing errors or mismatches caused by regional variations.
The Sinch SDK is optional for this specific guide, which focuses on receiving inbound SMS messages. However, the SDK is required if you extend the functionality to send replies or other outbound communications.
While this guide uses PostgreSQL, you can adapt it to use other databases by adjusting the DATABASE_URL and configuring the appropriate Prisma provider in the schema.prisma file.
Webhook security is paramount. Generate a strong secret with openssl rand -base64 32 and configure it in both your .env file (SINCH_WEBHOOK_SECRET) and the Sinch dashboard for the specific webhook endpoint. This secret will be used to verify the signature of incoming webhook requests, ensuring they originate from Sinch and not malicious actors.
The diagram visually illustrates the flow of requests and data between the user, your Next.js application, the Sinch platform, and the database. It highlights the interactions with NextAuth.js for login requests and Prisma for database access, and showcases how the webhooks are processed via an API route.
Use tools like ngrok or the Vercel CLI to expose your local development server to the internet. This will allow Sinch to send webhooks to your local endpoint during development and testing. Make sure to configure the generated URL from ngrok or Vercel as your webhook URL in Sinch.
You'll need Node.js, access to a PostgreSQL database, a Sinch account with API credentials and a dedicated phone number, and basic understanding of React, TypeScript, and REST APIs.
The application assumes that SMS messages are sent in E.164 format. It looks up the user in the database whose phoneNumber field matches the incoming fromNumber received in the Sinch webhook payload. Accurate and consistent use of E.164 is critical for correct user identification.
The guide is designed for Next.js version 14 or later, using the App Router.
Prisma is a modern Object-Relational Mapper (ORM) that simplifies database interactions. It provides a type-safe way to access and manipulate data in your PostgreSQL database, including creating, updating, and querying users and message data.
TypeScript adds static typing to your project, which improves code maintainability, developer experience with enhanced autocompletion and error detection, and helps catch errors early in development. It's recommended to leverage TypeScript in any large-scale Next.js application.
User passwords are securely hashed using bcryptjs before being stored in the database. This ensures that even if the database is compromised, the raw passwords remain protected.
This guide doesn't explicitly cover deployment steps, however common platforms for deploying a Next.js app include Vercel, Netlify, AWS Amplify, or Google Cloud Run. In your chosen production environment, ensure your environment variables are correctly set, and database connections configured.
Learn how to build a Next.js application that receives and processes inbound SMS messages using Sinch webhooks with NextAuth authentication. This comprehensive tutorial covers webhook security with HMAC-SHA256 verification, E.164 phone number validation, user authentication, and production deployment.
You'll create a secure web application where authenticated users receive SMS messages sent to a dedicated Sinch phone number, automatically linked to their account via their registered phone number.
What you'll build:
Table of Contents
Project Overview and Goals
Your application features:
Why receive inbound SMS with Next.js and Sinch?
Traditional web applications can't directly receive SMS messages from users. This guide shows you how to programmatically receive and process SMS messages through Sinch webhooks, linking them to authenticated user accounts within a modern Next.js framework. Use this foundation for:
Technologies Used:
next-auth@beta(v5.0.0-beta.25), now branded as Auth.js. While feature-complete and widely used, beta versions may contain bugs or breaking changes. For maximum stability in production, consider using the latest stable v4 release or carefully test v5 before deploying. NextAuth v5 requires Next.js 14.0 or higher.)create-next-app).System Architecture:
Prerequisites:
opensslcommand line tool for generating secrets. (Alternatives for Windows include Git Bash, Windows Subsystem for Linux (WSL), or online generation tools – use trusted tools for security-sensitive secrets).Final Outcome:
A deployed Next.js application where users can register and login with phone number authentication. Incoming SMS messages sent to the configured Sinch number are securely received, verified via HMAC-SHA256, associated with the correct user based on their registered phone number (using consistent E.164 format), and stored in the PostgreSQL database.
1. Set Up Your Next.js Project
Initialize your Next.js application and install the necessary dependencies for receiving inbound SMS.
1.1 Create Next.js Application
Open your terminal and create a new Next.js project:
npx create-next-app@latest sinch-inbound-app --typescript --eslint --tailwind --src-dir --app --use-npm cd sinch-inbound-appEnable TypeScript, ESLint, Tailwind CSS, the
src/directory, and the App Router when prompted. These options create a production-ready project structure with modern Next.js features.1.2 Install Required Dependencies
Install authentication, database, and Sinch-related packages:
Package explanations:
next-auth@beta(v5.0.0-beta.25) – Authentication library, now branded as Auth.js@next-auth/prisma-adapter– Connects NextAuth to your Prisma databaseprismaand@prisma/client– ORM for type-safe database accesspg– PostgreSQL driver required by Prismabcryptjsand@types/bcryptjs– Secure password hashing (10 salt rounds)zod– Runtime validation for credentials, webhooks, and phone numbers@sinch/sdk-core– Optional Sinch SDK for sending replies (not required for receiving)1.3 Initialize Prisma
Set up Prisma in your project:
This creates a
prismadirectory withschema.prismaand a.envfile at your project root.1.4 Configure Environment Variables
Open the
.envfile created by Prisma and add these configuration variables. Never commit this file to version control – it contains sensitive credentials.Security requirements:
AUTH_SECRET– Generate withopenssl rand -base64 32. Never use a weak or hardcoded value. This protects all user sessions.SINCH_WEBHOOK_SECRET– Generate withopenssl rand -base64 32. You create this secret and configure it in both your code and the Sinch dashboard. Sinch uses it to sign webhook requests.DATABASE_URL– Use SSL in production: add?sslmode=requireto your connection string.NEXTAUTH_URL– Must match your deployment URL exactly, including protocol (http:// or https://).1.5 Define Database Schema
Open
prisma/schema.prismaand define your data models. Store phone numbers in E.164 format (+15551234567) for consistent international number handling.Key design decisions:
1.6 Apply Database Migrations
Sync your Prisma schema with your PostgreSQL database:
This command creates all tables, indexes, and constraints defined in your schema. Use
db pushfor rapid development iteration.For production deployments, use Prisma's migration system for version control:
# Create a migration file npx prisma migrate dev --name init # Deploy migrations to production npx prisma migrate deployMigrations provide:
1.7 Project Structure Overview
Your
srcdirectory structure:2. Implement User Authentication
Set up NextAuth.js (Auth.js v5) for secure user registration and login with email, password, and phone number validation.
2.1 Create Prisma Client Instance
Create a singleton Prisma client to prevent connection exhaustion in development:
// src/lib/prisma.ts import { PrismaClient } from '@prisma/client'; declare global { // eslint-disable-next-line no-var var prisma: PrismaClient | undefined; } // Configure logging based on environment const prisma = global.prisma || new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] // Verbose logging in development : ['error'], // Only errors in production }); // Prevent multiple instances in development hot-reload if (process.env.NODE_ENV !== 'production') { global.prisma = prisma; } export default prisma;Why use a global variable? Next.js hot-reloading in development creates new module instances on each save. Without the global variable, you'd create dozens of database connections, eventually exhausting your connection pool.
2.2 NextAuth Base Configuration (
auth.config.ts)Define basic configurations like custom pages and the authorization callback used by the middleware.
// src/auth.config.ts import type { NextAuthConfig } from 'next-auth'; export const authConfig = { // trustHost: true, // Consider uncommenting if deploying behind a proxy and facing issues, but understand security implications. pages: { signIn: '/login', // Redirect users to /login if they need to authenticate // error: '/auth/error', // Optional: Custom error page }, callbacks: { authorized({ auth, request: { nextUrl } }) { const isLoggedIn = !!auth?.user; const isOnDashboard = nextUrl.pathname.startsWith('/dashboard'); const isOnAuthPage = nextUrl.pathname === '/login' || nextUrl.pathname === '/signup'; if (isOnDashboard) { if (isLoggedIn) return true; // Allow access if logged in return false; // Redirect unauthenticated users to login page } else if (isLoggedIn && isOnAuthPage) { // If logged in and trying to access login/signup, redirect to dashboard const dashboardUrlBase = process.env.NEXTAUTH_URL; if (dashboardUrlBase) { // Prefer absolute redirect if NEXTAUTH_URL is set console.log(`Redirecting logged-in user from ${nextUrl.pathname} to dashboard.`); return Response.redirect(new URL('/dashboard', dashboardUrlBase)); } else { // Fallback to relative redirect if NEXTAUTH_URL is missing (less ideal) console.warn('NEXTAUTH_URL is not set. Attempting relative redirect to /dashboard.'); // Note: Relative redirects in middleware might not work as expected in all environments. // Consider ensuring NEXTAUTH_URL is always set. // For this example, we attempt it, but it might require returning false to trigger default redirect. return Response.redirect(new URL('/dashboard', nextUrl.origin)); // Try constructing absolute from origin } } // Allow access to all other pages (home, public routes, auth pages if not logged in) return true; }, // JWT and Session callbacks are defined in auth.ts }, providers: [ // Provider definitions will go in auth.ts ], } satisfies NextAuthConfig;2.3 NextAuth Main Configuration (
auth.ts)This file includes the provider logic (Credentials), database interactions, and password handling.
// src/auth.ts import NextAuth from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; import { z } from 'zod'; import bcrypt from 'bcryptjs'; import { PrismaAdapter } from '@next-auth/prisma-adapter'; import { authConfig } from './auth.config'; import prisma from '@/lib/prisma'; import type { User } from '@prisma/client'; // Helper function to find user by email async function getUserByEmail(email: string): Promise<User | null> { try { // In production, replace console.log with structured logging // logger.info({ email }, 'Attempting to fetch user by email'); return await prisma.user.findUnique({ where: { email } }); } catch (error) { // Log error to a monitoring service in production console.error('Failed to fetch user by email:', error); // logger.error({ email, error }, 'Database error while fetching user'); throw new Error('Database error while fetching user.'); // Re-throw for NextAuth to handle } } export const { handlers: { GET, POST }, auth, signIn, signOut, } = NextAuth({ ...authConfig, // Spread the base config adapter: PrismaAdapter(prisma), // Use Prisma for session, account, etc. storage session: { strategy: 'jwt' }, // Use JWT strategy for sessions providers: [ Credentials({ async authorize(credentials) { // 1. Validate credentials using Zod const parsedCredentials = z .object({ email: z.string().email({ message: 'Invalid email format.' }), password: z.string().min(6, { message: 'Password must be at least 6 characters.' }), }) .safeParse(credentials); if (!parsedCredentials.success) { console.log('Invalid credentials format:', parsedCredentials.error.flatten().fieldErrors); return null; // Returning null triggers CredentialsSignin error type } const { email, password } = parsedCredentials.data; // 2. Find user in the database const user = await getUserByEmail(email); if (!user || !user.password) { console.log(`No user found for email: ${email} or user has no password set.`); return null; // User not found or password not set } // 3. Compare passwords const passwordsMatch = await bcrypt.compare(password, user.password); if (passwordsMatch) { console.log(`User ${email} authenticated successfully.`); // Return the user object required by NextAuth // Ensure the returned object matches NextAuth's expected User shape for the session/JWT return { id: user.id, email: user.email, name: user.name, image: user.image }; } // 4. If passwords don't match console.log(`Password mismatch for user: ${email}`); return null; // Invalid password }, }), // Add other providers like Google, GitHub, etc. here if needed ], callbacks: { ...authConfig.callbacks, // Include callbacks from authConfig (like `authorized`) async jwt({ token, user }) { // Add custom claims to JWT (e.g., user ID) after sign in if (user) { token.id = user.id; // You could add roles or other data here if needed // token.role = user.role; // Assuming user object has role } return token; }, async session({ session, token }) { // Add custom data to the session object from the JWT if (token && session.user) { session.user.id = token.id as string; // Add user ID to session.user // session.user.role = token.role; // Add role if present in token } return session; }, } });2.4 Middleware for Route Protection
Create the middleware file to protect routes based on authentication status using the
authorizedcallback defined inauth.config.ts.// src/middleware.ts import NextAuth from 'next-auth'; import { authConfig } from './auth.config'; // Initialize NextAuth using the configuration that contains the 'authorized' callback export default NextAuth(authConfig).auth; export const config = { // Matcher specifies routes where the middleware should run. // It uses a negative lookahead to exclude API routes, static files, images, etc. // Adjust the matcher if you have other top-level routes to exclude or include. // Example: ['/dashboard/:path*', '/settings'] matcher: ['/((?!api|_next/static|_next/image|.*\\.png$|favicon.ico).*)'], };2.5 Server Actions for Auth (
actions/auth.ts)Create Server Actions for handling sign-up and login form submissions.
// src/lib/actions/auth.ts 'use server'; import { z } from 'zod'; import bcrypt from 'bcryptjs'; import { AuthError } from 'next-auth'; import { signIn, signOut } from '@/auth'; // Import signIn/signOut from auth.ts import prisma from '@/lib/prisma'; // --- Login Action --- const LoginSchema = z.object({ email: z.string().email({ message: 'Please enter a valid email.' }), password: z.string().min(1, { message: 'Password is required.' }), // Basic check, NextAuth authorize does deeper check }); export type LoginState = { errors?: { email?: string[]; password?: string[]; credentials?: string[]; // For general sign-in errors }; message?: string | null; }; export async function authenticate( prevState: LoginState | undefined, formData: FormData, ): Promise<LoginState> { const validatedFields = LoginSchema.safeParse( Object.fromEntries(formData.entries()), ); if (!validatedFields.success) { return { errors: validatedFields.error.flatten().fieldErrors, message: 'Invalid fields. Failed to Login.', }; } const { email, password } = validatedFields.data; try { // In production, replace console.log with structured logging console.log(`Attempting sign in for user: ${email}`); await signIn('credentials', { email, password, redirect: false, // Handle redirect via middleware or explicitly on success return }); // If signIn succeeds without throwing, middleware will likely handle redirect. console.log(`Sign in successful for: ${email}. Redirect should occur via middleware.`); // Return success state, though middleware might redirect before this is processed client-side return { message: 'Login successful! Redirecting…' }; } catch (error) { if (error instanceof AuthError) { // Log specific AuthError types for debugging switch (error.type) { case 'CredentialsSignin': console.error('CredentialsSignin error:', error.cause?.err?.message); return { message: 'Invalid email or password.', errors: { credentials: ['Invalid email or password.'] } }; case 'CallbackRouteError': console.error('CallbackRouteError:', error.cause?.err?.message); return { message: `Authentication callback failed: ${error.cause?.err?.message || 'Unknown callback error'}`, errors: { credentials: ['Authentication callback failed.'] } }; default: console.error('Unknown NextAuth error:', error); return { message: 'Something went wrong during authentication.', errors: { credentials: ['An unexpected error occurred.'] } }; } } // Handle non-AuthError exceptions (e.g., database connection issues caught before signIn is called) console.error('Non-AuthError during authenticate:', error); // Consider re-throwing if it's a critical, unexpected error that should halt execution. // For now, return a generic server error message. // throw error; // Uncomment if critical errors should propagate and potentially crash the request return { message: 'An unexpected server error occurred during login.', errors: { credentials: ['Server error during login.'] } }; } } // --- Sign Up Action --- const SignUpSchema = z.object({ email: z.string().email({ message: 'Please enter a valid email.' }), password: z.string().min(6, { message: 'Password must be at least 6 characters.' }), name: z.string().min(1, { message: 'Name is required.' }), // E.164 format validation: + followed by 1-15 digits, first digit 1-9 (country codes don't start with 0) // Recommended pattern from ITU E.164 specification phoneNumber: z.string() .min(8, { message: 'Please enter a valid phone number (e.g., +15551234567).' }) .regex(/^\+[1-9]\d{1,14}$/, { message: "Invalid phone number format. Must be E.164 format: +[country code][number] (e.g., +15551234567). Total 2-15 digits." }), }); export type SignUpState = { errors?: { email?: string[]; password?: string[]; name?: string[]; phoneNumber?: string[]; server?: string[]; // For general server/database errors }; message?: string | null; success?: boolean; }; export async function registerUser( prevState: SignUpState | undefined, formData: FormData, ): Promise<SignUpState> { const validatedFields = SignUpSchema.safeParse( Object.fromEntries(formData.entries()), ); if (!validatedFields.success) { return { errors: validatedFields.error.flatten().fieldErrors, message: 'Invalid fields. Failed to Sign Up.', success: false, }; } const { email, password, name, phoneNumber } = validatedFields.data; // Ensure phone number starts with '+' for consistent E.164 storage, if not already present const formattedPhoneNumber = phoneNumber.startsWith('+') ? phoneNumber : `+${phoneNumber}`; // Check if user already exists try { const existingUserByEmail = await prisma.user.findUnique({ where: { email } }); if (existingUserByEmail) { return { message: 'Email already in use.', errors: { email: ['Email already exists.'] }, success: false }; } const existingUserByPhone = await prisma.user.findUnique({ where: { phoneNumber: formattedPhoneNumber } }); if (existingUserByPhone) { return { message: 'Phone number already in use.', errors: { phoneNumber: ['Phone number already exists.'] }, success: false }; } // Hash the password const hashedPassword = await bcrypt.hash(password, 10); // Salt rounds = 10 // Create the user in the database await prisma.user.create({ data: { email, password: hashedPassword, name, phoneNumber: formattedPhoneNumber, // Store consistently formatted number }, }); // In production, replace console.log with structured logging console.log(`User registered successfully: ${email}`); // Optionally: Automatically sign in the user after registration // await signIn('credentials', { email, password, redirect: false }); return { message: 'Registration successful! Please log in.', success: true }; } catch (error) { console.error('Registration error:', error); // Log to monitoring service in production // Check for specific Prisma unique constraint violation errors (race condition safety) if (error instanceof Error && 'code' in error && (error as any).code === 'P2002') { const target = (error as any).meta?.target; if (target?.includes('email')) { return { message: 'Email already in use.', errors: { email: ['Email already exists.'] }, success: false }; } if (target?.includes('phoneNumber')) { return { message: 'Phone number already in use.', errors: { phoneNumber: ['Phone number already exists.'] }, success: false }; } } return { message: 'Database error: Failed to register user.', errors: { server: ['Could not create user account.'] }, success: false }; } } // --- Sign Out Action --- export async function logout() { try { // In production, replace console.log with structured logging console.log("Attempting user sign out."); await signOut({ redirectTo: '/login' }); // Redirect to login after sign out } catch (error) { console.error("Sign out error:", error); // Log error to monitoring service in production // Handle potential errors during sign out, though typically straightforward // Maybe redirect to an error page or show a message if signOut itself fails // For now, the redirect might still happen client-side even if server action errors. } }2.6 UI Components (Login and Sign Up Forms)
Create basic form components. Note: These examples assume you have basic
Button,Input, andLabelcomponents. Install shadcn/ui for pre-built components:// src/components/ui/button.tsx, input.tsx, label.tsx // ... Implement or install basic UI components ... // src/components/auth/LoginForm.tsx 'use client'; import { useActionState } from 'react'; import { authenticate, LoginState } from '@/lib/actions/auth'; import { Button } from '@/components/ui/button'; // Adjust path if needed import { Input } from '@/components/ui/input'; // Adjust path if needed import { Label } from '@/components/ui/label'; // Adjust path if needed import Link from 'next/link'; // Use Next.js Link for navigation export default function LoginForm() { const initialState: LoginState | undefined = undefined; const [state, formAction, isPending] = useActionState(authenticate, initialState); return ( <form action={formAction} className="space-y-4 w-full max-w-sm mx-auto p-6 border rounded-lg shadow-md bg-white"> <h2 className="text-2xl font-semibold text-center">Login</h2> {/* Display general success or error messages */} {state?.message && !state.errors?.credentials && !state.errors?.email && !state.errors?.password && ( <p className={`text-sm p-2 rounded ${state.errors ? 'text-red-600 bg-red-100' : 'text-green-600 bg-green-100'}`}> {state.message} </p> )} {/* Display specific credentials error */} {state?.errors?.credentials && ( <p className="text-sm text-red-600 bg-red-100 p-2 rounded">{state.errors.credentials.join(', ')}</p> )} <div className="space-y-1"> <Label htmlFor="email">Email</Label> <Input id="email" name="email" type="email" placeholder="you@example.com" required aria-describedby="email-error" /> {state?.errors?.email && ( <p id="email-error" className="text-sm text-red-500"> {state.errors.email.join(', ')} </p> )} </div> <div className="space-y-1"> <Label htmlFor="password">Password</Label> <Input id="password" name="password" type="password" required minLength={6} aria-describedby="password-error" /> {state?.errors?.password && ( <p id="password-error" className="text-sm text-red-500"> {state.errors.password.join(', ')} </p> )} </div> <Button type="submit" className="w-full" aria-disabled={isPending}> {isPending ? 'Logging in…' : 'Log In'} </Button> <p className="text-center text-sm text-gray-600"> Don't have an account?{' '} <Link href="/signup" className="text-blue-600 hover:underline"> Sign Up </Link> </p> </form> ); } // src/components/auth/SignUpForm.tsx 'use client'; import { useActionState, useEffect } from 'react'; import { registerUser, SignUpState } from '@/lib/actions/auth'; import { Button } from '@/components/ui/button'; // Adjust path import { Input } from '@/components/ui/input'; // Adjust path import { Label } from '@/components/ui/label'; // Adjust path import { useRouter } from 'next/navigation'; import Link from 'next/link'; // Use Next.js Link export default function SignUpForm() { const initialState: SignUpState | undefined = undefined; const [state, formAction, isPending] = useActionState(registerUser, initialState); const router = useRouter(); // Redirect on successful registration useEffect(() => { if (state?.success) { // Consider showing a success toast/notification here instead of alert console.log("Registration successful:", state.message); // Redirect to login page after successful registration router.push('/login'); } }, [state?.success, state?.message, router]); return ( <form action={formAction} className="space-y-4 w-full max-w-sm mx-auto p-6 border rounded-lg shadow-md bg-white"> <h2 className="text-2xl font-semibold text-center">Sign Up</h2> {/* Display general error messages (not success message, as we redirect) */} {state?.message && !state.success && ( <p className="text-sm text-red-600 bg-red-100 p-2 rounded">{state.message}</p> )} {state?.errors?.server && ( <p className="text-sm text-red-600 bg-red-100 p-2 rounded">{state.errors.server.join(', ')}</p> )} <div className="space-y-1"> <Label htmlFor="name">Name</Label> {/* ... (rest of SignUpForm component - input fields for name, email, phone, password) ... */} {/* Make sure inputs have correct id, name, type, required attributes, and error handling similar to LoginForm */} {/* Example for Name input: */} <Input id="name" name="name" type="text" placeholder="Your Name" required aria-describedby="name-error" /> {state?.errors?.name && ( <p id="name-error" className="text-sm text-red-500"> {state.errors.name.join(', ')} </p> )} </div> {/* Add similar divs for Email, Phone Number, and Password */} <div className="space-y-1"> <Label htmlFor="email">Email</Label> <Input id="email" name="email" type="email" placeholder="you@example.com" required aria-describedby="signup-email-error" /> {state?.errors?.email && ( <p id="signup-email-error" className="text-sm text-red-500"> {state.errors.email.join(', ')} </p> )} </div> <div className="space-y-1"> <Label htmlFor="phoneNumber">Phone Number (e.g., +15551234567)</Label> <Input id="phoneNumber" name="phoneNumber" type="tel" // Use type="tel" for phone numbers placeholder="+15551234567" required aria-describedby="phoneNumber-error" /> {state?.errors?.phoneNumber && ( <p id="phoneNumber-error" className="text-sm text-red-500"> {state.errors.phoneNumber.join(', ')} </p> )} </div> <div className="space-y-1"> <Label htmlFor="signup-password">Password</Label> <Input id="signup-password" name="password" type="password" required minLength={6} aria-describedby="signup-password-error" /> {state?.errors?.password && ( <p id="signup-password-error" className="text-sm text-red-500"> {state.errors.password.join(', ')} </p> )} </div> <Button type="submit" className="w-full" aria-disabled={isPending}> {isPending ? 'Signing Up…' : 'Sign Up'} </Button> <p className="text-center text-sm text-gray-600"> Already have an account?{' '} <Link href="/login" className="text-blue-600 hover:underline"> Log In </Link> </p> </form> ); }