code examples
code examples
Implementing Two-Factor Authentication (2FA) with Twilio Verify in RedwoodJS
Learn how to add SMS-based OTP two-factor authentication to your RedwoodJS application using Twilio Verify for enhanced security.
Implementing Two-Factor Authentication (2FA) with Twilio Verify in RedwoodJS
Time to complete: 2–3 hours | Skill level: Intermediate
This guide shows you how to add SMS-based One-Time Password (OTP) two-factor authentication (2FA) to your RedwoodJS application using Twilio Verify. You'll augment Redwood's built-in dbAuth to require an OTP code after successful password validation for users who enable 2FA.
Two-factor authentication blocks 99.9% of automated attacks by requiring both a password (something you know) and access to a registered phone (something you have).
Project Overview and Goals
What You'll Build:
- A RedwoodJS application using
dbAuthfor standard email/password authentication - Functionality for users to enable/disable SMS-based 2FA in their profile settings
- An updated login flow that prompts for an OTP code via SMS after successful password verification, but only if the user has 2FA enabled
- Backend API endpoints (GraphQL mutations) handled by Redwood Services to interact with the Twilio Verify API
Problem Solved: Standard password authentication exposes users to phishing, brute-force attacks, and credential stuffing. 2FA blocks these attacks by adding a second verification layer that attackers cannot bypass without physical access to the user's phone.
Technologies Used:
- RedwoodJS: Full-stack JavaScript/TypeScript framework (React frontend, GraphQL API, Prisma ORM) – provides integrated structure and excellent developer experience
- Node.js: Runtime environment for RedwoodJS's API side
- Prisma: Database toolkit for schema management and database access
- Twilio Verify: Managed service API for sending and checking OTP codes across various channels (we use SMS) – chosen for its reliability, scalability, and ease of integration
- GraphQL: API query language used by RedwoodJS
- React: Frontend library used by RedwoodJS's web side
Technology Versions:
- RedwoodJS: 6.0.0 or later
- Node.js: 18.x or later (20.x recommended)
- Twilio SDK: 4.19.0 or later
- Prisma: 5.x (included with RedwoodJS)
Final Outcome & Prerequisites:
- A RedwoodJS app where users can optionally enable robust SMS 2FA
- Prerequisites:
- Node.js 18.x or later and Yarn installed
- A Twilio account (free tier works for testing)
- A phone number capable of receiving SMS for testing
- Working knowledge of RedwoodJS, React, GraphQL, and Prisma
- A running PostgreSQL database instance
1. Setting up the Project
Start with a fresh RedwoodJS project and configure dbAuth.
1.1 Create RedwoodJS App:
Open your terminal and run:
yarn create redwood-app ./redwood-twilio-2fa
cd redwood-twilio-2faThis command scaffolds a new RedwoodJS application with the complete folder structure, dependencies, and configuration files. Installation takes 2–5 minutes.
Troubleshooting: If yarn fails, ensure you have Node.js 18.x or later installed (node --version). Clear the yarn cache with yarn cache clean and retry.
1.2 Configure Database:
Ensure your PostgreSQL database is running. Update the provider and url in api/db/schema.prisma if you use a different database.
Don't have PostgreSQL? Install it via Homebrew (brew install postgresql), Docker (docker run -e POSTGRES_PASSWORD=password -p 5432:5432 postgres), or download from postgresql.org. For local testing only, switch to SQLite by changing provider to "sqlite" and url to "file:./dev.db".
// api/db/schema.prisma
datasource db {
provider = "postgresql" // Or your DB provider
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
binaryTargets = "native"
}Update your .env file with your database connection string:
# .env
DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/redwood_2faConnection string format: postgresql://[username]:[password]@[host]:[port]/[database_name]
username: Your PostgreSQL username (default:postgres)password: Your PostgreSQL passwordhost: Database server address (uselocalhostfor local development)port: PostgreSQL port (default:5432)database_name: Name of your database
1.3 Setup dbAuth:
Run the RedwoodJS dbAuth setup generator to scaffold login/signup pages and API functions:
yarn rw setup auth dbAuthFiles created:
web/src/pages/LoginPage/LoginPage.tsx– Login formweb/src/pages/SignupPage/SignupPage.tsx– Signup formapi/src/functions/auth.ts– Authentication handlerapi/src/lib/auth.ts– Auth configuration and utilities- Updates to
api/db/schema.prisma– AddsUsermodel with authentication fields
1.4 Apply Initial Migration:
Create and apply the initial database migration:
yarn rw prisma migrate dev --name "initial setup with dbAuth"Success output: You should see "Migration applied successfully" and a new migration file in api/db/migrations/.
Troubleshooting: If the migration fails, verify your DATABASE_URL is correct and the database server is running. Check that no other application is using the database.
1.5 Install Twilio SDK:
Install the Twilio Node.js library in the API workspace:
yarn workspace api add twilioYou install twilio in the api workspace (not the root) because only the backend interacts with the Twilio API. Installing it workspace-specific keeps your dependencies organized and reduces your frontend bundle size.
1.6 Configure Environment Variables for Twilio:
Add your Twilio credentials to .env (you'll get these in Section 4):
# .env
# Database URL (already added)
DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/redwood_2fa
# RedwoodJS Session Secret (added by dbAuth setup)
SESSION_SECRET=your_strong_random_secret_here
# Twilio Credentials
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token_here
TWILIO_VERIFY_SERVICE_SID=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxGenerate a strong SESSION_SECRET: Run node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" in your terminal and paste the output.
Security: Never commit .env to version control. Verify .env is listed in your .gitignore file (RedwoodJS adds this by default).
Project Structure Explanation:
api/: Backend code (database schema, services, GraphQL definitions)web/: Frontend code (React components, pages, layouts)scripts/: Seeding or other helper scripts.env: Environment variables (never commit this file)redwood.toml: Project configuration
2. Implementing Core Functionality (API Service)
Create a Redwood Service to encapsulate all Twilio Verify logic. Services in RedwoodJS separate business logic from GraphQL resolvers, making code more testable, reusable, and maintainable.
2.1 Create Twilio Verify Service:
Generate a service for Twilio operations:
yarn rw g service twilioVerifyFiles created:
api/src/services/twilioVerify/twilioVerify.ts– Service logicapi/src/services/twilioVerify/twilioVerify.scenarios.ts– Test data fixturesapi/src/services/twilioVerify/twilioVerify.test.ts– Unit tests (write tests here to verify your Twilio integration without making actual API calls)
2.2 Implement Service Logic:
Open api/src/services/twilioVerify/twilioVerify.ts and add the following code:
// api/src/services/twilioVerify/twilioVerify.ts
import type { Prisma } from '@prisma/client'
import { Twilio } from 'twilio'
import { validate } from '@redwoodjs/api'
import { db } from 'src/lib/db' // Prisma client
import { logger } from 'src/lib/logger' // Redwood logger
// Initialize Twilio client (only if SIDs are present)
let twilioClient: Twilio | null = null
if (
process.env.TWILIO_ACCOUNT_SID &&
process.env.TWILIO_AUTH_TOKEN &&
process.env.TWILIO_VERIFY_SERVICE_SID
) {
twilioClient = new Twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
)
} else {
logger.warn('Twilio environment variables not fully configured. 2FA features will be disabled.')
}
const TWILIO_VERIFY_SERVICE_SID = process.env.TWILIO_VERIFY_SERVICE_SID
/**
* Sends an OTP verification code via SMS using Twilio Verify.
* @param phoneNumber - The phone number in E.164 format (e.g., +15551234567)
* @returns Promise resolving to the verification status ('pending' on success)
* @throws Error if Twilio client is not configured or API call fails
*/
export const sendVerificationCode = async (
phoneNumber: string
): Promise<string> => {
validate(phoneNumber, 'Phone Number', {
presence: true,
format: {
pattern: /^\+[1-9]\d{1,14}$/, // Basic E.164 format check
message: 'Phone number must be in E.164 format (e.g., +15551234567)',
},
})
if (!twilioClient || !TWILIO_VERIFY_SERVICE_SID) {
logger.error('Twilio client or Service SID is not configured.')
throw new Error('Twilio configuration error. Cannot send verification code.')
}
logger.info(`Sending verification code to ${phoneNumber}`)
try {
const verification = await twilioClient.verify.v2
.services(TWILIO_VERIFY_SERVICE_SID)
.verifications.create({ to: phoneNumber, channel: 'sms' })
logger.info(`Verification status for ${phoneNumber}: ${verification.status}`)
return verification.status // Should be 'pending'
} catch (error) {
logger.error(
{ error },
`Failed to send verification code to ${phoneNumber}`
)
// Consider re-throwing a more user-friendly error or handling specific Twilio errors
throw new Error(`Twilio API error: ${error.message}`)
}
}
/**
* Checks the OTP code provided by the user against Twilio Verify.
* @param phoneNumber - The phone number in E.164 format
* @param code - The OTP code entered by the user
* @returns Promise resolving to the verification check status ('approved' on success, 'incorrect' on failure, 'pending' if still processing)
* @throws Error if Twilio client is not configured or API call fails unexpectedly
*/
export const checkVerificationCode = async (
phoneNumber: string,
code: string
): Promise<string> => {
validate(phoneNumber, 'Phone Number', {
presence: true,
format: {
pattern: /^\+[1-9]\d{1,14}$/,
message: 'Phone number must be in E.164 format (e.g., +15551234567)',
},
})
validate(code, 'Verification Code', {
presence: true,
length: { min: 4, max: 10, message: 'Invalid code length' }, // Adjust based on your Twilio service settings
})
if (!twilioClient || !TWILIO_VERIFY_SERVICE_SID) {
logger.error('Twilio client or Service SID is not configured.')
throw new Error('Twilio configuration error. Cannot check verification code.')
}
logger.info(`Checking verification code for ${phoneNumber}`)
try {
const verificationCheck = await twilioClient.verify.v2
.services(TWILIO_VERIFY_SERVICE_SID)
.verificationChecks.create({ to: phoneNumber, code: code })
logger.info(
`Verification check status for ${phoneNumber}: ${verificationCheck.status}`
)
return verificationCheck.status // 'approved', 'pending', or 'canceled'
} catch (error) {
logger.error(
{ error },
`Failed to check verification code for ${phoneNumber}`
)
// Twilio often returns 404 for incorrect/expired codes, resulting in an error here.
// Check error status to provide clearer feedback than just 'pending'.
if (error.status === 404) {
logger.warn(`Verification check failed for ${phoneNumber}: Code likely incorrect or expired.`)
return 'incorrect' // Return a specific status for wrong/expired code
}
// Re-throw other unexpected errors
throw new Error(`Twilio API error: ${error.message}`)
}
}
// We'll add GraphQL resolvers here in the next step
// These functions are the core logic; GraphQL resolvers will call them.Why this approach?
- Encapsulation: Keeps all Twilio-related logic in one place
- Reusability: These functions can be called from different GraphQL resolvers or other services if needed
- Testability: Easier to write unit tests for the service functions in isolation (mocking the Twilio client)
- Configuration Check: Ensures the app handles missing Twilio credentials gracefully
- Logging: Uses Redwood's logger for visibility into the process
- Validation: Basic input validation using Redwood's
validate
3. Building the API Layer (GraphQL)
Expose the service functions via GraphQL mutations so the frontend can trigger them.
3.1 Define GraphQL Schema:
Create a new SDL file for your Twilio Verify operations: api/src/graphql/twilioVerify.sdl.ts.
// api/src/graphql/twilioVerify.sdl.ts
export const schema = gql`
type Mutation {
"""Enables 2FA for the currently logged-in user"""
enableTwoFactor(phoneNumber: String!): User! @requireAuth
"""Disables 2FA for the currently logged-in user"""
disableTwoFactor: User! @requireAuth
"""[Conceptual] Sends an OTP code (used during login verification)"""
requestOtpVerification(phoneNumber: String!): String! @requireAuth(roles: ["USER_PENDING_2FA"])
"""[Conceptual] Verifies an OTP code during login"""
verifyOtpAndLogIn(phoneNumber: String!, code: String!): AuthResult! @requireAuth(roles: ["USER_PENDING_2FA"])
"""Verifies an OTP code during 2FA setup"""
verifyOtpForSetup(phoneNumber: String!, code: String!): User! @requireAuth
}
# We need a custom role or status (like USER_PENDING_2FA) to track users
# who have passed password auth but are pending 2FA verification during login.
# This requires modifying dbAuth's core logic in api/src/lib/auth.ts.
# @requireAuth ensures a user is logged in for setup/disable.
# The standard AuthResult or User type might need modification
# if we want to return specific 2FA states.
`Important: The @requireAuth(roles: ["USER_PENDING_2FA"]) and associated mutations (requestOtpVerification, verifyOtpAndLogIn) are conceptual placeholders. Implementing the 2FA-during-login flow requires significant modifications to Redwood's dbAuth logic in api/src/lib/auth.ts, which is beyond this guide's scope. Focus on the enable/disable/verifyOtpForSetup mutations for managing 2FA settings.
What's missing: Complete implementation requires modifying dbAuth to create a "pending 2FA" state after successful password authentication, then completing the session only after OTP verification. This involves custom session management and state tracking.
3.2 Implement Resolvers:
Add the corresponding resolver functions to the twilioVerify service file (api/src/services/twilioVerify/twilioVerify.ts).
// api/src/services/twilioVerify/twilioVerify.ts
// ... (import statements and previous functions: sendVerificationCode, checkVerificationCode) ...
import { requireAuth } from 'src/lib/auth' // Redwood's auth utilities
import { AuthenticationError, UserInputError } from '@redwoodjs/graphql-server' // Import UserInputError
// --- Mutations for managing 2FA Setup ---
export const twilioVerifyMutations = {
/**
* Enables 2FA: Sends verification code to user's phone for setup.
* Stores phone number temporarily until verified.
*/
enableTwoFactor: async ({ phoneNumber }: { phoneNumber: string }) => {
requireAuth() // Ensure user is logged in
const userId = context.currentUser.id
// Basic E.164 format validation (consider libphonenumber-js for robustness)
validate(phoneNumber, 'Phone Number', {
presence: true,
format: {
pattern: /^\+[1-9]\d{1,14}$/,
message: 'Phone number must be in E.164 format (e.g., +15551234567)',
},
})
try {
// Send the verification code first
const status = await sendVerificationCode(phoneNumber)
if (status !== 'pending') {
throw new Error('Failed to initiate verification from Twilio.')
}
// Temporarily store phone number. Keep twoFactorEnabled false until verified.
const user = await db.user.update({
where: { id: userId },
data: {
phoneNumber: phoneNumber, // Store number for verification step
twoFactorEnabled: false, // Not enabled yet
},
})
logger.info(`Sent 2FA setup code to user ${userId} at ${phoneNumber}`)
// Return user data, frontend will now prompt for code
return user
} catch (error) {
logger.error({ error }, `Error enabling 2FA for user ${userId}`)
// Use UserInputError for validation issues, AuthenticationError for others
if (error.message.includes('E.164 format')) {
throw new UserInputError(error.message)
}
throw new AuthenticationError(`Failed to enable 2FA: ${error.message}`)
}
},
/**
* Verifies the OTP code during the setup process.
* If successful, marks 2FA as enabled for the user.
*/
verifyOtpForSetup: async ({ phoneNumber, code }: { phoneNumber: string; code: string }) => {
requireAuth()
const userId = context.currentUser.id
// Validate inputs
validate(phoneNumber, 'Phone Number', { presence: true }) // Basic check
validate(code, 'Verification Code', { presence: true })
try {
// Retrieve the user to ensure the phone number matches what we have stored
const user = await db.user.findUnique({ where: { id: userId }})
if (!user || user.phoneNumber !== phoneNumber) {
// Don't reveal if user exists, just state the problem
throw new AuthenticationError('Cannot verify OTP for this phone number at this time.')
}
if (user.twoFactorEnabled) {
// Already enabled, maybe an accidental double-click?
logger.warn(`User ${userId} attempted to verify setup but 2FA is already enabled.`)
return user; // Return current state
}
const status = await checkVerificationCode(phoneNumber, code)
if (status === 'approved') {
// Verification successful! Mark 2FA as enabled.
const updatedUser = await db.user.update({
where: { id: userId },
data: {
twoFactorEnabled: true,
},
})
logger.info(`Successfully enabled 2FA for user ${userId}`)
return updatedUser
} else if (status === 'incorrect') {
logger.warn(`Failed 2FA setup verification for user ${userId} (Status: ${status})`)
throw new UserInputError('Invalid or expired verification code.')
} else {
// Handle other statuses ('pending', 'canceled', or errors from checkVerificationCode)
logger.warn(`Failed 2FA setup verification for user ${userId} (Status: ${status})`)
throw new AuthenticationError('Could not verify code. Try again.')
}
} catch (error) {
logger.error({ error }, `Error verifying 2FA setup code for user ${userId}`)
// Avoid leaking specific Twilio errors to the client if possible
if (error instanceof UserInputError) { throw error } // Re-throw validation errors
throw new AuthenticationError(error.message || 'Failed to verify code.')
}
},
/**
* Disables 2FA for the currently logged-in user.
*/
disableTwoFactor: async () => {
requireAuth()
const userId = context.currentUser.id
try {
const updatedUser = await db.user.update({
where: { id: userId },
data: {
twoFactorEnabled: false,
phoneNumber: null, // Clear phone number when disabling
},
})
logger.info(`Disabled 2FA for user ${userId}`)
return updatedUser
} catch (error) {
logger.error({ error }, `Error disabling 2FA for user ${userId}`)
throw new Error('Failed to disable 2FA.') // Use generic Error or AuthenticationError
}
},
// --- Mutations for Login Flow (Conceptual - Requires dbAuth modification) ---
/**
* [Conceptual] Sends OTP after successful password login IF 2FA is enabled.
* This would likely be called *internally* by a modified dbAuth authenticate function.
* Requires careful security implementation.
*/
requestOtpVerification: async ({ phoneNumber }: { phoneNumber: string }) => {
// IMPORTANT: This mutation requires significant security considerations and
// modification of the core dbAuth flow (api/src/lib/auth.ts).
// It should ONLY be callable when the user is in a specific 'pending 2FA' state
// after successful password auth. The conceptual role check represents this.
requireAuth({ roles: ["USER_PENDING_2FA"] }) // Conceptual role check
const userId = context.currentUser.id // User should be partially authenticated
validate(phoneNumber, 'Phone Number', { presence: true, format: { pattern: /^\+[1-9]\d{1,14}$/ } })
const user = await db.user.findUnique({ where: { id: userId } });
if (!user || !user.twoFactorEnabled || user.phoneNumber !== phoneNumber) {
logger.error(`Attempt to request OTP for user ${userId} with invalid state or phone number.`);
throw new AuthenticationError('Cannot request OTP verification at this time.');
}
logger.info(`Requesting OTP for login verification for user ${userId}`);
try {
const status = await sendVerificationCode(phoneNumber);
if (status !== 'pending') {
throw new Error('Failed to send verification code via Twilio.');
}
return status; // Return 'pending'
} catch (error) {
logger.error({ error }, `Error sending OTP during login for user ${userId}`);
throw new AuthenticationError('Failed to send verification code.');
}
},
/**
* [Conceptual] Verifies OTP during login and completes the session.
* Requires the 'pending 2FA' state and deep integration with dbAuth session management.
*/
verifyOtpAndLogIn: async ({ phoneNumber, code }: { phoneNumber: string; code: string }) => {
// IMPORTANT: Similar security considerations and dbAuth modification requirements
// as requestOtpVerification apply here.
requireAuth({ roles: ["USER_PENDING_2FA"] }) // Conceptual role check
const userId = context.currentUser.id
validate(phoneNumber, 'Phone Number', { presence: true })
validate(code, 'Verification Code', { presence: true })
const user = await db.user.findUnique({ where: { id: userId } });
if (!user || !user.twoFactorEnabled || user.phoneNumber !== phoneNumber) {
logger.error(`Attempt to verify OTP for user ${userId} with invalid state or phone number.`);
throw new AuthenticationError('Cannot verify OTP at this time.');
}
logger.info(`Verifying OTP during login for user ${userId}`);
try {
const status = await checkVerificationCode(phoneNumber, code);
if (status === 'approved') {
logger.info(`User ${userId} successfully verified OTP for login.`);
// CRITICAL TODO: Integrate with dbAuth session management.
// This involves updating the session state to fully authenticated,
// clearing the 'pending 2FA' status, and returning the expected
// AuthResult structure defined by dbAuth's `auth.ts`.
// The exact implementation depends heavily on how dbAuth is modified.
return {
// This structure MUST match dbAuth's expected return type upon
// successful authentication. It likely includes user id, email, roles etc.
// Omitting sensitive data like hashedPassword is crucial.
id: user.id,
email: user.email,
roles: context.currentUser?.roles, // Pass existing roles if needed
// Potentially add other fields expected by dbAuth
} as any; // Cast as 'any' is TEMPORARY - Needs proper typing matching dbAuth.
} else if (status === 'incorrect') {
logger.warn(`Failed OTP login verification for user ${userId} (Status: ${status})`);
throw new UserInputError('Invalid or expired verification code.');
} else {
logger.warn(`Failed OTP login verification for user ${userId} (Status: ${status})`);
throw new AuthenticationError('Could not verify code. Try again.');
}
} catch (error) {
logger.error({ error }, `Error verifying OTP during login for user ${userId}`);
if (error instanceof UserInputError) { throw error }
throw new AuthenticationError(error.message || 'Failed to verify code.');
}
}
}
// Export the mutations for Redwood's GraphQL handler
export const twilioVerify = { ...twilioVerifyMutations }Important Considerations:
@requireAuth: Ensures only logged-in users can callenable/disable/verifyOtpForSetup- Login Flow (
requestOtpVerification,verifyOtpAndLogIn): These are marked conceptual because properly integrating them requires modifying Redwood's coredbAuthauthentication flow (api/src/lib/auth.ts). This involves:- Intercepting the default
authenticatefunction inauth.ts - After successful password check, if
user.twoFactorEnabledis true, prevent the immediate return of the full user session - Instead, return a partial session or set a specific state/role (like the conceptual
USER_PENDING_2FA) - The frontend uses this partial state to prompt for the OTP
- The frontend calls
requestOtpVerification(which needs the special role/state) - The frontend calls
verifyOtpAndLogIn(also needs the special role/state) verifyOtpAndLogInmust then correctly finalize the session setup upon successful OTP verification, returning the structure expected bydbAuth
- Modifying
api/src/lib/auth.tsis complex and requires careful security considerations – beyond this guide's scope. This guide focuses on the mechanics of enabling/disabling 2FA and interacting with Twilio.
- Intercepting the default
- Error Handling: Uses
AuthenticationErrorfor auth-related issues andUserInputErrorfor validation problems, providing clearer feedback to the frontend without leaking internal details - Validation: Uses Redwood's
validate. Consider more robust phone number validation (e.g.,libphonenumber-js) for production environments
4. Integrating with Third-Party Services (Twilio)
Get the necessary credentials from Twilio.
4.1 Sign Up/Log In to Twilio:
- Go to twilio.com and sign up for a free trial account or log in
Free trial limitations: Trial accounts include $15.50 USD in credit. You can send SMS to verified numbers only (numbers you register in your Twilio console). Production accounts can send to any number but incur per-message costs (approximately $0.0075 per SMS in the US).
4.2 Find Account SID and Auth Token:
-
Navigate to your main Twilio Console Dashboard (https://console.twilio.com/)
-
Your Account SID and Auth Token appear prominently on the dashboard under "Account Info"
- Click "Show" or authenticate again to reveal the Auth Token
-
Copy these values into your
.envfile forTWILIO_ACCOUNT_SIDandTWILIO_AUTH_TOKENdotenv# .env TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TWILIO_AUTH_TOKEN=your_auth_token_here
4.3 Create a Twilio Verify Service:
- In the Twilio Console, use the search bar (or navigate via "Explore Products" → "Verify") to find the Verify service
- Click on Verify → Services in the left sidebar
- Click the "Create Service Now" button (or "Create new Service")
- Enter a Friendly Name for your service (e.g., "Redwood App 2FA")
- Under "Verification Channels", ensure SMS is enabled. Disable others like "Call" or "Email" if not needed
- Set the Code length (6 digits is standard and recommended)
- Code expiration: Default is 10 minutes. Adjust under "Code Expiration" if needed (shorter timeframes increase security but may frustrate users)
- Configure other settings like "Default Sender ID" if desired (often requires further setup/number purchase)
- Click "Create"
4.4 Find Verify Service SID:
-
After creating the service, you'll land on its settings page
-
The Service SID (starting with
VA...) appears at the top -
Copy this value into your
.envfile forTWILIO_VERIFY_SERVICE_SIDdotenv# .env TWILIO_VERIFY_SERVICE_SID=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
4.5 Security: Handling API Keys:
- NEVER commit your
.envfile to version control (Git). Ensure.envis listed in your.gitignorefile (Redwood adds this by default) - Use your cloud provider's secret management system to store these credentials in production:
- AWS: AWS Secrets Manager or Systems Manager Parameter Store
- Google Cloud: Secret Manager
- Vercel: Environment Variables (Settings → Environment Variables)
- Netlify: Environment Variables (Site settings → Build & deploy → Environment)
Environment Variables Explained:
TWILIO_ACCOUNT_SID: Your main Twilio account identifierTWILIO_AUTH_TOKEN: Your secret key for authenticating API requests to Twilio – treat it like a passwordTWILIO_VERIFY_SERVICE_SID: Identifies the specific Verify configuration (code length, channels, etc.) you want to use within your Twilio account
5. Error Handling, Logging, and Retry Mechanisms
Error Handling Strategy:
- Service Level: Catch errors from the Twilio client (
try...catchblocks intwilioVerify.ts). Handle specific errors like 404 on verification checks to return meaningful statuses ('incorrect') - API Level (GraphQL Resolvers):
- Catch errors from the service calls
- Use Redwood's
AuthenticationErrorfor auth/permission issues andUserInputErrorfor bad input (like invalid phone format or incorrect OTP), providing clear error messages to the frontend - Avoid exposing sensitive internal or Twilio-specific error details directly to the client
- Twilio Errors: The
twilio-nodelibrary throws errors with properties likestatus(HTTP status code) andcode(Twilio error code). Log these details for debugging (logger.error({ error }, 'message'))
Common Twilio Error Codes:
| Error Code | HTTP Status | Description | Handling |
|---|---|---|---|
| 20003 | 403 | Authentication failed | Verify your Account SID and Auth Token are correct |
| 20404 | 404 | Resource not found | Check your Verify Service SID is correct |
| 60200 | 400 | Invalid phone number | Validate phone format before sending to Twilio |
| 60202 | 429 | Max check attempts reached | Implement rate limiting on frontend; inform user to request new code |
| 60203 | 404 | Max send attempts reached | Implement rate limiting; wait before allowing resend |
Logging:
- Use Redwood's built-in
logger(import { logger } from 'src/lib/logger') - Log key events:
info: Sending verification, checking verification, enabling/disabling 2FA success, successful login completion (within the modifiedauth.ts)warn: Failed verification checks (e.g., wrong code returned status'incorrect'), attempts to use 2FA when not configured, failed login attempts due to incorrect OTPerror: Twilio API errors (non-404s during checks), database errors, unexpected exceptions, configuration errors (missing env vars) – include the error object in the log context
- Production Logging: Configure Redwood's logger for production (JSON format, appropriate log level) in
api/src/lib/logger.tsand forward logs to a dedicated logging service (Datadog, Logtail, Papertrail, etc.) for analysis and alerting
Retry Mechanisms:
- OTP Sending: If
sendVerificationCodefails due to a transient network issue, a very limited server-side retry (1–2 attempts with exponential backoff) might be acceptable. However, it's simpler and safer to let the user trigger a "Resend Code" action on the frontend if the first attempt fails - OTP Checking: Retrying OTP checks (
checkVerificationCode) automatically is strongly discouraged as it facilitates brute-force attacks. Rely on Twilio's built-in rate limiting for verification checks. Frontend should allow the user to re-enter the code, but the backend should not retry the check automatically on failure
Twilio Rate Limits:
- Verification sends: 5 attempts per phone number per hour by default
- Verification checks: 5 attempts per verification code by default
- Configure these limits in your Twilio Verify Service settings
Example Testing Error Scenarios:
- Provide an incorrectly formatted phone number to
enableTwoFactor - Provide a valid number but an incorrect OTP code to
verifyOtpForSetup - Let the OTP code expire (Twilio default is 10 minutes) and then try to verify
- Temporarily remove Twilio credentials from
.envto test configuration error handling in the service
6. Database Schema and Data Layer
Update the User model to store 2FA-related information.
6.1 Update Prisma Schema:
Modify the User model in api/db/schema.prisma:
// api/db/schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
hashedPassword String? // Made optional by dbAuth setup
salt String? // Made optional by dbAuth setup
resetToken String?
resetTokenExpiresAt DateTime?
// Add these fields for 2FA
twoFactorEnabled Boolean @default(false)
phoneNumber String? @unique // Store in E.164 format, make unique
// Nullable: only set when 2FA is enabled/pending.
// Optional: Add fields for recovery codes if implementing that feature
// recoveryCodes String[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Explanation:
twoFactorEnabled: A boolean flag indicating if the user has successfully set up and enabled 2FA – defaults tofalsephoneNumber: Stores the user's verified phone number in E.164 format (e.g.,+15551234567). It's nullable because users without 2FA won't have one stored. Making it@uniqueprevents two users from registering the same phone number for 2FA (consider implications if shared numbers are a valid use case, such as family accounts)
6.2 Create and Apply Migration:
Generate a new migration file reflecting these schema changes and apply it to your database:
# Create the migration file
yarn rw prisma migrate dev --name "add 2fa fields to user"
# Review the generated SQL in the migrations folder (optional but recommended)
# Apply the migration (if not done automatically by the previous command)
# yarn rw prisma migrate deployRollback instructions: If the migration fails or you need to revert, run yarn rw prisma migrate resolve --rolled-back [migration_name] and manually restore your database from a backup.
Data Access:
- The Prisma client (
dbimported fromsrc/lib/db) is used in thetwilioVerifyservice resolvers to read and update thetwoFactorEnabledandphoneNumberfields during the enable/disable/verify flows
Performance/Scale:
- Adding these fields has minimal performance impact. The
@uniqueconstraint onphoneNumberautomatically creates a database index, ensuring efficient lookups if needed
Handling Existing Users: If you deploy this to a production database with existing users, all users will have twoFactorEnabled set to false and phoneNumber set to null by default. Users can opt-in to 2FA through their profile settings.
7. Adding Security Features
Security is paramount for authentication flows.
7.1 Input Validation and Sanitization:
- Phone Numbers: Use robust backend validation (e.g.,
libphonenumber-jsrecommended) to ensure E.164 format before sending to Twilio or storing. Use Redwood'svalidatefor basic presence/format checks as a first line of defense in resolvers
Install libphonenumber-js:
yarn workspace api add libphonenumber-jsEnhanced phone validation:
import { parsePhoneNumber } from 'libphonenumber-js'
function validateAndFormatPhone(phoneInput: string): string {
try {
const phoneNumber = parsePhoneNumber(phoneInput)
if (!phoneNumber.isValid()) {
throw new Error('Invalid phone number')
}
return phoneNumber.format('E.164') // Returns formatted string like +15551234567
} catch (error) {
throw new UserInputError('Phone number must be a valid international number (e.g., +1 555 123 4567)')
}
}- OTP Codes: Validate expected length and format (usually digits) on the API side using
validate
7.2 Protection Against Common Vulnerabilities:
- CSRF: RedwoodJS has built-in CSRF protection via the
@redwoodjs/api-serverpackage. Ensure it remains enabled (this is the default) - XSS: Rely on React's automatic escaping for rendering user data. Avoid
dangerouslySetInnerHTMLunless absolutely necessary and sanitize content with a library like DOMPurify - SQL Injection: Prisma protects against SQL injection by using parameterized queries. Never concatenate user input into raw SQL queries
- Rate Limiting: Implement rate limiting on 2FA endpoints to prevent brute-force attacks:
// In api/src/functions/graphql.ts or your GraphQL handler
import rateLimit from 'express-rate-limit'
const otpLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Limit each IP to 5 requests per windowMs
message: 'Too many OTP attempts. Try again in 15 minutes.',
})
// Apply to your GraphQL endpoint
// Implementation depends on your server setup-
Session Hijacking Prevention:
- Use secure, HTTP-only cookies for session tokens (RedwoodJS
dbAuthdoes this by default) - Implement session expiration (configure in
api/src/lib/auth.ts) - Regenerate session IDs after successful 2FA verification
- Use HTTPS in production (enforced via your hosting provider)
- Use secure, HTTP-only cookies for session tokens (RedwoodJS
-
Backup Codes/Recovery Mechanism: Implement backup codes users can generate and store securely. If they lose phone access, they can use a backup code to log in and reconfigure 2FA:
// Add to User model in schema.prisma
recoveryCodes String[] // Array of hashed backup codesGenerate backup codes on 2FA setup:
import crypto from 'crypto'
function generateRecoveryCodes(count: number = 10): string[] {
return Array.from({ length: count }, () =>
crypto.randomBytes(4).toString('hex').toUpperCase()
)
}
function hashRecoveryCode(code: string): string {
return crypto.createHash('sha256').update(code).digest('hex')
}Security Testing Recommendations:
- Perform penetration testing before production deployment
- Test with OWASP ZAP or Burp Suite for common vulnerabilities
- Implement automated security scanning in your CI/CD pipeline
- Review the OWASP Top 10 and ensure your implementation addresses each risk
- Test rate limiting by attempting multiple rapid OTP requests
- Verify session cookies have
Secure,HttpOnly, andSameSiteattributes
Summary
This guide provided a comprehensive walkthrough for implementing SMS-based 2FA with Twilio Verify in RedwoodJS. You learned how to:
- Set up a RedwoodJS project with
dbAuth - Create a Twilio Verify service and integrate it with your backend
- Build GraphQL mutations for enabling, disabling, and verifying 2FA
- Update your database schema to support 2FA
- Implement proper error handling, logging, and security measures
What's not covered (requires additional implementation):
- Complete frontend implementation (React components for 2FA setup and login flows)
- Full integration of 2FA into the login flow (requires modifying
api/src/lib/auth.ts) - Recovery codes UI and backend logic
- Production deployment configuration
- Monitoring and alerting setup
- User experience optimization (progressive disclosure, clear error messages, accessibility)
- Compliance considerations (GDPR data handling, TCPA consent for SMS)
Next steps:
- Test your implementation thoroughly with various error scenarios
- Implement the frontend UI for 2FA setup and verification
- Modify
dbAuthto integrate 2FA into the login flow - Add backup codes for account recovery
- Set up monitoring and alerting for authentication failures
- Review security best practices and perform security testing before production deployment
Production checklist:
- Environment variables securely stored in cloud secret manager
- HTTPS enforced on all endpoints
- Rate limiting implemented on authentication endpoints
- Session cookies configured with
Secure,HttpOnly,SameSiteattributes - Backup codes implemented and tested
- Error messages don't leak sensitive information
- Logging configured for production (JSON format, appropriate retention)
- Security testing completed (penetration testing, OWASP Top 10 review)
- User documentation written (how to enable/disable 2FA, what to do if phone is lost)
- Compliance requirements reviewed (GDPR, TCPA, etc.)