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.
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.
RedwoodJS application using dbAuth for 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.
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_id to 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 verifyOtp GraphQL mutation with the code and user identifier.
Code Check: API uses the stored request_id and 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)
Creates Login, Signup, Forgot Password, and Reset Password pages and routes
Adds User model to Prisma schema (api/db/schema.prisma) with hashedPassword, salt, resetToken, etc.
Creates auth services and GraphQL definitions (api/src/services/auth.ts, api/src/graphql/auth.sdl.ts)
Sets up Auth context provider in web/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.js configuration. 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:
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:
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:
Bash
yarn workspace api add @vonage/server-sdk
6. 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.
Bash
# ./.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.
Modify the SDL file to define new types and the verifyOtp mutation.
GraphQL
# 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 logintype OtpRequired {otpRequired: Boolean!userId: Int!message: String!}# Union type for the login mutation resultunion LoginResult = CurrentUser | OtpRequired
typeMutation{# 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!@skipAuthlogout: Boolean @requireAuth
signup(input: SignupInput!): CurrentUser!@skipAuth}# Define input type for signupinput SignupInput {email: String!password: String!phoneNumber: String
enableOtp: Boolean
}
Key Changes:
CurrentUser Type: Authenticated user data (returned by direct login and successful OTP verification)
OtpRequired Type: State where OTP is needed
LoginResult Union: The login mutation returns this union, allowing the frontend to distinguish between immediate success and OTP step
login Mutation: Changed return type to LoginResult!. Added @skipAuth as login happens before authentication is established
verifyOtp Mutation:
Takes userId: Int! and code: String! as arguments
Returns CurrentUser! upon successful verification
Uses @skipAuth because this mutation is called before the final session is granted
SignupInput: Input type matching the modified signup service function
2. Generate Types:
Run the Redwood types generator to update TypeScript types based on SDL changes:
Bash
yarn rw generate types
This 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.ts securely initializes the Vonage client using API keys from .env
Environment Variables:.env stores VONAGE_API_KEY, VONAGE_API_SECRET, and VONAGE_BRAND_NAME. Configure these in your Vonage Dashboard and .env file:
VONAGE_API_KEY / VONAGE_API_SECRET: Found on the main page of your Vonage API Dashboard
VONAGE_BRAND_NAME: Configured per request in vonage.verify.start. No default dashboard setting for Verify API v1.
Sending OTP: The login service calls vonage.verify.start after password validation if OTP is required
Verifying OTP: The verifyOtp service calls vonage.verify.check using stored requestId and user-provided code
Secure Handling: Credentials loaded from .env, not hardcoded. Ensure .env is in .gitignore
Production Considerations:
Retries: Implement retry logic (e.g., using async-retry) around vonage.verify.start and vonage.verify.check for 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
5. Implementing Error Handling and Logging
Basic error handling and logging are incorporated. Let's refine it.
Error Handling Strategy:
Service Layer: Use try...catch blocks around external API calls (Vonage)
Specific Errors: Catch known Vonage errors (invalid number, wrong code, expired) and re-throw as AuthenticationError or UserInputError (from @redwoodjs/graphql-server) with user-friendly messages
Generic Errors: Catch unexpected errors, log with details, return generic AuthenticationError or InternalServerError
GraphQL Layer: Redwood automatically maps thrown errors to GraphQL errors. Frontend receives these in the error object from mutations/queries
Production Error Monitoring:
Configure error tracking service (Sentry, Rollbar, or Bugsnag):
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 zod for complex validation:
TypeScript
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 per requestId. Vonage has built-in limits (3 attempts), but implement your own layer for more control
Redis-based Rate Limiting Implementation:
Bash
yarn workspace api add redis ioredis
TypeScript
// api/src/lib/rateLimit.tsimportRedisfrom'ioredis'import { logger } from'src/lib/logger'const redis = newRedis(process.env.REDIS_URL)
exportasyncfunctioncheckRateLimit(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 } = awaitcheckRateLimit(rateLimitKey, 3, 3600) // 3 requests per hourif (!allowed) {
thrownewAuthenticationError('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:
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
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.
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
Important: Never commit real phone numbers or API credentials to version control. Use .env files and ensure they're in .gitignore.
Frequently asked questions
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.
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.
The Vonage Verify API handles sending SMS OTPs and verifying user-entered codes, simplifying the implementation of two-factor authentication.
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.
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.
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.
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.
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.
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.
You need Node.js, Yarn, the RedwoodJS CLI, a Vonage API account, and a basic understanding of RedwoodJS concepts (Cells, Services, GraphQL, dbAuth).
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.
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.
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.
Prisma is used for database schema management, migrations, and type-safe database access. The RedwoodJS setup integrates Prisma seamlessly for interacting with the database.
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
What You'll Build:
dbAuthfor user/password authenticationProblem 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:
Technologies Used:
@vonage/server-sdk: Official Vonage Node.js SDK. Current version: 3.24.1 (October 2025). Vonage Node SDKVersion Compatibility (October 2025):
System Architecture:
Authentication Flow:
dbAuth.request_idto the API, which stores it temporarily in the database. API responds to web side, indicating OTP is required.verifyOtpGraphQL mutation with the code and user identifier.request_idand submitted code to ask Vonage to verify validity.request_id) and generates the RedwoodJS session. Returns authenticated user data.useAuth), and redirects to the protected area.Failure Scenarios and Recovery:
Prerequisites:
yarn global add @redwoodjs/cli)dbAuth(RedwoodJS Authentication Docs, dbAuth Guide)Critical Vonage Verify API Specifications (October 2025):
⚠️ PIN Code Defaults:
pin_expiryparameter (60–3600 seconds)⚠️ Rate Limiting Requirements:
⚠️ Phone Number Format:
+[Country Code][Subscriber Number](max 15 digits total)+14155551234, UK+442012345678, France+33612345678Expected 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-otpdirectory.2. Setup Database Authentication (
dbAuth):Use RedwoodJS generators to set up
dbAuthfor email/password login.This command:
@redwoodjs/auth-dbauth-api,@redwoodjs/auth-dbauth-web)Usermodel to Prisma schema (api/db/schema.prisma) withhashedPassword,salt,resetToken, etc.api/src/services/auth.ts,api/src/graphql/auth.sdl.ts)Authcontext provider inweb/src/App.tsxapi/src/functions/auth.jsconfiguration. Official dbAuth Guide3. Add User Phone Number to Schema:
Store the user's phone number for OTP sending. Modify the
Usermodel inapi/db/schema.prisma:Field Descriptions:
phoneNumber+14155552671US,+442071234567UK). Add@uniqueconstraint if your app requires unique phone numbers per user.otpRequestIdrequest_idfrom OTP initiationotpVerifiedAtotpRequiredE.164 Validation Requirements:
+[1-3 digit country code][up to 12 digit subscriber number]/^\+[1-9]\d{10,14}$/(starts with +, no leading zero in country code, 11–15 total digits)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
apiworkspace:6. Configure Environment Variables:
Create a
.envfile 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
.envis in.gitignore:# Check .gitignore contains: .env .env.* !.env.exampleRedwoodJS automatically loads variables from
.envintoprocess.envon 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
dbAuthlogin process. After verifying the password, trigger Vonage OTP request if OTP is required for the user.Update the
loginfunction inapi/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):LoginResultunion type from GraphQL__typenameto distinguishCurrentUservsOtpRequiredOtpRequiredreturneduserIdin session storage for OTP verification2. OTP Verification Page (
web/src/pages/OtpPage/OtpPage.tsx):userIdfrom session storage or route paramsverifyOtpmutation withuserIdandcode3. 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
verifyOtpservice function via GraphQL and adjust theloginmutation's return type.1. Update GraphQL Schema Definition (
auth.sdl.ts):Modify the SDL file to define new types and the
verifyOtpmutation.# 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:userId: Int!andcode: String!as argumentsCurrentUser!upon successful verification@skipAuthbecause this mutation is called before the final session is grantedSignupInput: Input type matching the modifiedsignupservice function2. Generate Types:
Run the Redwood types generator to update TypeScript types based on SDL changes:
This 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 theauth.tsservice.Vonage Integration Summary:
api/src/lib/vonage.tssecurely initializes the Vonage client using API keys from.env.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.loginservice callsvonage.verify.startafter password validation if OTP is requiredverifyOtpservice callsvonage.verify.checkusing storedrequestIdand user-provided code.env, not hardcoded. Ensure.envis in.gitignoreasync-retry) aroundvonage.verify.startandvonage.verify.checkfor transient network issues5. Implementing Error Handling and Logging
Basic error handling and logging are incorporated. Let's refine it.
Error Handling Strategy:
try...catchblocks around external API calls (Vonage)AuthenticationErrororUserInputError(from@redwoodjs/graphql-server) with user-friendly messagesAuthenticationErrororInternalServerErrorerrorobject from mutations/queriesProduction 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:
api/src/lib/logger.ts(pre-configured)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 contextRetry Mechanisms:
Add retries for Vonage calls using
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.checkin theverifyOtpservice. 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).
api/db/schema.prisma. IncludesUsermodel withphoneNumber,otpRequestId,otpVerifiedAt,otpRequiredyarn rw prisma migrate dev. Create migrations for every schema changeapi/src/lib/db.ts) within API services. Redwood provides setup. Prisma offers type-safe database accessphoneNumberandemailfields (@uniquealready creates an index, add one forphoneNumberif frequently queried:@@index([phoneNumber]))prisma studioor database-specific analysisData Retention Policy for OTP Records:
otpRequestIdotpVerifiedAtphoneNumberImplement 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:
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)required,type="tel",pattern,maxlength)Rate Limiting:
Critical for OTP to prevent abuse and toll fraud.
verify.start): Limit requests per phone number/user within time windowverify.check): Limit check attempts perrequestId. Vonage has built-in limits (3 attempts), but implement your own layer for more controlRedis-based Rate Limiting Implementation:
// 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
dbAuthhandles session creation securely (secure, httpOnly cookies by default). Ensure yourSESSION_SECRETin.envis strong and kept private:# Generate strong session secret: openssl rand -base64 32Dependency Security:
Regularly update dependencies and check for vulnerabilities:
HTTPS:
Always deploy over HTTPS (handled by deployment platforms like Vercel, Netlify, Render).
GDPR/Privacy Compliance for Phone Numbers:
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_expiryparameter invonage.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:+14155551234+442071234567+33612345678Numbers must include the
+prefix with no spaces, parentheses, or dashes. Use validation regex/^\+[1-9]\d{10,14}$/. For user input, uselibphonenumber-jsto 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:
2. Vonage built-in protection:
request_id3. Additional security measures:
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:
To use Verify v2:
yarn workspace api add @vonage/verify2Note: 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:
vonage.verify.start()vonage.verify2.newRequest()v2 requires updating request/response handling in
loginandverifyOtpservices.How do I handle users without phone numbers or SMS access?
Implement fallback authentication methods:
Option 1: Make 2FA optional
otpRequiredflag per userOption 2: Alternative 2FA methods
otpauthOption 3: Conditional 2FA
TOTP Implementation Example:
// 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:
0361617101Error 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:
2. Vonage Dashboard logs:
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
.envfiles and ensure they're in.gitignore.