Implementing Two-Factor Authentication (2FA) with Twilio Verify in RedwoodJS
This guide provides a step-by-step walkthrough for adding SMS-based One-Time Password (OTP) two-factor authentication (2FA) to your RedwoodJS application using Twilio Verify. We'll augment Redwood's built-in dbAuth
to require an OTP code after successful password validation for users who have enabled 2FA.
This enhances security by requiring users to possess both their password (something they know) and access to their registered phone (something they have).
Project Overview and Goals
What We'll Build:
- A RedwoodJS application using
dbAuth
for 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 is vulnerable to phishing, brute-force attacks, and credential stuffing. Adding 2FA significantly mitigates these risks by adding a second verification layer.
Technologies Used:
- RedwoodJS: Full-stack JavaScript/TypeScript framework (React frontend, GraphQL API, Prisma ORM). Chosen for its integrated structure and developer experience.
- Node.js: The runtime environment for RedwoodJS's API side.
- Prisma: Database toolkit for schema management and database access.
- Twilio Verify: A managed service API for sending and checking OTP codes across various channels (we'll 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.
Final Outcome & Prerequisites:
- A RedwoodJS app where users can optionally enable robust SMS 2FA.
- Prerequisites:
- Node.js (>=18.x recommended) and Yarn installed.
- A Twilio account (Free Tier is sufficient for testing).
- A personal phone number capable of receiving SMS for testing.
- Basic understanding of RedwoodJS, React, GraphQL, and Prisma.
- A working PostgreSQL database instance.
1. Setting up the Project
We'll 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-2fa
1.2 Configure Database:
Ensure your PostgreSQL database is running. Update the provider
and url
in api/db/schema.prisma
if necessary (default is PostgreSQL).
// 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 actual database connection string:
# .env
DATABASE_URL=postgresql://user:password@host:port/database_name
1.3 Setup dbAuth:
Run the RedwoodJS dbAuth
setup generator. This scaffolds basic login/signup pages and API functions.
yarn rw setup auth dbAuth
This command modifies files on both the web
and api
sides and adds a basic User
model to your schema.prisma
.
1.4 Apply Initial Migration:
Create and apply the initial database migration, which includes the User
table generated by dbAuth
.
yarn rw prisma migrate dev --name "initial setup with dbAuth"
1.5 Install Twilio SDK:
Install the necessary Twilio Node.js helper library in the API workspace. Redwood automatically handles loading variables from the .env
file.
yarn workspace api add twilio
1.6 Configure Environment Variables for Twilio:
You'll need three pieces of information from your Twilio account (covered in Section 4): Account SID, Auth Token, and Verify Service SID. Add these to your .env
file.
# .env
# Database URL (already added)
DATABASE_URL=postgresql://user:password@host:port/database_name
# 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=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Project Structure Explanation:
api/
: Holds backend code (database schema, services, GraphQL definitions).web/
: Holds frontend code (React components, pages, layouts).scripts/
: For seeding or other helper scripts..env
: Stores environment variables securely (DO NOT commit this file).redwood.toml
: Project configuration.
We install twilio
in the api
workspace because only the backend needs to interact directly with the Twilio API.
2. Implementing Core Functionality (API Service)
We'll create a Redwood Service to encapsulate the logic for interacting with Twilio Verify.
2.1 Create Twilio Verify Service:
Use the Redwood generator to create a service for Twilio operations.
yarn rw g service twilioVerify
This creates api/src/services/twilioVerify/twilioVerify.ts
and related test/scenario files.
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)
We expose the service functions via GraphQL mutations so the frontend can trigger them.
3.1 Define GraphQL Schema:
Create a new SDL file for our 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.
`
Note: The @requireAuth(roles: ["USER_PENDING_2FA"])
and the associated mutations (requestOtpVerification
, verifyOtpAndLogIn
) are conceptual. Implementing the 2FA-during-login flow requires significant modifications to Redwood's dbAuth
logic in api/src/lib/auth.ts
, which is beyond the scope of this guide. For now, focus on the enable
/disable
/verifyOtpForSetup
mutations for managing 2FA settings.
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. Please 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. Please 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 coredbAuth
authentication flow (api/src/lib/auth.ts
). This involves:- Intercepting the default
authenticate
function inauth.ts
. - After successful password check, if
user.twoFactorEnabled
is 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). verifyOtpAndLogIn
must then correctly finalize the session setup upon successful OTP verification, returning the structure expected bydbAuth
.
- Modifying
api/src/lib/auth.ts
is a complex task requiring careful security considerations and is outside the scope of this guide. This guide focuses on the mechanics of enabling/disabling 2FA and interacting with Twilio.
- Intercepting the default
- Error Handling: Uses
AuthenticationError
for auth-related issues andUserInputError
for 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)
Let's 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.
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 are displayed prominently on the dashboard under ""Account Info"".
- You might need to click ""Show"" or authenticate again to reveal the Auth Token.
-
Copy these values into your
.env
file forTWILIO_ACCOUNT_SID
andTWILIO_AUTH_TOKEN
.# .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. You can disable others like ""Call"" or ""Email"" if not needed.
- Set the Code length (6 digits is standard and recommended).
- 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 be taken to its settings page.
-
The Service SID (starting with
VA...
) is displayed at the top. -
Copy this value into your
.env
file forTWILIO_VERIFY_SERVICE_SID
.# .env TWILIO_VERIFY_SERVICE_SID=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
4.5 Security: Handling API Keys:
- NEVER commit your
.env
file to version control (Git). Ensure.env
is listed in your.gitignore
file (Redwood adds this by default). - Use your cloud provider's secret management system (e.g., AWS Secrets Manager, Google Secret Manager, Vercel Environment Variables) to store these credentials in production environments.
Environment Variables Explained:
TWILIO_ACCOUNT_SID
: Your main Twilio account identifier.TWILIO_AUTH_TOKEN
: Your secret key for authenticating API requests to Twilio. Treat it like a password.TWILIO_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...catch
blocks 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
AuthenticationError
for auth/permission issues andUserInputError
for 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-node
library throws errors with properties likestatus
(HTTP status code) andcode
(Twilio error code). Log these details for debugging (logger.error({ error }, 'message')
).
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 OTP.error
: 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 (e.g., JSON format, appropriate log level) and forward logs to a dedicated logging service (Datadog, Logtail, Papertrail, etc.) for analysis and alerting.
Retry Mechanisms:
- OTP Sending: If
sendVerificationCode
fails due to a potential transient network issue between your server and Twilio, a very limited server-side retry (1-2 attempts with backoff) might be acceptable. However, it's often simpler and safer to let the user trigger a "Resend Code" action on the frontend if the first attempt fails visibly. - OTP Checking: Retrying OTP checks (
checkVerificationCode
) automatically is strongly discouraged as it can facilitate 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.
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
.env
to test configuration error handling in the service.
6. Database Schema and Data Layer
We need to 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 tofalse
.phoneNumber
: 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@unique
prevents two users from registering the same phone number for 2FA (consider implications if shared numbers are a valid use case).
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 deploy
Data Access:
- The Prisma client (
db
imported fromsrc/lib/db
) is used in thetwilioVerify
service resolvers to read and update thetwoFactorEnabled
andphoneNumber
fields during the enable/disable/verify flows.
Performance/Scale:
- Adding these fields has minimal performance impact. The
@unique
constraint onphoneNumber
automatically creates a database index, ensuring efficient lookups if needed.
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-js
recommended, see Section 8) to ensure E.164 format before sending to Twilio or storing. Use Redwood'svalidate
for basic presence/format checks as a first line of defense in resolvers. - 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. Ensure it's enabled and configured correctly.
- XSS: Rely on React's automatic escaping for rendering user data. Avoid
dangerouslySetInnerHTML
.