code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

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

  1. Project Overview and Goals
  2. Setting up the Project
  3. Implementing Core Functionality (API Side)
  4. Building the API Layer (GraphQL)
  5. Integrating with Third-Party Services (Vonage)
  6. Implementing Error Handling and Logging
  7. Database Schema and Data Layer
  8. Adding Security Features
  9. Frequently Asked Questions

Project Overview and Goals

What You'll Build:

  • 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.

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:

  1. User Interaction: User accesses the login page.
  2. Login Attempt: User submits email/password via form.
  3. Initial Auth: Web side sends GraphQL mutation to API. API verifies password against database using Prisma and dbAuth.
  4. OTP Trigger: If password is correct, API initiates OTP request using Vonage SDK, targeting the user's registered phone number. Vonage sends the SMS.
  5. 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.
  6. OTP Entry: Web side redirects user to OTP entry page.
  7. OTP Verification: User submits the received OTP code. Web side sends verifyOtp GraphQL mutation with the code and user identifier.
  8. Code Check: API uses the stored request_id and submitted code to ask Vonage to verify validity.
  9. 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.
  10. Access Granted: Web side receives confirmation, establishes user session locally (using useAuth), and redirects to the protected area.

Failure Scenarios and Recovery:

Failure PointRecovery Flow
Password incorrectDisplay error, allow retry (rate-limited)
SMS delivery failureVonage auto-retries, user can request new code after 60s
Wrong OTP codeAllow 3 attempts, then require new login
OTP expired (5 min)Display timeout message, restart login flow
Network timeoutRetry with exponential backoff, fallback error message
Vonage API outageLog error, display maintenance message, email support alert

Prerequisites:

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_expiry parameter (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:

bash
yarn create redwood-app ./redwood-vonage-otp
cd redwood-vonage-otp

This 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.

bash
yarn rw setup auth dbAuth

This command:

  • Adds auth packages (@redwoodjs/auth-dbauth-api, @redwoodjs/auth-dbauth-web)
  • 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:

FieldPurposeFormat
phoneNumberUser's phone numberE.164 format (e.g., +14155552671 US, +442071234567 UK). Add @unique constraint if your app requires unique phone numbers per user.
otpRequestIdVonage request_id from OTP initiationCleared after successful verification or expiry
otpVerifiedAtTimestamp of last successful OTP verificationUse for "Remember this device" functionality (implement by checking if verified within X days)
otpRequiredControls OTP enforcement for this userSet during signup or in user profile management

E.164 Validation Requirements:

AspectSpecification
Format+[1-3 digit country code][up to 12 digit subscriber number]
Maximum Length15 digits total (including country code)
Validation Regex/^\+[1-9]\d{10,14}$/ (starts with +, no leading zero in country code, 11–15 total digits)
StorageAlways store with + prefix and no formatting characters (spaces, dashes, parentheses)
User InputAccept various formats but normalize to E.164 before storage

Phone Number Normalization Example using libphonenumber-js:

typescript
// 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:

bash
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.

Security Configuration:

Ensure .env is in .gitignore:

bash
# Check .gitignore contains:
.env
.env.*
!.env.example

RedwoodJS 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:

typescript
// 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:

typescript
// 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 LoginResult union type from GraphQL
  • Check __typename to distinguish CurrentUser vs OtpRequired
  • Redirect to OTP page if OtpRequired returned
  • Store userId in session storage for OTP verification

2. OTP Verification Page (web/src/pages/OtpPage/OtpPage.tsx):

  • Create new page with OTP code input form
  • Retrieve userId from session storage or route params
  • Call verifyOtp mutation with userId and code
  • 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:

typescript
// 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.

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 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:

  • 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):

typescript
// 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 like userId and requestId
    • logger.warn: Potential issues or non-critical failures (e.g., "Attempt to verify OTP without pending request")
    • logger.error: Actual errors in catch blocks. 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:

bash
yarn workspace api add async-retry @types/async-retry
typescript
// 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. Includes User model with phoneNumber, 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 phoneNumber and email fields (@unique already creates an index, add one for phoneNumber if frequently queried: @@index([phoneNumber]))
    • Database connection pooling (Prisma handles this)
    • Analyze query performance using prisma studio or database-specific analysis

Data Retention Policy for OTP Records:

Data FieldRetention PeriodCleanup Method
otpRequestId10 minutes after creationCleared on successful verification or via cron job
otpVerifiedAt90 days (or per compliance requirements)Archive or delete via scheduled task
phoneNumberUntil user deletion or opt-outAllow user to remove in profile settings

Implement cleanup cron job:

typescript
// 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 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.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:

bash
# Generate strong session secret:
openssl rand -base64 32

Dependency Security:

Regularly update dependencies and check for vulnerabilities:

bash
yarn upgrade-interactive
yarn audit
yarn audit fix

HTTPS:

Always deploy over HTTPS (handled by deployment platforms like Vercel, Netlify, Render).

GDPR/Privacy Compliance for Phone Numbers:

RequirementImplementation
ConsentObtain explicit consent before collecting phone numbers. Add checkbox to signup form.
Purpose LimitationUse phone numbers only for 2FA. State this in privacy policy.
Data MinimizationCollect phone numbers only if user enables 2FA.
Right to ErasureProvide UI in profile settings to remove phone number and disable 2FA.
Data PortabilityInclude phone number in user data export functionality.
Breach NotificationLog 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():

typescript
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:

  1. Install dedicated package: yarn workspace api add @vonage/verify2
  2. Update integration code to use v2 endpoints
  3. 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:

Aspectv1v2
SDK methodvonage.verify.start()vonage.verify2.newRequest()
Request formatSimple objectWorkflow-based JSON
Response handlingStatus codesPromise-based with typed responses
Channel supportSMS, VoiceSMS, 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 otpRequired flag 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:

bash
yarn workspace api add otpauth qrcode
typescript
// 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:

StatusMeaningAction
0SuccessCode sent/verified successfully
3Invalid number formatValidate E.164 format before sending
6Request ID not foundCode expired or invalid request_id
16Wrong code providedAllow retry (max 3 attempts)
17Code expired or max attemptsRequest new code via new verify.start()
101Missing/invalid credentialsCheck VONAGE_API_KEY and VONAGE_API_SECRET

Error handling in code:

typescript
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):

typescript
// 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:

typescript
// 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:

typescript
// 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.