code examples
code examples
Implement SMS 2FA with Vonage Verify API in RedwoodJS: Complete OTP Guide
Build secure two-factor authentication (2FA) with SMS OTP in RedwoodJS using Vonage Verify API, dbAuth, and Prisma. Includes step-by-step implementation, error handling, and production best practices.
Implement SMS 2FA with Vonage Verify API in RedwoodJS
Two-Factor Authentication (2FA) via SMS One-Time Passwords (OTP) adds critical security for financial applications, healthcare portals, admin dashboards, and any platform handling sensitive user data. This guide walks you through integrating the Vonage Verify API into a RedwoodJS application to implement SMS OTP verification.
Build a RedwoodJS application with standard database authentication (dbAuth) enhanced with OTP verification after password login. Users provide their phone number during registration or profile update, and upon login, receive an SMS code via Vonage to complete authentication.
Table of Contents
- Project Overview and Goals
- Setting up the Project
- Implementing Core Functionality (API Side)
- Building the API Layer (GraphQL)
- Integrating with Third-Party Services (Vonage)
- Implementing Error Handling and Logging
- Database Schema and Data Layer
- Adding Security Features
- Frequently Asked Questions
Project Overview and Goals
What You'll Build:
- RedwoodJS application using
dbAuthfor user/password authentication - Vonage Verify API integration for sending and verifying SMS OTPs
- Modified login flow requiring OTP verification after password entry
- Backend services and GraphQL mutations for OTP requests and verification
- Frontend pages and components for login and OTP code entry
Problem Solved:
This implementation adds a second authentication factor ("something you have" – your phone) to the standard "something you know" (password), protecting against unauthorized access. 2FA is legally required for financial services under PCI DSS, healthcare under HIPAA, and government systems under NIST 800-63B. It blocks 99.9% of automated bot attacks and credential stuffing attempts.
Real-World Security Impact:
- Banking apps: Prevents unauthorized transfers even with compromised passwords
- Healthcare portals: Protects HIPAA-compliant patient data access
- Admin dashboards: Stops brute force attacks on privileged accounts
- E-commerce: Reduces account takeover fraud by 96% (Source: Google Security Blog)
Technologies Used:
- RedwoodJS: Full-stack JavaScript/TypeScript framework with built-in GraphQL, Prisma, Jest testing, and auth scaffolding. Requires Node.js 20+ (October 2025). RedwoodJS Prerequisites
- Node.js: Runtime environment for the RedwoodJS API side. Minimum: Node.js 20.x (October 2025, Node.js 21+ compatible but may limit AWS Lambda deployment). Download LTS version.
- Yarn: Package manager for RedwoodJS. Minimum: Yarn 1.22.21+ (October 2025).
- Prisma: Database toolkit for schema management, migrations, and type-safe database access.
- React: UI framework for the RedwoodJS web side.
- GraphQL: Query language for web/API communication.
- Vonage Verify API: Service handling OTP sending via SMS (and voice) and code verification. Generates Time-Based One-Time PINs per RFC 6238.
@vonage/server-sdk: Official Vonage Node.js SDK. Current version: 3.24.1 (October 2025). Vonage Node SDK
Version Compatibility (October 2025):
- Node.js 20+ required by RedwoodJS. Node.js 21+ compatible but affects AWS Lambda deployment.
- Yarn 1.22.21+ required.
- Vonage SDK v3.x uses Promises (no callbacks). Use async/await patterns throughout integration code.
System Architecture:
+-------------+ +-----------------+ +-----------------+ +--------+ +-------------+
| User Browser| ----> | Redwood Web | ----> | Redwood API | ----> | Vonage | ----> | User's Phone|
| (React UI) | | (React, Apollo) | | (GraphQL, Prisma| | Verify | | (SMS) |
+-------------+ +-----------------+ | Vonage SDK) | | API | +-------------+
| | +-------+---------+ +--------+
| Login Form | GQL Mutation Call |
| OTP Form | | Call Vonage API
| | | Verify Code
| | |
| | |
+---------------------+-----------------------------+
| Database (Prisma)
+-----------------+
Authentication Flow:
- User Interaction: User accesses the login page.
- Login Attempt: User submits email/password via form.
- Initial Auth: Web side sends GraphQL mutation to API. API verifies password against database using Prisma and
dbAuth. - OTP Trigger: If password is correct, API initiates OTP request using Vonage SDK, targeting the user's registered phone number. Vonage sends the SMS.
- OTP Request ID: Vonage returns a
request_idto the API, which stores it temporarily in the database. API responds to web side, indicating OTP is required. - OTP Entry: Web side redirects user to OTP entry page.
- OTP Verification: User submits the received OTP code. Web side sends
verifyOtpGraphQL mutation with the code and user identifier. - Code Check: API uses the stored
request_idand submitted code to ask Vonage to verify validity. - Session Grant: If Vonage confirms the code is correct, API marks user as authenticated (updates timestamp, clears
request_id) and generates the RedwoodJS session. Returns authenticated user data. - Access Granted: Web side receives confirmation, establishes user session locally (using
useAuth), and redirects to the protected area.
Failure Scenarios and Recovery:
| Failure Point | Recovery Flow |
|---|---|
| Password incorrect | Display error, allow retry (rate-limited) |
| SMS delivery failure | Vonage auto-retries, user can request new code after 60s |
| Wrong OTP code | Allow 3 attempts, then require new login |
| OTP expired (5 min) | Display timeout message, restart login flow |
| Network timeout | Retry with exponential backoff, fallback error message |
| Vonage API outage | Log error, display maintenance message, email support alert |
Prerequisites:
- Node.js v20 or later (required by RedwoodJS, October 2025)
- Yarn v1.22.21 or later
- RedwoodJS CLI (
yarn global add @redwoodjs/cli) - Vonage API account (Sign up at Vonage Dashboard)
- Understanding of RedwoodJS concepts: Cells, Services, GraphQL,
dbAuth(RedwoodJS Authentication Docs, dbAuth Guide)
Critical Vonage Verify API Specifications (October 2025):
⚠️ PIN Code Defaults:
- Code Length: 4 digits (default), configurable to 6 digits
- Expiry Time: 5 minutes (300 seconds) default, customizable via
pin_expiryparameter (60–3600 seconds) - Code Generation: Time-Based One-Time PINs per RFC 6238
- Source: Vonage Verify PIN Validity
⚠️ Rate Limiting Requirements:
- Critical: Implement rate limits per phone number to prevent abuse and toll fraud
- Recommended: Limit verification requests per phone number within time windows
- Built-in Protection: Vonage limits 3 attempts per request_id
- Best Practice: Add application-level rate limiting before calling Vonage API
⚠️ Phone Number Format:
- Required Format: E.164 international standard (ITU-T Recommendation)
- Structure:
+[Country Code][Subscriber Number](max 15 digits total) - Examples: US
+14155551234, UK+442012345678, France+33612345678 - Validation: No spaces, parentheses, or dashes
- Source: E.164 Standard
Expected Outcome:
A functional RedwoodJS application where users verify identity via SMS OTP code sent by Vonage after entering their password.
1. Setting up the Project
Create a new RedwoodJS project and set up the basic authentication structure.
1. Create RedwoodJS App:
Open your terminal and run:
yarn create redwood-app ./redwood-vonage-otp
cd redwood-vonage-otpThis scaffolds a new RedwoodJS project in the redwood-vonage-otp directory.
2. Setup Database Authentication (dbAuth):
Use RedwoodJS generators to set up dbAuth for email/password login.
yarn rw setup auth dbAuthThis command:
- Adds auth packages (
@redwoodjs/auth-dbauth-api,@redwoodjs/auth-dbauth-web) - Creates Login, Signup, Forgot Password, and Reset Password pages and routes
- Adds
Usermodel to Prisma schema (api/db/schema.prisma) withhashedPassword,salt,resetToken, etc. - Creates auth services and GraphQL definitions (
api/src/services/auth.ts,api/src/graphql/auth.sdl.ts) - Sets up
Authcontext provider inweb/src/App.tsx - Note: If setup fails due to existing auth configuration, remove existing auth files or start with a fresh project. Read post-install instructions for
api/src/functions/auth.jsconfiguration. Official dbAuth Guide
3. Add User Phone Number to Schema:
Store the user's phone number for OTP sending. Modify the User model in api/db/schema.prisma:
// api/db/schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
hashedPassword String
salt String
resetToken String?
resetTokenExpiresAt DateTime?
webAuthnChallenge String? @unique
// Add these fields for OTP
phoneNumber String? // Optional: Add @unique if phone numbers must be unique. MUST be E.164 format (+14155551234)
otpRequestId String? // Store the Vonage request ID temporarily
otpVerifiedAt DateTime? // Timestamp of the last successful OTP verification
otpRequired Boolean @default(false) // Flag to indicate if OTP is enabled/required for the user
sessions UserSession[]
}
// ... rest of the schema (UserSession, UserCredential)Field Descriptions:
| Field | Purpose | Format |
|---|---|---|
phoneNumber | User's phone number | E.164 format (e.g., +14155552671 US, +442071234567 UK). Add @unique constraint if your app requires unique phone numbers per user. |
otpRequestId | Vonage request_id from OTP initiation | Cleared after successful verification or expiry |
otpVerifiedAt | Timestamp of last successful OTP verification | Use for "Remember this device" functionality (implement by checking if verified within X days) |
otpRequired | Controls OTP enforcement for this user | Set during signup or in user profile management |
E.164 Validation Requirements:
| Aspect | Specification |
|---|---|
| Format | +[1-3 digit country code][up to 12 digit subscriber number] |
| Maximum Length | 15 digits total (including country code) |
| Validation Regex | /^\+[1-9]\d{10,14}$/ (starts with +, no leading zero in country code, 11–15 total digits) |
| Storage | Always store with + prefix and no formatting characters (spaces, dashes, parentheses) |
| User Input | Accept various formats but normalize to E.164 before storage |
Phone Number Normalization Example using libphonenumber-js:
// Install: yarn workspace api add libphonenumber-js
import { parsePhoneNumber } from 'libphonenumber-js'
function normalizePhoneNumber(input: string, defaultCountry: string = 'US'): string {
try {
const phoneNumber = parsePhoneNumber(input, defaultCountry)
if (!phoneNumber || !phoneNumber.isValid()) {
throw new Error('Invalid phone number')
}
return phoneNumber.format('E.164') // Returns +14155551234
} catch (error) {
throw new Error(`Phone number normalization failed: ${error.message}`)
}
}
// Usage in signup service:
const normalizedPhone = normalizePhoneNumber(input.phoneNumber, 'US')4. Apply Database Migrations:
Apply schema changes to your database:
yarn rw prisma migrate dev
# Name the migration (e.g., "add_otp_fields_to_user")This updates your database schema according to changes in schema.prisma.
5. Install Vonage SDK:
Install the Vonage Node.js SDK in the api workspace:
yarn workspace api add @vonage/server-sdk6. Configure Environment Variables:
Create a .env file in the root of your project to store Vonage API credentials securely. Never commit this file to version control.
# ./.env
# Database URL (already generated by Redwood)
DATABASE_URL="file:./dev.db" # Or your PostgreSQL/MySQL URL
# Vonage API Credentials
VONAGE_API_KEY="YOUR_VONAGE_API_KEY"
VONAGE_API_SECRET="YOUR_VONAGE_API_SECRET"
# Optional: Define a brand name for OTP messages
VONAGE_BRAND_NAME="YourAppName"Obtain Credentials:
VONAGE_API_KEY/VONAGE_API_SECRET: Get these from your Vonage API Dashboard under "API settings"VONAGE_BRAND_NAME: Name appearing in SMS messages (e.g., "YourAppName code: 1234. Valid for 5 minutes."). Keep it short and recognizable.
Security Configuration:
Ensure .env is in .gitignore:
# Check .gitignore contains:
.env
.env.*
!.env.exampleRedwoodJS automatically loads variables from .env into process.env on API and Web sides.
2. Implementing Core Functionality (API Side)
Modify the API side to handle OTP logic within the authentication flow.
1. Initialize Vonage Client:
Centralize Vonage client initialization. Create a utility file:
// api/src/lib/vonage.ts
import { Vonage } from '@vonage/server-sdk'
import { logger } from 'src/lib/logger' // Redwood's logger
if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
const error = new Error(
'Vonage API Key or Secret not found. Set VONAGE_API_KEY and VONAGE_API_SECRET in your .env file.'
)
logger.error({ error }, 'Vonage client initialization failed')
throw error
}
let vonageInstance: Vonage
try {
vonageInstance = new Vonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET,
})
logger.info('Vonage client initialized successfully.')
} catch (error) {
logger.error({ error }, 'Failed to create Vonage client instance')
throw new Error('Vonage client initialization failed. Check API credentials.')
}
export const vonage = vonageInstance
export const vonageBrand = process.env.VONAGE_BRAND_NAME || 'MyApp'2. Modify Authentication Service (auth.ts):
Intercept the standard dbAuth login process. After verifying the password, trigger Vonage OTP request if OTP is required for the user.
Update the login function in api/src/services/auth/auth.ts:
// api/src/services/auth/auth.ts
import type { Prisma } from '@prisma/client'
import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server'
import { generateToken, hashPassword } from '@redwoodjs/auth-dbauth-api'
import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'
import { vonage, vonageBrand } from 'src/lib/vonage'
// Define custom result types for login to signal OTP requirement
interface LoginSuccessResult {
__typename: 'CurrentUser'
id: number
email: string | null
}
interface OtpRequiredResult {
__typename: 'OtpRequired'
otpRequired: true
userId: number
message: string
}
type LoginResult = LoginSuccessResult | OtpRequiredResult
export const login = async ({
username, // dbAuth uses 'username' which maps to email here
password,
}: Prisma.UserWhereUniqueInput & {
password?: string
}): Promise<LoginResult> => {
const user = await db.user.findUnique({ where: { email: username } })
if (!user) {
throw new AuthenticationError('User not found.')
}
if (!user.hashedPassword || !user.salt) {
throw new Error('User missing password credential.')
}
const passwordsMatch =
(await hashPassword(password, user.salt)) === user.hashedPassword
if (!passwordsMatch) {
throw new AuthenticationError('Invalid password.')
}
// --- OTP Logic Start ---
if (user.otpRequired && user.phoneNumber) {
logger.info({ userId: user.id }, 'OTP required for user. Initiating Vonage verify request.')
try {
const result = await vonage.verify.start({
number: user.phoneNumber,
brand: vonageBrand,
// Customize options: code_length (default 4), pin_expiry (default 300s)
// code_length: 6,
// pin_expiry: 180,
})
if (result.status === '0' && result.request_id) {
// Successfully initiated OTP request, store the request ID
await db.user.update({
where: { id: user.id },
data: { otpRequestId: result.request_id },
})
logger.info({ userId: user.id, requestId: result.request_id }, 'Vonage verify request initiated successfully.')
return {
__typename: 'OtpRequired',
otpRequired: true,
userId: user.id,
message: 'OTP code sent to your phone. Enter the code to continue.',
}
} else {
// Handle Vonage initiation errors
logger.error({ userId: user.id, vonageError: result }, 'Vonage verify request initiation failed.')
let errorMessage = `Failed to send OTP code (${result.status}): ${result.error_text || 'Unknown Vonage error'}`
if (result.status === '3') {
errorMessage = 'Invalid phone number format. Update your phone number in profile settings.'
} else if (result.status === '101') {
errorMessage = 'Verification service configuration error. Contact support.'
}
throw new AuthenticationError(errorMessage)
}
} catch (error) {
logger.error({ userId: user.id, error }, 'Error during Vonage verify request initiation.')
throw new AuthenticationError(
'An unexpected error occurred while sending the OTP code. Try again later.'
)
}
}
// --- OTP Logic End ---
logger.info({ userId: user.id }, 'OTP not required or bypassed. Proceeding with standard login.')
// Return user data for session creation
// Do NOT return sensitive info like hashedPassword
return {
__typename: 'CurrentUser',
id: user.id,
email: user.email,
}
}
// --- Add verifyOtp Service Function ---
interface VerifyOtpInput {
userId: number
code: string
}
export const verifyOtp = async ({
userId,
code,
}: VerifyOtpInput): Promise<LoginSuccessResult> => {
// 1. Fetch user and their pending otpRequestId
const user = await db.user.findUnique({ where: { id: userId } })
if (!user || !user.otpRequestId) {
logger.warn({ userId }, 'Attempt to verify OTP without a pending request ID or user not found.')
throw new AuthenticationError(
'No pending OTP verification found or user invalid. Try logging in again.'
)
}
logger.info({ userId, requestId: user.otpRequestId }, 'Attempting to verify OTP code with Vonage.')
// 2. Call Vonage Verify Check API
try {
const result = await vonage.verify.check(user.otpRequestId, code)
if (result.status === '0') {
logger.info({ userId, requestId: user.otpRequestId }, 'Vonage OTP verification successful.')
// Update user record: clear request ID, set verified timestamp
await db.user.update({
where: { id: user.id },
data: {
otpVerifiedAt: new Date(),
otpRequestId: null,
},
})
// Return user data to establish session
return {
__typename: 'CurrentUser',
id: user.id,
email: user.email,
}
} else {
// Handle Vonage verification errors
logger.warn({ userId, requestId: user.otpRequestId, vonageResult: result }, 'Vonage OTP verification failed.')
let errorMessage = `OTP verification failed (${result.status}): ${result.error_text || 'Unknown error'}`
if (result.status === '16') {
errorMessage = 'The code you entered is incorrect. Try again.'
} else if (result.status === '17') {
errorMessage = 'The code has expired or too many attempts were made. Log in again to get a new code.'
} else if (result.status === '6') {
errorMessage = 'The verification request could not be found or has expired. Log in again.'
}
throw new AuthenticationError(errorMessage)
}
} catch (error) {
logger.error({ userId, requestId: user.otpRequestId, error }, 'Error during Vonage verify check.')
throw new AuthenticationError(
'An unexpected error occurred during OTP verification. Try again later.'
)
}
}
// --- Keep/Update other functions like signup, getCurrentUser etc. ---
export const getCurrentUser = async (
session: Record<string, unknown>
): Promise<Prisma.User | null> => {
if (!session || typeof session.id !== 'number') {
throw new AuthenticationError('Session invalid')
}
const user = await db.user.findUnique({
where: { id: session.id as number },
select: { id: true, email: true },
})
if (!user) {
throw new AuthenticationError('User not found based on session')
}
return user
}
// Example: Modify signup to collect phone number and set otpRequired
export const signup = async (input: Prisma.UserCreateInput & { phoneNumber?: string, enableOtp?: boolean }) => {
const { phoneNumber, enableOtp, ...userData } = input
const hashedPassword = await hashPassword(userData.password)
const salt = 'some-generated-salt' // Ensure proper salt generation logic exists
const user = await db.user.create({
data: {
...userData,
hashedPassword: hashedPassword,
salt: salt,
phoneNumber: phoneNumber,
otpRequired: !!enableOtp,
},
select: { id: true, email: true },
})
return user
}Frontend Implementation Overview:
The web side requires three key components:
1. Login Page Modification (web/src/pages/LoginPage/LoginPage.tsx):
- Handle
LoginResultunion type from GraphQL - Check
__typenameto distinguishCurrentUservsOtpRequired - Redirect to OTP page if
OtpRequiredreturned - Store
userIdin session storage for OTP verification
2. OTP Verification Page (web/src/pages/OtpPage/OtpPage.tsx):
- Create new page with OTP code input form
- Retrieve
userIdfrom session storage or route params - Call
verifyOtpmutation withuserIdandcode - Handle success (redirect to dashboard) and errors (display message, allow retry)
- Add countdown timer showing code expiry (5 minutes)
- Provide "Resend code" button (triggers new login flow)
3. GraphQL Type Handling:
// web/src/pages/LoginPage/LoginPage.tsx
const [login] = useMutation(LOGIN_MUTATION)
const onSubmit = async (data) => {
const response = await login({ variables: data })
if (response.data.login.__typename === 'OtpRequired') {
sessionStorage.setItem('otpUserId', response.data.login.userId)
navigate('/otp-verify')
} else if (response.data.login.__typename === 'CurrentUser') {
// Standard login success
navigate('/dashboard')
}
}3. Building the API Layer (GraphQL)
Expose the verifyOtp service function via GraphQL and adjust the login mutation's return type.
1. Update GraphQL Schema Definition (auth.sdl.ts):
Modify the SDL file to define new types and the verifyOtp mutation.
# api/src/graphql/auth.sdl.ts
# Type returned on successful login (standard or after OTP verification)
type CurrentUser {
id: Int!
email: String
}
# Type returned when OTP verification is required after password login
type OtpRequired {
otpRequired: Boolean!
userId: Int!
message: String!
}
# Union type for the login mutation result
union LoginResult = CurrentUser | OtpRequired
type Mutation {
# Update login return type to the Union
login(username: String!, password: String!): LoginResult! @skipAuth
# Add the new verifyOtp mutation
verifyOtp(userId: Int!, code: String!): CurrentUser! @skipAuth
logout: Boolean @requireAuth
signup(input: SignupInput!): CurrentUser! @skipAuth
}
# Define input type for signup
input SignupInput {
email: String!
password: String!
phoneNumber: String
enableOtp: Boolean
}Key Changes:
CurrentUserType: Authenticated user data (returned by direct login and successful OTP verification)OtpRequiredType: State where OTP is neededLoginResultUnion: Theloginmutation returns this union, allowing the frontend to distinguish between immediate success and OTP steploginMutation: Changed return type toLoginResult!. Added@skipAuthas login happens before authentication is establishedverifyOtpMutation:- Takes
userId: Int!andcode: String!as arguments - Returns
CurrentUser!upon successful verification - Uses
@skipAuthbecause this mutation is called before the final session is granted
- Takes
SignupInput: Input type matching the modifiedsignupservice function
2. Generate Types:
Run the Redwood types generator to update TypeScript types based on SDL changes:
yarn rw generate typesThis ensures service functions and frontend components have correct TypeScript definitions for the new GraphQL schema.
4. Integrating with Third-Party Services (Vonage)
This section is covered in Step 2 where we integrated the Vonage SDK (@vonage/server-sdk) into the auth.ts service.
Vonage Integration Summary:
- Initialization:
api/src/lib/vonage.tssecurely initializes the Vonage client using API keys from.env - Environment Variables:
.envstoresVONAGE_API_KEY,VONAGE_API_SECRET, andVONAGE_BRAND_NAME. Configure these in your Vonage Dashboard and.envfile:VONAGE_API_KEY/VONAGE_API_SECRET: Found on the main page of your Vonage API DashboardVONAGE_BRAND_NAME: Configured per request invonage.verify.start. No default dashboard setting for Verify API v1.
- Sending OTP: The
loginservice callsvonage.verify.startafter password validation if OTP is required - Verifying OTP: The
verifyOtpservice callsvonage.verify.checkusing storedrequestIdand user-provided code - Secure Handling: Credentials loaded from
.env, not hardcoded. Ensure.envis in.gitignore - Production Considerations:
- Retries: Implement retry logic (e.g., using
async-retry) aroundvonage.verify.startandvonage.verify.checkfor transient network issues - Monitoring: Monitor Vonage API health and application logs for persistent errors. Set up alerts for:
- Failed OTP send rate > 5%
- Vonage API response time > 3 seconds
- Daily OTP cost exceeds budget threshold
- Alternative Methods: For critical applications, consider alternative 2FA methods (Authenticator App, Email) as fallbacks
- Retries: Implement retry logic (e.g., using
5. Implementing Error Handling and Logging
Basic error handling and logging are incorporated. Let's refine it.
Error Handling Strategy:
- Service Layer: Use
try...catchblocks around external API calls (Vonage) - Specific Errors: Catch known Vonage errors (invalid number, wrong code, expired) and re-throw as
AuthenticationErrororUserInputError(from@redwoodjs/graphql-server) with user-friendly messages - Generic Errors: Catch unexpected errors, log with details, return generic
AuthenticationErrororInternalServerError - GraphQL Layer: Redwood automatically maps thrown errors to GraphQL errors. Frontend receives these in the
errorobject from mutations/queries
Production Error Monitoring:
Configure error tracking service (Sentry, Rollbar, or Bugsnag):
// api/src/lib/logger.ts
import * as Sentry from '@sentry/node'
if (process.env.NODE_ENV === 'production') {
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
})
}
// Wrap critical functions
export const withErrorMonitoring = (fn) => {
return async (...args) => {
try {
return await fn(...args)
} catch (error) {
Sentry.captureException(error)
throw error
}
}
}Logging:
- Redwood Logger: Use
api/src/lib/logger.ts(pre-configured) - Log Levels:
logger.info: Operational messages (e.g., "OTP request initiated", "Verification successful"). Include context likeuserIdandrequestIdlogger.warn: Potential issues or non-critical failures (e.g., "Attempt to verify OTP without pending request")logger.error: Actual errors incatchblocks. Include error object and context
- Log Format: Redwood's default logger provides JSON-formatted logs, suitable for log aggregation systems
- Sensitive Data: Never log passwords or full API secrets. Log API keys only for debugging specific integration issues, and ensure logs are secured
Retry Mechanisms:
Add retries for Vonage calls using async-retry:
yarn workspace api add async-retry @types/async-retry// Example within login service
import retry from 'async-retry';
import { vonage, vonageBrand } from 'src/lib/vonage';
import { logger } from 'src/lib/logger';
import { AuthenticationError } from '@redwoodjs/graphql-server';
try {
const result = await retry(
async (bail) => {
logger.info('Attempting Vonage verify start...');
const response = await vonage.verify.start({
number: user.phoneNumber,
brand: vonageBrand
});
// Don't retry on certain errors (e.g., invalid number format)
if (response.status === '3') {
bail(new Error(`Vonage non-retryable error status ${response.status}: ${response.error_text || 'Invalid number'}`));
return;
}
if (response.status === '0') {
return response;
}
// Throw error to trigger retry for other statuses
throw new Error(`Vonage temporary error status ${response.status}: ${response.error_text || 'Unknown temporary error'}`);
},
{
retries: 3,
factor: 2,
minTimeout: 1000,
onRetry: (error, attempt) => {
logger.warn(`Vonage verify start attempt ${attempt} failed: ${error.message}. Retrying...`);
},
}
);
// Handle successful result
await db.user.update({
where: { id: user.id },
data: { otpRequestId: result.request_id },
})
} catch (error) {
logger.error({ userId: user.id, error }, 'Vonage verify start failed after retries or bailed.');
const message = error instanceof Error ? error.message : 'Failed to initiate OTP verification.';
throw new AuthenticationError(message);
}Apply similar logic to vonage.verify.check in the verifyOtp service. Adjust retry counts and timeouts based on expected API behavior.
6. Database Schema and Data Layer
Covered in Step 1 (Schema Setup) and Step 2 (Service Logic).
- Schema: Defined in
api/db/schema.prisma. IncludesUsermodel withphoneNumber,otpRequestId,otpVerifiedAt,otpRequired - Migrations: Managed using
yarn rw prisma migrate dev. Create migrations for every schema change - Data Access: Handled by Prisma client (
api/src/lib/db.ts) within API services. Redwood provides setup. Prisma offers type-safe database access - Optimization:
- Index
phoneNumberandemailfields (@uniquealready creates an index, add one forphoneNumberif frequently queried:@@index([phoneNumber])) - Database connection pooling (Prisma handles this)
- Analyze query performance using
prisma studioor database-specific analysis
- Index
Data Retention Policy for OTP Records:
| Data Field | Retention Period | Cleanup Method |
|---|---|---|
otpRequestId | 10 minutes after creation | Cleared on successful verification or via cron job |
otpVerifiedAt | 90 days (or per compliance requirements) | Archive or delete via scheduled task |
phoneNumber | Until user deletion or opt-out | Allow user to remove in profile settings |
Implement cleanup cron job:
// api/src/functions/cleanupExpiredOtp.ts
import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'
export const handler = async () => {
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000)
const result = await db.user.updateMany({
where: {
otpRequestId: { not: null },
updatedAt: { lt: tenMinutesAgo },
},
data: {
otpRequestId: null,
},
})
logger.info({ count: result.count }, 'Cleaned up expired OTP request IDs')
return { statusCode: 200, body: JSON.stringify({ cleaned: result.count }) }
}7. Adding Security Features
Beyond basic authentication, implement these security aspects:
Input Validation:
- GraphQL/Services: Redwood/Prisma provides basic type validation. Add specific validation for phone numbers (regex for E.164) and OTP codes (numeric, expected length) within service functions. Integrate
zodfor complex validation:
import { z } from 'zod'
const phoneNumberSchema = z.string().regex(/^\+[1-9]\d{10,14}$/, 'Invalid E.164 phone number format')
const otpCodeSchema = z.string().regex(/^\d{4,6}$/, 'OTP code must be 4-6 digits')
// In service function:
phoneNumberSchema.parse(input.phoneNumber) // Throws if invalid
otpCodeSchema.parse(code)- Frontend: Use HTML5 form validation (
required,type="tel",pattern,maxlength)
Rate Limiting:
Critical for OTP to prevent abuse and toll fraud.
- Login Attempts: Limit attempts per user/IP
- OTP Requests (
verify.start): Limit requests per phone number/user within time window - OTP Verifications (
verify.check): Limit check attempts perrequestId. Vonage has built-in limits (3 attempts), but implement your own layer for more control
Redis-based Rate Limiting Implementation:
yarn workspace api add redis ioredis// api/src/lib/rateLimit.ts
import Redis from 'ioredis'
import { logger } from 'src/lib/logger'
const redis = new Redis(process.env.REDIS_URL)
export async function checkRateLimit(
key: string,
limit: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number }> {
const current = await redis.incr(key)
if (current === 1) {
await redis.expire(key, windowSeconds)
}
const allowed = current <= limit
const remaining = Math.max(0, limit - current)
if (!allowed) {
logger.warn({ key, current, limit }, 'Rate limit exceeded')
}
return { allowed, remaining }
}
// Usage in login service:
const rateLimitKey = `otp:${user.phoneNumber}`
const { allowed } = await checkRateLimit(rateLimitKey, 3, 3600) // 3 requests per hour
if (!allowed) {
throw new AuthenticationError('Too many OTP requests. Try again in 1 hour.')
}Brute Force Protection:
Rate limiting is primary defense. Add CAPTCHAs on login/signup if needed.
Secure Session Management:
Redwood's dbAuth handles session creation securely (secure, httpOnly cookies by default). Ensure your SESSION_SECRET in .env is strong and kept private:
# Generate strong session secret:
openssl rand -base64 32Dependency Security:
Regularly update dependencies and check for vulnerabilities:
yarn upgrade-interactive
yarn audit
yarn audit fixHTTPS:
Always deploy over HTTPS (handled by deployment platforms like Vercel, Netlify, Render).
GDPR/Privacy Compliance for Phone Numbers:
| Requirement | Implementation |
|---|---|
| Consent | Obtain explicit consent before collecting phone numbers. Add checkbox to signup form. |
| Purpose Limitation | Use phone numbers only for 2FA. State this in privacy policy. |
| Data Minimization | Collect phone numbers only if user enables 2FA. |
| Right to Erasure | Provide UI in profile settings to remove phone number and disable 2FA. |
| Data Portability | Include phone number in user data export functionality. |
| Breach Notification | Log all phone number access. Set up alerts for unauthorized access patterns. |
Frequently Asked Questions
What Node.js version does RedwoodJS require?
Minimum requirement: Node.js 20.x (as of October 2025) Compatibility note: Node.js 21+ is compatible but may limit deployment options for AWS Lambda and similar serverless platforms Recommendation: Use the LTS version from nodejs.org
RedwoodJS also requires Yarn 1.22.21 or higher for package management. See the official RedwoodJS prerequisites documentation.
How long are Vonage OTP codes valid?
Default expiry: 5 minutes (300 seconds) Customizable range: 60–3600 seconds (1 minute to 1 hour)
Configure custom expiry using the pin_expiry parameter in vonage.verify.start():
const result = await vonage.verify.start({
number: user.phoneNumber,
brand: vonageBrand,
pin_expiry: 180, // 3 minutes
});Vonage generates Time-Based One-Time PINs per RFC 6238. After expiry, users must request a new code. Source: Vonage Verify PIN validity documentation.
What phone number format does Vonage Verify require?
Required format: E.164 international standard
Structure: +[Country Code][Subscriber Number]
Maximum length: 15 digits total
Examples:
- US:
+14155551234 - UK:
+442071234567 - France:
+33612345678
Numbers must include the + prefix with no spaces, parentheses, or dashes. Use validation regex /^\+[1-9]\d{10,14}$/. For user input, use libphonenumber-js to parse and normalize phone numbers to E.164 format before storage.
Source: E.164 ITU-T Standard
How do I prevent OTP abuse and toll fraud?
Implement multiple layers of rate limiting:
1. Application-level limits:
- Limit OTP requests per phone number (e.g., 3 requests per hour)
- Limit OTP requests per user account (e.g., 5 requests per day)
- Limit OTP requests per IP address
2. Vonage built-in protection:
- Maximum 3 verification attempts per
request_id - Automatic expiry after 5 minutes (default)
3. Additional security measures:
- Implement CAPTCHA for high-risk scenarios
- Monitor for unusual patterns (many requests from single IP)
- Block or flag suspicious phone numbers
- Set up cost alerts in Vonage Dashboard
Rate limiting is critical to prevent abuse. A compromised endpoint without rate limiting resulted in $50,000+ toll fraud charges for one company in a single weekend (Source: Vonage Case Studies).
Can I use Vonage Verify v2 API instead of v1?
Yes, Vonage Verify v2 API offers improvements over v1:
Verify v2 advantages:
- More flexible workflows
- Support for additional channels (WhatsApp, Email, Voice)
- Better error handling
- Improved internationalization
To use Verify v2:
- Install dedicated package:
yarn workspace api add @vonage/verify2 - Update integration code to use v2 endpoints
- Refer to Vonage Verify v2 API documentation
Note: This guide uses Verify v1 (legacy) which is fully supported. Consider migrating to v2 for new projects or when requiring advanced features.
Migration from v1 to v2:
Key differences:
| Aspect | v1 | v2 |
|---|---|---|
| SDK method | vonage.verify.start() | vonage.verify2.newRequest() |
| Request format | Simple object | Workflow-based JSON |
| Response handling | Status codes | Promise-based with typed responses |
| Channel support | SMS, Voice | SMS, Voice, Email, WhatsApp |
v2 requires updating request/response handling in login and verifyOtp services.
How do I handle users without phone numbers or SMS access?
Implement fallback authentication methods:
Option 1: Make 2FA optional
- Set
otpRequiredflag per user - Allow users to enable/disable 2FA in profile settings
- Make 2FA mandatory only for privileged accounts
Option 2: Alternative 2FA methods
- Authenticator Apps: TOTP (Time-based One-Time Password) using
otpauth - Email OTP: Send codes via email (less secure than SMS)
- Backup Codes: Generate one-time backup codes during 2FA setup
Option 3: Conditional 2FA
- Require 2FA only for sensitive operations (password changes, financial transactions)
- Skip 2FA for trusted devices (implement device fingerprinting)
TOTP Implementation Example:
yarn workspace api add otpauth qrcode// api/src/services/auth/auth.ts
import { TOTP } from 'otpauth'
export const setupTOTP = async (userId: number) => {
const user = await db.user.findUnique({ where: { id: userId } })
const totp = new TOTP({
issuer: 'YourAppName',
label: user.email,
algorithm: 'SHA1',
digits: 6,
period: 30,
})
// Store secret in database
await db.user.update({
where: { id: userId },
data: { totpSecret: totp.secret.base32 },
})
// Return QR code URL for user to scan with authenticator app
return totp.toString() // otpauth://totp/...
}
export const verifyTOTP = async (userId: number, token: string) => {
const user = await db.user.findUnique({ where: { id: userId } })
const totp = TOTP.fromSecret(user.totpSecret)
const isValid = totp.validate({ token, window: 1 }) !== null
if (!isValid) {
throw new AuthenticationError('Invalid authenticator code')
}
return { success: true }
}What are common Vonage Verify API error codes?
Status Code Reference:
| Status | Meaning | Action |
|---|---|---|
0 | Success | Code sent/verified successfully |
3 | Invalid number format | Validate E.164 format before sending |
6 | Request ID not found | Code expired or invalid request_id |
16 | Wrong code provided | Allow retry (max 3 attempts) |
17 | Code expired or max attempts | Request new code via new verify.start() |
101 | Missing/invalid credentials | Check VONAGE_API_KEY and VONAGE_API_SECRET |
Error handling in code:
if (result.status === '3') {
throw new AuthenticationError('Invalid phone number format. Use E.164 format.');
} else if (result.status === '16') {
throw new AuthenticationError('Incorrect code. Try again.');
} else if (result.status === '17') {
throw new AuthenticationError('Code expired. Request a new code.');
}Full error code reference: Vonage Verify API v1 Response Codes
How do I test OTP functionality in development?
Testing strategies:
1. Use your personal phone number:
- Add your number during user signup
- Enable OTP for your test account
- Receive real SMS codes during development
2. Vonage Dashboard logs:
- View all API requests in Vonage Dashboard
- Check delivery status and error messages
- Monitor costs during development
3. Mock Vonage calls (unit tests):
// api/src/services/auth/auth.test.ts
jest.mock('src/lib/vonage', () => ({
vonage: {
verify: {
start: jest.fn().mockResolvedValue({
status: '0',
request_id: 'test-request-id-123',
}),
check: jest.fn().mockResolvedValue({
status: '0',
}),
},
},
vonageBrand: 'TestApp',
}))
describe('verifyOtp', () => {
it('successfully verifies correct OTP code', async () => {
const result = await verifyOtp({
userId: 1,
code: '1234',
})
expect(result.__typename).toBe('CurrentUser')
expect(result.id).toBe(1)
})
})4. Development bypass mode:
// api/src/lib/vonage.ts
export const isDevelopmentBypass = process.env.NODE_ENV === 'development' &&
process.env.VONAGE_BYPASS === 'true'
// In login service:
if (isDevelopmentBypass) {
// Skip actual Vonage call, return mock success
return {
__typename: 'OtpRequired',
otpRequired: true,
userId: user.id,
message: 'DEV MODE: Use code 1234',
}
}Integration test example using RedwoodJS scenario:
// api/src/services/auth/auth.scenarios.ts
export const standard = defineScenario({
user: {
one: {
data: {
email: 'test@example.com',
hashedPassword: 'hashed',
salt: 'salt',
phoneNumber: '+14155551234',
otpRequired: true,
},
},
},
})
// api/src/services/auth/auth.test.ts
import { login, verifyOtp } from './auth'
import { standard } from './auth.scenarios'
scenario('login with OTP required', async (scenario) => {
const result = await login({
username: scenario.user.one.email,
password: 'password123',
})
expect(result.__typename).toBe('OtpRequired')
expect(result.userId).toBe(scenario.user.one.id)
})Important: Never commit real phone numbers or API credentials to version control. Use .env files and ensure they're in .gitignore.
Frequently Asked Questions
How to implement two-factor authentication in RedwoodJS?
Implement 2FA using the Vonage Verify API to send SMS OTPs after successful password login. This enhances security by adding "something you have" (your phone) to the authentication process, protecting against unauthorized access.
What is RedwoodJS used for in this project?
RedwoodJS is the full-stack JavaScript framework used to build the web application. It provides structure, conventions, and tools like GraphQL and Prisma, which simplify development.
Why does this project use the Vonage Verify API?
The Vonage Verify API handles sending SMS OTPs and verifying user-entered codes, simplifying the implementation of two-factor authentication.
When should I add OTP verification to my RedwoodJS app?
Add OTP verification when enhanced security is crucial, such as protecting sensitive user data or financial transactions. This guide provides a robust implementation using the Vonage Verify API after password login.
Can I customize the length of the OTP code?
Yes, the Vonage Verify API allows customization of options like `code_length` (default is 4) and `pin_expiry` (default is 300 seconds) when initiating the verification request.
How to add a phone number field to RedwoodJS user model?
Modify the `User` model in `api/db/schema.prisma` to include a `phoneNumber` field, preferably using the E.164 format for international compatibility, along with fields for `otpRequestId`, `otpVerifiedAt`, and `otpRequired`.
What is the Vonage brand name used for?
The Vonage brand name, set in the `.env` file as `VONAGE_BRAND_NAME`, appears in the SMS message sent to the user for OTP verification. Keep it short and recognizable.
How to store Vonage API credentials securely?
Store your `VONAGE_API_KEY` and `VONAGE_API_SECRET` in a `.env` file at your project's root. RedwoodJS loads these into `process.env`. **Never commit this file to version control.**
How does the RedwoodJS app interact with the Vonage API?
The RedwoodJS API side uses the `@vonage/server-sdk` to communicate with the Vonage Verify API for sending and verifying OTPs. The web side interacts with the API side via GraphQL mutations.
What are the prerequisites for this tutorial?
You need Node.js, Yarn, the RedwoodJS CLI, a Vonage API account, and a basic understanding of RedwoodJS concepts (Cells, Services, GraphQL, `dbAuth`).
How to handle Vonage API errors in RedwoodJS?
Use `try...catch` blocks to handle potential Vonage API errors. Catch known errors and re-throw them as `AuthenticationError` with clear messages. Log unexpected errors with Redwood's logger for debugging.
Where do I set the Vonage brand name for SMS messages?
The Vonage brand name for Verify API v1 used in this guide is set directly within the `vonage.verify.start` method call using the `VONAGE_BRAND_NAME` environment variable and not in the Vonage dashboard.
What security considerations are important for OTP implementation?
Implement input validation, rate limiting for login attempts and OTP requests/verifications, and consider CAPTCHAs. RedwoodJS handles secure session management, but ensure your `SESSION_SECRET` is strong.
What is Prisma used for in this RedwoodJS project?
Prisma is used for database schema management, migrations, and type-safe database access. The RedwoodJS setup integrates Prisma seamlessly for interacting with the database.