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.)
Frequently Asked Questions
How to set up two-factor authentication in RedwoodJS?
Set up 2FA in your RedwoodJS app by first creating a new RedwoodJS project using Yarn, configuring your database in `schema.prisma`, and then setting up dbAuth using Redwood's generator. This lays the foundation for integrating Twilio Verify for SMS-based OTPs.
What is Twilio Verify used for in 2FA?
Twilio Verify is a service that simplifies the process of sending and verifying one-time passwords (OTPs) for two-factor authentication (2FA). In this setup, it's used to send OTPs via SMS to enhance login security.
Why use Twilio for two-factor authentication?
Twilio Verify is a managed service, providing reliability and scalability for sending and verifying OTPs. It's chosen for its ease of integration with various platforms, including RedwoodJS, and support for multiple channels like SMS.
When should I implement two-factor authentication?
Implement 2FA whenever enhanced security is a priority. It mitigates risks like phishing, brute-force attacks, and credential stuffing, which are common vulnerabilities of standard password authentication.
How to install Twilio SDK in RedwoodJS?
You can install the Twilio Node.js helper library using Yarn within the API workspace of your RedwoodJS project. This allows your backend to interact directly with the Twilio API for sending and verifying OTPs.
What environment variables are needed for Twilio integration?
You need three environment variables from your Twilio account: `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, and `TWILIO_VERIFY_SERVICE_SID`. These credentials are essential for authenticating and using the Twilio Verify service.
How to create a Twilio Verify service?
Create a Twilio Verify Service through your Twilio console. Enable SMS as the verification channel, set your desired code length (6 digits is recommended), and configure other settings like the Default Sender ID.
Where do I find my Twilio Account SID and Auth Token?
Your Twilio Account SID and Auth Token can be found on your main Twilio Console Dashboard under "Account Info." You may need to click "Show" or re-authenticate to reveal your Auth Token.
What is a Verify Service SID and where can I find it?
The Verify Service SID (starting with 'VA...') is a unique identifier for your Twilio Verify service configuration. It can be found on the settings page of the Verify service you created in the Twilio console.
How to update Prisma schema for two-factor authentication?
Add `twoFactorEnabled` (Boolean) and `phoneNumber` (String, unique, nullable) fields to your `User` model in your `schema.prisma` file. These fields store the user's 2FA status and phone number.
Why is logging important for 2FA implementation?
Logging is crucial for debugging, monitoring, and security auditing. Use Redwood's built-in logger to track key events like successful verification, failed attempts, errors, and configuration issues for better visibility into the 2FA process.
What are RedwoodJS Services and why use them for 2FA?
RedwoodJS Services encapsulate backend logic, promoting reusability and testability. Using a service for Twilio interactions keeps 2FA logic organized and separate from other parts of your application.
How does input validation enhance security in 2FA?
Input validation prevents vulnerabilities. Use Redwood's `validate` and consider a library like `libphonenumber-js` to ensure phone numbers and OTP codes are in the correct format, preventing issues and potential exploits.
How to handle errors from Twilio API calls in RedwoodJS?
Implement `try...catch` blocks in your service file to handle Twilio API errors. Use Redwood's `AuthenticationError` and `UserInputError` to provide specific error feedback to the frontend without revealing sensitive details.