This guide provides a step-by-step walkthrough for integrating AWS Simple Notification Service (SNS) to send One-Time Passwords (OTPs) via SMS, adding a layer of Two-Factor Authentication (2FA) to a RedwoodJS application using the built-in dbAuth provider.
We'll build a system where users, after logging in with their password, must verify their identity using a code sent to their registered phone number if they have 2FA enabled.
Project Goals:
Enhance application security with SMS-based OTP verification.
Integrate AWS SNS for reliable SMS delivery.
Leverage RedwoodJS's dbAuth for primary authentication.
Provide a seamless user experience for enabling and verifying 2FA.
Technology Stack:
Framework: RedwoodJS (v8.x or later recommended)
Authentication: RedwoodJS dbAuth
Database: PostgreSQL (or SQLite/MySQL supported by Prisma)
OTP Delivery: AWS Simple Notification Service (SNS)
AWS Interaction: AWS SDK for JavaScript v3 (@aws-sdk/client-sns)
Hashing: bcrypt (for secure OTP storage)
System Architecture:
Text
+-------------+ +-----------------+ +-----------------+ +---------+ +-------------+
| User | ----> | Redwood Web UI | ----> | Redwood API | ----> | dbAuth | ----> | Database |
| (Browser) | | (React) | | (GraphQL/Lambda)| | (Login) | | (Prisma) |
+-------------+ +-----------------+ +-----------------+ +---------+ +-------------+
| | | ^ |
| 1. Login Request (Email/Pass) | | | |
| | | | 2. Verify Credentials |
| | | | |
| | | v 3. Check if 2FA Enabled |
| | |--------------------------------------> |
| | |
| | 4. If 2FA Enabled: |
| | a. Generate OTP |
| | b. Send OTP via AWS SNS |
| | c. Hash OTP & Store Hash/Expiry |
| |---------+ |
| | |
| v |
| +-----------------+ |
| | AWS SNS Service | |
| +-----------------+ |
| | |
| v 5. Send SMS OTP |
| +-----------------+ |
|<--------------------------------------------- | User's Phone | <---------------------+
| 6. User Receives OTP +-----------------+
|
| 7. User Submits OTP via Web UI
|
|------------> Redwood Web UI -------------> Redwood API (Verify OTP) -----> Database (Check OTP Hash/Expiry)
|
| 8. If Valid: Grant Full Access
| 9. If Invalid: Deny Access / Retry
Prerequisites:
Node.js (v18 or later) and Yarn installed.
A RedwoodJS project already set up or willingness to create one.
An AWS account with permissions to manage SNS and create IAM users.
Access to the AWS Management Console.
A mobile phone number capable of receiving SMS for testing.
Final Outcome:
A RedwoodJS application where users can optionally enable SMS-based 2FA. Authenticated users with 2FA enabled will be prompted for an OTP code after password login before gaining full access.
1. Setting up the Project
We'll start with a new RedwoodJS project and set up dbAuth. If you have an existing project with dbAuth, you can adapt these steps.
1.1 Create RedwoodJS App (if needed):
Open your terminal and run:
Bash
yarn create redwood-app ./redwood-sns-otp
cd redwood-sns-otp
1.2 Setup dbAuth:
Redwood's dbAuth provides the foundation for user authentication (signup, login, password management).
Bash
yarn rw setup auth dbAuth
This command modifies your schema, adds API functions, and generates web-side pages for authentication.
1.3 Modify Database Schema:
We need to add fields to the User model to support 2FA. Open api/db/schema.prisma:
Prisma
// api/db/schema.prisma
datasource db {
provider = "postgresql" // Or your chosen DB provider
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
binaryTargets = "native"
}
model User {
id Int @id @default(autoincrement())
email String @unique
hashedPassword String
salt String
resetToken String?
resetTokenExpiresAt DateTime?
roles String? @default("user") // Optional: For role-based access
// --- 2FA Fields ---
phoneNumber String? @unique // Store in E.164 format (e.g., +12223334444)
twoFactorEnabled Boolean @default(false)
otpSecret String? // Stores the securely HASHED OTP secret
otpExpiresAt DateTime? // Store expiry time for the OTP
otpAttempts Int? @default(0) // Track failed attempts
// --- End 2FA Fields ---
// Optional: Add other user profile fields as needed
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Explanation:
phoneNumber: Stores the user's verified phone number for OTP delivery (must be unique). Storing in E.164 format is crucial for SNS.
twoFactorEnabled: A flag indicating if the user has enabled 2FA.
otpSecret: Stores a secure hash (e.g., bcrypt) of the generated OTP. Never store the plain OTP.
otpExpiresAt: Timestamp indicating when the generated OTP becomes invalid.
otpAttempts: Tracks failed verification attempts to prevent brute-force attacks.
1.4 Apply Database Migrations:
Run the Prisma migration command to apply schema changes to your database:
Bash
yarn rw prisma migrate dev --name add-2fa-fields
This creates a new migration file and updates your database schema.
1.5 Install Dependencies:
Install the necessary AWS SDK v3 package for SNS and bcrypt for hashing:
Bash
yarn workspace api add @aws-sdk/client-sns bcrypt @types/bcrypt
1.6 Configure Environment Variables:
Add AWS credentials and SNS configuration to your .env file. Never commit .env files with secrets to version control.
.env
# .env (at the root of your project)
# Database URL (already present from Redwood setup)
DATABASE_URL="postgresql://user:password@localhost:5432/redwood_sns_otp?schema=public"
# --- AWS SNS Configuration ---
# Replace with your actual AWS credentials and region
AWS_ACCESS_KEY_ID="YOUR_AWS_ACCESS_KEY_ID"
AWS_SECRET_ACCESS_KEY="YOUR_AWS_SECRET_ACCESS_KEY"
AWS_REGION="us-east-1" # e.g., us-east-1, eu-west-2
AWS_SNS_SENDER_ID="MyBrandOTP" # Optional: A custom sender ID (requires registration in some regions)
# --- End AWS SNS Configuration ---
# Optional: Redwood Logger Level
LOG_LEVEL="info"
Obtaining AWS Credentials:
Go to the AWS Management Console -> IAM (Identity and Access Management).
Navigate to Users and click Add users.
Enter a username (e.g., redwood-sns-user). Select Access key - Programmatic access as the credential type.
Search for and select the AmazonSNSFullAccess policy (for simplicity). For production, create a custom policy with least privilege, granting only sns:Publish permissions.
Click Next: Tags (optional), Next: Review, then Create user.
Crucially: Copy the Access key ID and Secret access key. You won't be able to see the secret key again after leaving this screen. Store them securely in your .env file.
Set AWS_REGION to the AWS region where you want to operate SNS (e.g., us-east-1).
AWS_SNS_SENDER_ID is optional but recommended for branding. Note that using custom Sender IDs often requires registration with authorities in specific countries (like India, USA 10DLC). If omitted or unregistered, SNS might use a generic number.
2. Implementing Core Functionality (OTP Logic)
We'll create services on the API side to handle OTP generation, sending, and verification.
2.1 Create OTP Service:
Generate a new service for OTP logic:
Bash
yarn rw g service otp
This creates api/src/services/otp/otp.ts, otp.scenarios.ts, and otp.test.ts.
2.2 Implement OTP Generation and Sending:
Open api/src/services/otp/otp.ts and add the logic.
TypeScript
// api/src/services/otp/otp.tsimport crypto from'crypto'// Use Node's crypto module for secure random generationimport bcrypt from'bcrypt'import { SNSClient, PublishCommand } from'@aws-sdk/client-sns'importtype { Prisma } from'@prisma/client'import { db } from'src/lib/db'import { requireAuth } from'src/lib/auth'// Use Redwood's auth checkerimport { logger } from'src/lib/logger'// Use Redwood's loggerconstOTP_LENGTH = 6// Standard OTP lengthconstOTP_VALIDITY_MINUTES = 5// How long the OTP is validconstMAX_OTP_ATTEMPTS = 5// Max verification attemptsconstBCRYPT_SALT_ROUNDS = 10// Cost factor for bcrypt hashing// Initialize SNS Client (ensure AWS env vars are set)const snsClient = newSNSClient({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
})
/**
* Generates a cryptographically secure random numeric string using Node's crypto module.
*/const generateOtpCode = (length: number): string => {
let otp = ''for (let i = 0; i < length; i++) {
// crypto.randomInt is preferred over Math.random() for security-sensitive numbers
otp += crypto.randomInt(0, 10).toString()
}
return otp
}
/**
* Sends an OTP code via AWS SNS.
*/constsendOtpSms = async (phoneNumber: string, otpCode: string) => {
// Ensure E.164 format for SNSif (!phoneNumber || !phoneNumber.match(/^\+[1-9]\d{1,14}$/)) {
logger.error({ custom: { phoneNumber } }, 'Invalid or missing phone number for SNS. Must be E.164.')
thrownewError('Invalid phone number format. Must start with +countrycode.')
}
const brandName = process.env.AWS_SNS_SENDER_ID || 'YourApp'// Use sender ID or fallbackconst message = `Your ${brandName} verification code is: ${otpCode}. It expires in ${OTP_VALIDITY_MINUTES} minutes.`const params = {
Message: message,
PhoneNumber: phoneNumber,
// Optional: Add MessageAttributes for SenderID if registered and required// MessageAttributes: {// 'AWS.SNS.SMS.SenderID': {// 'DataType': 'String',// 'StringValue': process.env.AWS_SNS_SENDER_ID// },// 'AWS.SNS.SMS.SMSType': {// 'DataType': 'String',// 'StringValue': 'Transactional' // Or 'Promotional'// }// }
}
try {
logger.info({ custom: { phoneNumber } }, 'Attempting to send OTP via SNS')
const command = newPublishCommand(params)
const data = await snsClient.send(command)
logger.info({ custom: { messageId: data.MessageId, phoneNumber } }, 'Successfully sent OTP via SNS')
return data
} catch (error) {
logger.error({ err: error, custom: { phoneNumber } }, 'Failed to send OTP via SNS')
// Handle specific SNS errors if needed (e.g., throttling, invalid number)thrownewError('Failed to send verification code. Please try again later.')
}
}
/**
* Mutation: Initiates the OTP request process for the logged-in user.
* Requires the user to have 2FA enabled and a phone number set.
*/exportconstrequestOtp = async () => {
requireAuth() // Ensure user is logged inconst userId = context.currentUser.idconst user = await db.user.findUnique({ where: { id: userId } })
if (!user) {
thrownewError('User not found.') // Should not happen if requireAuth passes
}
if (!user.twoFactorEnabled || !user.phoneNumber) {
thrownewError('Two-factor authentication is not enabled or phone number is missing.')
}
// --- Basic Rate Limiting Check (Example) ---// Production systems *must* implement more robust IP-based and user-based rate limiting// (e.g., using Redis with leaky/token bucket algorithms, or platform-specific middleware)// to prevent SMS toll fraud and abuse. This example only checks expiry.if (user.otpExpiresAt && user.otpExpiresAt > newDate()) {
const secondsRemaining = Math.ceil((user.otpExpiresAt.getTime() - Date.now()) / 1000);
logger.warn({ custom: { userId } }, `OTP request denied. Previous OTP still valid for ${secondsRemaining} seconds.`);
thrownewError(`Please wait ${secondsRemaining} seconds before requesting a new code.`);
}
// --- End Rate Limiting Check ---const otpCode = generateOtpCode(OTP_LENGTH)
const expiresAt = newDate(Date.now() + OTP_VALIDITY_MINUTES * 60 * 1000)
try {
// 1. Send the plain OTP via SMS firstawaitsendOtpSms(user.phoneNumber, otpCode)
// 2. Hash the OTP for secure storageconst hashedOtp = await bcrypt.hash(otpCode, BCRYPT_SALT_ROUNDS)
// 3. Store the HASHED OTP and expiry in the databaseawait db.user.update({
where: { id: userId },
data: {
otpSecret: hashedOtp, // Store the hash, not the plain codeotpExpiresAt: expiresAt,
otpAttempts: 0, // Reset attempts on new code generation
},
})
logger.info({ custom: { userId } }, 'OTP requested successfully.')
// Return limited info to the clientreturn { success: true, message: `Verification code sent to ${user.phoneNumber.slice(0, -4)}****.` }
} catch (error) {
logger.error({ err: error, custom: { userId } }, 'Error during OTP request process.')
// Avoid clearing DB fields here, as the user might still need to retry.// Let expiry handle cleanup eventually.thrownewError('Failed to request verification code. Please try again.')
}
}
/**
* Mutation: Verifies the OTP code submitted by the user.
*/interfaceVerifyOtpInput {
otpCode: string
}
exportconstverifyOtp = async ({ otpCode }: VerifyOtpInput) => {
requireAuth()
const userId = context.currentUser.idif (!otpCode || otpCode.length !== OTP_LENGTH || !/^\d+$/.test(otpCode)) {
thrownewError('Invalid OTP format.');
}
const user = await db.user.findUnique({ where: { id: userId } })
if (!user || !user.twoFactorEnabled) {
thrownewError('User not found or 2FA not enabled.')
}
// otpSecret now holds the HASHif (!user.otpSecret || !user.otpExpiresAt) {
thrownewError('No active OTP request found. Please request a new code.')
}
// --- Attempt Limiting ---if (user.otpAttempts >= MAX_OTP_ATTEMPTS) {
logger.warn({ custom: { userId } }, 'Max OTP attempts exceeded.');
// Clear the current OTP to force requesting a new one after too many failuresawait db.user.update({
where: { id: userId },
data: { otpSecret: null, otpExpiresAt: null, otpAttempts: null },
});
thrownewError('Maximum verification attempts exceeded. Please request a new code.');
}
// --- Check Expiry ---if (newDate() > user.otpExpiresAt) {
logger.warn({ custom: { userId } }, 'OTP expired attempt.');
await db.user.update({
where: { id: userId },
data: {
otpSecret: null, // Clear expired OTP hashotpExpiresAt: null,
otpAttempts: (user.otpAttempts || 0) + 1, // Still count as an attempt
},
});
thrownewError('Verification code has expired. Please request a new one.')
}
// --- Verify Code using bcrypt ---const isValid = await bcrypt.compare(otpCode, user.otpSecret)
if (isValid) {
// Success! Clear OTP fieldsawait db.user.update({
where: { id: userId },
data: {
otpSecret: null,
otpExpiresAt: null,
otpAttempts: null, // Reset attempts on success
},
})
logger.info({ custom: { userId } }, 'OTP verified successfully.')
// Note: Redwood's default dbAuth session cookie isn't typically modified// by this 2FA verification step. Application access control after login// relies on the frontend flow (redirecting to OTP page, then away upon success).// If persistent 2FA status within the session is needed, session handling// would need customization (more advanced).return { success: true, message: 'Verification successful.' }
} else {
// Invalid codeawait db.user.update({
where: { id: userId },
data: {
otpAttempts: (user.otpAttempts || 0) + 1,
},
})
logger.warn({ custom: { userId, attempts: (user.otpAttempts || 0) + 1 } }, 'Invalid OTP code entered.')
thrownewError('Invalid verification code.')
}
}
// Add other potential functions like disableTwoFactorAuth if needed
Explanation:
Initialization:SNSClient and bcrypt are imported. Constants are defined.
generateOtpCode: Uses Node's crypto.randomInt for stronger random number generation compared to Math.random().
Includes a basic expiry-based rate limit check. Emphasizes the critical need for robust IP/user-based rate limiting in production.
Generates the plain otpCode.
Sends the plain otpCode via SMS.
Hashes the otpCode using bcrypt.
Stores the hashed OTP (otpSecret) and expiry in the database, resetting attempts.
Returns a success message.
verifyOtp (Mutation):
Authenticates user, validates input format.
Retrieves user and checks for an active OTP hash (otpSecret).
Enforces MAX_OTP_ATTEMPTS.
Checks for OTP expiry.
Uses bcrypt.compare to securely compare the submitted plain otpCode against the stored otpSecret hash.
Clears OTP fields on success.
Increments attempts on failure.
Includes comments on session state implications.
3. Building the API Layer (GraphQL)
Define the GraphQL schema for the OTP mutations.
3.1 Update GraphQL Schema:
Open api/src/graphql/otp.sdl.ts and define the mutations and types:
GraphQL
// api/src/graphql/otp.sdl.ts
export const schema= gql`
type OtpResponse {success: Boolean!message: String
}typeMutation{"""
Requests an OTP code to be sent via SMS to the user's registered phone number.
Requires user to be authenticated and have 2FA enabled.
"""requestOtp: OtpResponse!@requireAuth"""
Verifies the OTP code submitted by the user against the stored hash.
Requires user to be authenticated.
"""
verifyOtp(otpCode: String!): OtpResponse!@requireAuth}
`
Explanation:
OtpResponse: A simple type to return success status and a message.
requestOtp: Mutation to trigger the OTP sending process. Protected by @requireAuth.
verifyOtp: Mutation to submit the OTP code for verification. Takes otpCode as input. Protected by @requireAuth. Uses standard """ for docstrings.
3.2 Add Service to GraphQL Handler:
Ensure the otp service is included in your GraphQL handler. Redwood usually does this automatically via services import globbing in api/src/functions/graphql.ts, but double-check:
TypeScript
// api/src/functions/graphql.ts// ... other importsimport services from'src/services/**/*.{js,ts}'// Ensure this line includes your serviceexportconst handler = createGraphQLHandler({
// ... other configloggerConfig: { logger, options: {} },
directives,
sdls,
services, // Make sure 'services' is passed here// ... onException
})
4. Integrating Third-Party Services (AWS SNS)
This section was largely covered during setup and implementation. Key points:
Configuration: AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) and AWS_REGION must be in .env.
Secure Handling: Use environment variables; never hardcode credentials. Ensure your .env file is in .gitignore. In production, use secret management tools provided by your deployment platform (e.g., Vercel Environment Variables, Netlify Build Environment Variables, AWS Secrets Manager).
IAM Permissions: Use the principle of least privilege. The IAM user only needs sns:Publish permission for the specific SNS topic or phone numbers if possible, not AmazonSNSFullAccess, for production.
AWS Console:
IAM: AWS Console -> IAM -> Users -> Add User / Manage Security Credentials.
SNS: AWS Console -> Simple Notification Service. Monitor usage and configure settings (like delivery status logging) here. Check SNS quotas and SMS pricing for your region.
Sender ID: If using AWS_SNS_SENDER_ID, be aware of registration requirements per country (e.g., US 10DLC, India). Failure to register may result in delivery issues or use of a generic number.
5. Error Handling, Logging, and Retries
Error Handling: The otp.ts service includes try...catch blocks around database operations, hashing, and SNS calls. It throws user-friendly errors back to the client while logging detailed errors internally using src/lib/logger.
Logging: Redwood's built-in logger (src/lib/logger) is used. Configure LOG_LEVEL in .env. Logs appear in the console (dev) and deployment platform's service (prod). Include context (userId) in logs.
Retries (SNS): AWS SDK v3 has built-in retries for transient network errors/throttling from SNS. Custom retry logic for the SDK call is usually unnecessary.
Retries (User): The requestOtp logic includes basic rate limiting. The "Resend Code" frontend feature handles user retries. verifyOtp handles invalid attempts and expiry.
6. Database Schema and Data Layer
Covered in Section 1.
Schema:api/db/schema.prisma defines the User model with 2FA fields (otpSecret stores the hash).
Migrations:yarn rw prisma migrate dev applies schema changes.
Data Access: Redwood services use Prisma Client (src/lib/db).
Performance:
Indexes on @id and @unique fields are automatic.
Consider separating OTP data (otpSecret, otpExpiresAt, otpAttempts) to Redis/Memcached for very high-traffic apps to reduce User table contention (adds complexity).
Optimize queries if needed.
7. Adding Security Features
Input Validation:
Phone numbers validated for E.164 format (otp.ts).
OTP codes validated for length/numeric format (otp.ts).
GraphQL layer provides type validation. Add custom service-level validation.
Rate Limiting:Absolutely critical for requestOtp (prevents SMS cost abuse/toll fraud) and verifyOtp (prevents brute-force).
The example has minimal rate limiting.
Production Requirement: Implement robust IP-based and/or user-based rate limiting. Use middleware (express-rate-limit if applicable), platform features (Vercel/Netlify rate limiting), or external stores like Redis (leaky/token bucket algorithms).
Brute Force Protection:otpAttempts field and MAX_OTP_ATTEMPTS constant in verifyOtp. Consider account locking or CAPTCHAs after excessive failures.
Secret Management: AWS keys via environment variables (Section 4).
OTP Security:
Hashing:Implemented using bcrypt to store OTP hashes (otpSecret) securely. Comparison uses bcrypt.compare.
Expiry: Short validity period (OTP_VALIDITY_MINUTES).
Length: Standard 6-digit length.
Secure Generation: Uses Node's crypto.randomInt for cryptographically stronger random numbers.
CSRF Protection: Redwood includes CSRF protection; verify your setup.
We need pages/components to manage and verify 2FA.
8.1 Add Phone Number & Enable 2FA Component:
Create a component for users to manage their 2FA settings.
Prerequisites: Accessible only to logged-in users.
Functionality: Input phone number (E.164), enable/disable 2FA buttons.
Example Component Snippet (web/src/components/TwoFactorSettingsCell/TwoFactorSettingsCell.tsx):
TSX
// web/src/components/TwoFactorSettingsCell/TwoFactorSettingsCell.tsx// Generate this with `yarn rw g cell TwoFactorSettings`importtype { FindTwoFactorSettingsQuery, FindTwoFactorSettingsQueryVariables } from'types/graphql'importtype { CellSuccessProps, CellFailureProps } from'@redwoodjs/web'import { Form, TextField, Submit, FieldError, Label, useForm } from'@redwoodjs/forms'import { useMutation } from'@redwoodjs/web'import { toast } from'@redwoodjs/web/toast'import { useState } from'react'// GraphQL Query to fetch user's current 2FA status// NOTE: Assumes you have a query named `getCurrentUserSettings` in your SDL/Service.exportconstQUERY = gql`query FindTwoFactorSettingsQuery {twoFactorSettings: getCurrentUserSettings {
id
phoneNumber
twoFactorEnabled
}}
`// --- Mutations ---// NOTE: Assumes you create these mutations in your SDL and Service (see example snippets below).constENABLE_2FA_MUTATION = gql`mutation EnableTwoFactorAuthMutation($phoneNumber: String!){
enableTwoFactorAuth(phoneNumber:$phoneNumber) {
success
message
user {# Return updated user data
id
phoneNumber
twoFactorEnabled
}}}
`constDISABLE_2FA_MUTATION = gql`mutation DisableTwoFactorAuthMutation {
disableTwoFactorAuth {
success
message
user {
id
phoneNumber
twoFactorEnabled
}}}
`// --- End Mutations ---exportconstLoading = () => <div>Loading...</div>exportconstEmpty = () => <div>User settings not found.</div>exportconstFailure = ({ error }: CellFailureProps<FindTwoFactorSettingsQueryVariables>) => (
<divstyle={{color: 'red' }}>Error: {error?.message}</div>
)
exportconstSuccess = ({ twoFactorSettings }: CellSuccessProps<FindTwoFactorSettingsQuery, FindTwoFactorSettingsQueryVariables>) => {
const [phoneNumber, setPhoneNumber] = useState(twoFactorSettings.phoneNumber || '')
const formMethods = useForm()
const [enable2FA, { loading: enableLoading }] = useMutation(ENABLE_2FA_MUTATION, {
onCompleted: (data) => {
if (data.enableTwoFactorAuth.success) {
toast.success(data.enableTwoFactorAuth.message || '2FA Enabled!')
setPhoneNumber(data.enableTwoFactorAuth.user.phoneNumber) // Update local state// QUERY refetch will update the cell state automatically
} else {
toast.error(data.enableTwoFactorAuth.message || 'Failed to enable 2FA.')
}
},
onError: (error) => {
toast.error(error.message)
},
// Refetch the user settings after mutationrefetchQueries: [{ query: QUERY }],
awaitRefetchQueries: true,
})
const [disable2FA, { loading: disableLoading }] = useMutation(DISABLE_2FA_MUTATION, {
onCompleted: (data) => {
if (data.disableTwoFactorAuth.success) {
toast.success(data.disableTwoFactorAuth.message || '2FA Disabled!')
setPhoneNumber('') // Clear local state
} else {
toast.error(data.disableTwoFactorAuth.message || 'Failed to disable 2FA.')
}
},
onError: (error) => {
toast.error(error.message)
},
refetchQueries: [{ query: QUERY }],
awaitRefetchQueries: true,
})
constonEnableSubmit = (data) => {
// Basic client-side format check (more robust validation server-side)if (!data.phoneNumber || !data.phoneNumber.match(/^\+[1-9]\d{1,14}$/)) {
toast.error('Please enter a valid phone number in E.164 format (e.g., +15551234567).')
return;
}
enable2FA({ variables: { phoneNumber: data.phoneNumber } })
}
constonDisableClick = () => {
if(confirm('Are you sure you want to disable Two-Factor Authentication?')) {
disable2FA()
}
}
return (
<div><h2>Two-Factor Authentication (2FA)</h2>
{twoFactorSettings.twoFactorEnabled ? (
<div><p>Status: <strongstyle={{color: 'green'}}>Enabled</strong></p><p>Registered Phone: {twoFactorSettings.phoneNumber}</p><buttononClick={onDisableClick}disabled={disableLoading}>
{disableLoading ? 'Disabling...' : 'Disable 2FA'}
</button></div>
) : (
<div><p>Status: <strongstyle={{color: 'red'}}>Disabled</strong></p><p>Enable 2FA by adding your phone number below. A verification code will be sent via SMS for login confirmation.</p><FormonSubmit={onEnableSubmit}formMethods={formMethods}><Labelname=""phoneNumber"" errorClassName=""error"">Phone Number (E.164 format: +1...)</Label><TextFieldname=""phoneNumber""
placeholder=""+15551234567""
validation={{required:true, pattern: { value: /^\+[1-9]\d{1,14}$/, message: ""InvalidE.164format""} }}
errorClassName=""error""
defaultValue={phoneNumber} // Usestateforcontrolledcomponentifpreferred
/><FieldErrorname=""phoneNumber"" className=""error"" /><Submitdisabled={enableLoading}>
{enableLoading ? 'Enabling...' : 'Enable 2FA'}
</Submit></Form></div>
)}
</div>
)
}
// --- IMPORTANT: Backend Implementation Required ---/*
The above frontend component relies on GraphQL queries and mutations
(`getCurrentUserSettings`, `enableTwoFactorAuth`, `disableTwoFactorAuth`)
that you need to implement on the backend (API side).
Follow the same pattern used for the OTP service/SDL. Here are example snippets:
// api/src/graphql/users.sdl.ts (o
Frequently asked questions
Set up 2FA in RedwoodJS by first installing required dependencies like the AWS SDK for SNS and bcrypt. Then, modify your Prisma schema to include fields for phone numbers, 2FA status, OTP details, and update your database. Finally, configure environment variables for your AWS credentials and region in your .env file. Remember to apply database migrations after schema changes.
AWS SNS (Simple Notification Service) is used to deliver One-Time Passwords (OTPs) via SMS messages to users' registered phone numbers as part of the two-factor authentication process. This ensures secure access by verifying user identity.
bcrypt is essential for securely storing OTPs. It hashes the generated OTP codes before saving them to the database, preventing storage of sensitive information in plain text. This protects user accounts in case of a data breach.
Request production access for AWS SNS before deploying your RedwoodJS application to real users. New AWS accounts default to a sandbox environment, restricting SMS messages to verified numbers within your account. Production access enables you to send OTPs to any valid phone number.
Yes, you can customize the SMS sender ID using the AWS_SNS_SENDER_ID environment variable in your RedwoodJS app. However, using a custom sender ID often requires registering with authorities in specific countries to ensure compliance. If omitted, a generic number may be used.
Use Node.js's crypto.randomInt function within your RedwoodJS OTP service to generate cryptographically secure random numbers for your OTP codes. This method is preferred over Math.random() for enhanced security in sensitive operations.
dbAuth in RedwoodJS provides the primary authentication layer (username/password login). The OTP-based 2FA adds an additional security layer on top of dbAuth, triggered only after successful password authentication, if the user has 2FA enabled.
Implement robust rate limiting for OTP requests in RedwoodJS by integrating mechanisms like IP-based or user-based limits. You can utilize middleware or external stores like Redis with algorithms such as leaky bucket or token bucket, preventing abuse and ensuring the security of the OTP process.
Use the E.164 format (+15551234567) for storing and validating phone numbers in RedwoodJS 2FA. This international standard format is crucial for compatibility with AWS SNS and ensures accurate OTP delivery.
Verify OTP codes securely using bcrypt.compare to check the user-submitted code against the stored bcrypt hash of the OTP. This prevents direct comparison of plain text codes and protects against security vulnerabilities.
If a user exceeds the maximum OTP attempts (as defined by MAX_OTP_ATTEMPTS in your OTP service), their current OTP is cleared, requiring them to request a new one. This mitigates brute-force attacks.
Hashing is crucial for OTP security as it transforms the OTP into a unique, irreversible representation, preventing the storage of the actual OTP in the database. Using bcrypt for hashing adds a layer of protection against potential data breaches, ensuring that even if compromised, the OTPs remain unreadable.
Handle OTP expiry in RedwoodJS by setting an expiration time (otpExpiresAt) when generating the OTP. During verification, check if the current time is past the expiration; if so, invalidate the OTP, clear relevant fields, and prompt the user to request a new one.
This guide provides a step-by-step walkthrough for integrating AWS Simple Notification Service (SNS) to send One-Time Passwords (OTPs) via SMS, adding a layer of Two-Factor Authentication (2FA) to a RedwoodJS application using the built-in
dbAuthprovider.We'll build a system where users, after logging in with their password, must verify their identity using a code sent to their registered phone number if they have 2FA enabled.
Project Goals:
dbAuthfor primary authentication.Technology Stack:
dbAuth@aws-sdk/client-sns)System Architecture:
Prerequisites:
Final Outcome:
A RedwoodJS application where users can optionally enable SMS-based 2FA. Authenticated users with 2FA enabled will be prompted for an OTP code after password login before gaining full access.
1. Setting up the Project
We'll start with a new RedwoodJS project and set up
dbAuth. If you have an existing project withdbAuth, you can adapt these steps.1.1 Create RedwoodJS App (if needed):
Open your terminal and run:
yarn create redwood-app ./redwood-sns-otp cd redwood-sns-otp1.2 Setup dbAuth:
Redwood's
dbAuthprovides the foundation for user authentication (signup, login, password management).This command modifies your schema, adds API functions, and generates web-side pages for authentication.
1.3 Modify Database Schema:
We need to add fields to the
Usermodel to support 2FA. Openapi/db/schema.prisma:Explanation:
phoneNumber: Stores the user's verified phone number for OTP delivery (must be unique). Storing in E.164 format is crucial for SNS.twoFactorEnabled: A flag indicating if the user has enabled 2FA.otpSecret: Stores a secure hash (e.g., bcrypt) of the generated OTP. Never store the plain OTP.otpExpiresAt: Timestamp indicating when the generated OTP becomes invalid.otpAttempts: Tracks failed verification attempts to prevent brute-force attacks.1.4 Apply Database Migrations:
Run the Prisma migration command to apply schema changes to your database:
This creates a new migration file and updates your database schema.
1.5 Install Dependencies:
Install the necessary AWS SDK v3 package for SNS and
bcryptfor hashing:1.6 Configure Environment Variables:
Add AWS credentials and SNS configuration to your
.envfile. Never commit.envfiles with secrets to version control.Obtaining AWS Credentials:
redwood-sns-user). Select Access key - Programmatic access as the credential type.AmazonSNSFullAccesspolicy (for simplicity). For production, create a custom policy with least privilege, granting onlysns:Publishpermissions..envfile.AWS_REGIONto the AWS region where you want to operate SNS (e.g.,us-east-1).AWS_SNS_SENDER_IDis optional but recommended for branding. Note that using custom Sender IDs often requires registration with authorities in specific countries (like India, USA 10DLC). If omitted or unregistered, SNS might use a generic number.2. Implementing Core Functionality (OTP Logic)
We'll create services on the API side to handle OTP generation, sending, and verification.
2.1 Create OTP Service:
Generate a new service for OTP logic:
This creates
api/src/services/otp/otp.ts,otp.scenarios.ts, andotp.test.ts.2.2 Implement OTP Generation and Sending:
Open
api/src/services/otp/otp.tsand add the logic.// api/src/services/otp/otp.ts import crypto from 'crypto' // Use Node's crypto module for secure random generation import bcrypt from 'bcrypt' import { SNSClient, PublishCommand } from '@aws-sdk/client-sns' import type { Prisma } from '@prisma/client' import { db } from 'src/lib/db' import { requireAuth } from 'src/lib/auth' // Use Redwood's auth checker import { logger } from 'src/lib/logger' // Use Redwood's logger const OTP_LENGTH = 6 // Standard OTP length const OTP_VALIDITY_MINUTES = 5 // How long the OTP is valid const MAX_OTP_ATTEMPTS = 5 // Max verification attempts const BCRYPT_SALT_ROUNDS = 10 // Cost factor for bcrypt hashing // Initialize SNS Client (ensure AWS env vars are set) const snsClient = new SNSClient({ region: process.env.AWS_REGION, credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, }, }) /** * Generates a cryptographically secure random numeric string using Node's crypto module. */ const generateOtpCode = (length: number): string => { let otp = '' for (let i = 0; i < length; i++) { // crypto.randomInt is preferred over Math.random() for security-sensitive numbers otp += crypto.randomInt(0, 10).toString() } return otp } /** * Sends an OTP code via AWS SNS. */ const sendOtpSms = async (phoneNumber: string, otpCode: string) => { // Ensure E.164 format for SNS if (!phoneNumber || !phoneNumber.match(/^\+[1-9]\d{1,14}$/)) { logger.error({ custom: { phoneNumber } }, 'Invalid or missing phone number for SNS. Must be E.164.') throw new Error('Invalid phone number format. Must start with +countrycode.') } const brandName = process.env.AWS_SNS_SENDER_ID || 'YourApp' // Use sender ID or fallback const message = `Your ${brandName} verification code is: ${otpCode}. It expires in ${OTP_VALIDITY_MINUTES} minutes.` const params = { Message: message, PhoneNumber: phoneNumber, // Optional: Add MessageAttributes for SenderID if registered and required // MessageAttributes: { // 'AWS.SNS.SMS.SenderID': { // 'DataType': 'String', // 'StringValue': process.env.AWS_SNS_SENDER_ID // }, // 'AWS.SNS.SMS.SMSType': { // 'DataType': 'String', // 'StringValue': 'Transactional' // Or 'Promotional' // } // } } try { logger.info({ custom: { phoneNumber } }, 'Attempting to send OTP via SNS') const command = new PublishCommand(params) const data = await snsClient.send(command) logger.info({ custom: { messageId: data.MessageId, phoneNumber } }, 'Successfully sent OTP via SNS') return data } catch (error) { logger.error({ err: error, custom: { phoneNumber } }, 'Failed to send OTP via SNS') // Handle specific SNS errors if needed (e.g., throttling, invalid number) throw new Error('Failed to send verification code. Please try again later.') } } /** * Mutation: Initiates the OTP request process for the logged-in user. * Requires the user to have 2FA enabled and a phone number set. */ export const requestOtp = async () => { requireAuth() // Ensure user is logged in const userId = context.currentUser.id const user = await db.user.findUnique({ where: { id: userId } }) if (!user) { throw new Error('User not found.') // Should not happen if requireAuth passes } if (!user.twoFactorEnabled || !user.phoneNumber) { throw new Error('Two-factor authentication is not enabled or phone number is missing.') } // --- Basic Rate Limiting Check (Example) --- // Production systems *must* implement more robust IP-based and user-based rate limiting // (e.g., using Redis with leaky/token bucket algorithms, or platform-specific middleware) // to prevent SMS toll fraud and abuse. This example only checks expiry. if (user.otpExpiresAt && user.otpExpiresAt > new Date()) { const secondsRemaining = Math.ceil((user.otpExpiresAt.getTime() - Date.now()) / 1000); logger.warn({ custom: { userId } }, `OTP request denied. Previous OTP still valid for ${secondsRemaining} seconds.`); throw new Error(`Please wait ${secondsRemaining} seconds before requesting a new code.`); } // --- End Rate Limiting Check --- const otpCode = generateOtpCode(OTP_LENGTH) const expiresAt = new Date(Date.now() + OTP_VALIDITY_MINUTES * 60 * 1000) try { // 1. Send the plain OTP via SMS first await sendOtpSms(user.phoneNumber, otpCode) // 2. Hash the OTP for secure storage const hashedOtp = await bcrypt.hash(otpCode, BCRYPT_SALT_ROUNDS) // 3. Store the HASHED OTP and expiry in the database await db.user.update({ where: { id: userId }, data: { otpSecret: hashedOtp, // Store the hash, not the plain code otpExpiresAt: expiresAt, otpAttempts: 0, // Reset attempts on new code generation }, }) logger.info({ custom: { userId } }, 'OTP requested successfully.') // Return limited info to the client return { success: true, message: `Verification code sent to ${user.phoneNumber.slice(0, -4)}****.` } } catch (error) { logger.error({ err: error, custom: { userId } }, 'Error during OTP request process.') // Avoid clearing DB fields here, as the user might still need to retry. // Let expiry handle cleanup eventually. throw new Error('Failed to request verification code. Please try again.') } } /** * Mutation: Verifies the OTP code submitted by the user. */ interface VerifyOtpInput { otpCode: string } export const verifyOtp = async ({ otpCode }: VerifyOtpInput) => { requireAuth() const userId = context.currentUser.id if (!otpCode || otpCode.length !== OTP_LENGTH || !/^\d+$/.test(otpCode)) { throw new Error('Invalid OTP format.'); } const user = await db.user.findUnique({ where: { id: userId } }) if (!user || !user.twoFactorEnabled) { throw new Error('User not found or 2FA not enabled.') } // otpSecret now holds the HASH if (!user.otpSecret || !user.otpExpiresAt) { throw new Error('No active OTP request found. Please request a new code.') } // --- Attempt Limiting --- if (user.otpAttempts >= MAX_OTP_ATTEMPTS) { logger.warn({ custom: { userId } }, 'Max OTP attempts exceeded.'); // Clear the current OTP to force requesting a new one after too many failures await db.user.update({ where: { id: userId }, data: { otpSecret: null, otpExpiresAt: null, otpAttempts: null }, }); throw new Error('Maximum verification attempts exceeded. Please request a new code.'); } // --- Check Expiry --- if (new Date() > user.otpExpiresAt) { logger.warn({ custom: { userId } }, 'OTP expired attempt.'); await db.user.update({ where: { id: userId }, data: { otpSecret: null, // Clear expired OTP hash otpExpiresAt: null, otpAttempts: (user.otpAttempts || 0) + 1, // Still count as an attempt }, }); throw new Error('Verification code has expired. Please request a new one.') } // --- Verify Code using bcrypt --- const isValid = await bcrypt.compare(otpCode, user.otpSecret) if (isValid) { // Success! Clear OTP fields await db.user.update({ where: { id: userId }, data: { otpSecret: null, otpExpiresAt: null, otpAttempts: null, // Reset attempts on success }, }) logger.info({ custom: { userId } }, 'OTP verified successfully.') // Note: Redwood's default dbAuth session cookie isn't typically modified // by this 2FA verification step. Application access control after login // relies on the frontend flow (redirecting to OTP page, then away upon success). // If persistent 2FA status within the session is needed, session handling // would need customization (more advanced). return { success: true, message: 'Verification successful.' } } else { // Invalid code await db.user.update({ where: { id: userId }, data: { otpAttempts: (user.otpAttempts || 0) + 1, }, }) logger.warn({ custom: { userId, attempts: (user.otpAttempts || 0) + 1 } }, 'Invalid OTP code entered.') throw new Error('Invalid verification code.') } } // Add other potential functions like disableTwoFactorAuth if neededExplanation:
SNSClientandbcryptare imported. Constants are defined.generateOtpCode: Uses Node'scrypto.randomIntfor stronger random number generation compared toMath.random().sendOtpSms:SNSClient.requestOtp(Mutation):otpCode.otpCodevia SMS.otpCodeusingbcrypt.otpSecret) and expiry in the database, resetting attempts.verifyOtp(Mutation):otpSecret).MAX_OTP_ATTEMPTS.bcrypt.compareto securely compare the submitted plainotpCodeagainst the storedotpSecrethash.3. Building the API Layer (GraphQL)
Define the GraphQL schema for the OTP mutations.
3.1 Update GraphQL Schema:
Open
api/src/graphql/otp.sdl.tsand define the mutations and types:// api/src/graphql/otp.sdl.ts export const schema = gql` type OtpResponse { success: Boolean! message: String } type Mutation { """ Requests an OTP code to be sent via SMS to the user's registered phone number. Requires user to be authenticated and have 2FA enabled. """ requestOtp: OtpResponse! @requireAuth """ Verifies the OTP code submitted by the user against the stored hash. Requires user to be authenticated. """ verifyOtp(otpCode: String!): OtpResponse! @requireAuth } `Explanation:
OtpResponse: A simple type to return success status and a message.requestOtp: Mutation to trigger the OTP sending process. Protected by@requireAuth.verifyOtp: Mutation to submit the OTP code for verification. TakesotpCodeas input. Protected by@requireAuth. Uses standard"""for docstrings.3.2 Add Service to GraphQL Handler:
Ensure the
otpservice is included in your GraphQL handler. Redwood usually does this automatically viaservicesimport globbing inapi/src/functions/graphql.ts, but double-check:// api/src/functions/graphql.ts // ... other imports import services from 'src/services/**/*.{js,ts}' // Ensure this line includes your service export const handler = createGraphQLHandler({ // ... other config loggerConfig: { logger, options: {} }, directives, sdls, services, // Make sure 'services' is passed here // ... onException })4. Integrating Third-Party Services (AWS SNS)
This section was largely covered during setup and implementation. Key points:
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY) andAWS_REGIONmust be in.env..envfile is in.gitignore. In production, use secret management tools provided by your deployment platform (e.g., Vercel Environment Variables, Netlify Build Environment Variables, AWS Secrets Manager).sns:Publishpermission for the specific SNS topic or phone numbers if possible, notAmazonSNSFullAccess, for production.AWS Console->IAM->Users->Add User/Manage Security Credentials.AWS Console->Simple Notification Service. Monitor usage and configure settings (like delivery status logging) here. Check SNS quotas and SMS pricing for your region.AWS_SNS_SENDER_ID, be aware of registration requirements per country (e.g., US 10DLC, India). Failure to register may result in delivery issues or use of a generic number.5. Error Handling, Logging, and Retries
otp.tsservice includestry...catchblocks around database operations, hashing, and SNS calls. It throws user-friendly errors back to the client while logging detailed errors internally usingsrc/lib/logger.src/lib/logger) is used. ConfigureLOG_LEVELin.env. Logs appear in the console (dev) and deployment platform's service (prod). Include context (userId) in logs.requestOtplogic includes basic rate limiting. The "Resend Code" frontend feature handles user retries.verifyOtphandles invalid attempts and expiry.6. Database Schema and Data Layer
Covered in Section 1.
api/db/schema.prismadefines theUsermodel with 2FA fields (otpSecretstores the hash).yarn rw prisma migrate devapplies schema changes.src/lib/db).@idand@uniquefields are automatic.otpSecret,otpExpiresAt,otpAttempts) to Redis/Memcached for very high-traffic apps to reduce User table contention (adds complexity).7. Adding Security Features
otp.ts).otp.ts).@requireAuthprotects OTP mutations.requestOtp(prevents SMS cost abuse/toll fraud) andverifyOtp(prevents brute-force).express-rate-limitif applicable), platform features (Vercel/Netlify rate limiting), or external stores like Redis (leaky/token bucket algorithms).otpAttemptsfield andMAX_OTP_ATTEMPTSconstant inverifyOtp. Consider account locking or CAPTCHAs after excessive failures.bcryptto store OTP hashes (otpSecret) securely. Comparison usesbcrypt.compare.OTP_VALIDITY_MINUTES).crypto.randomIntfor cryptographically stronger random numbers.dbAuthprovides secure cookie-based sessions.8. Frontend Implementation (Web Side)
We need pages/components to manage and verify 2FA.
8.1 Add Phone Number & Enable 2FA Component:
Create a component for users to manage their 2FA settings.
Example Component Snippet (
web/src/components/TwoFactorSettingsCell/TwoFactorSettingsCell.tsx):// web/src/components/TwoFactorSettingsCell/TwoFactorSettingsCell.tsx // Generate this with `yarn rw g cell TwoFactorSettings` import type { FindTwoFactorSettingsQuery, FindTwoFactorSettingsQueryVariables } from 'types/graphql' import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web' import { Form, TextField, Submit, FieldError, Label, useForm } from '@redwoodjs/forms' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import { useState } from 'react' // GraphQL Query to fetch user's current 2FA status // NOTE: Assumes you have a query named `getCurrentUserSettings` in your SDL/Service. export const QUERY = gql` query FindTwoFactorSettingsQuery { twoFactorSettings: getCurrentUserSettings { id phoneNumber twoFactorEnabled } } ` // --- Mutations --- // NOTE: Assumes you create these mutations in your SDL and Service (see example snippets below). const ENABLE_2FA_MUTATION = gql` mutation EnableTwoFactorAuthMutation($phoneNumber: String!) { enableTwoFactorAuth(phoneNumber: $phoneNumber) { success message user { # Return updated user data id phoneNumber twoFactorEnabled } } } ` const DISABLE_2FA_MUTATION = gql` mutation DisableTwoFactorAuthMutation { disableTwoFactorAuth { success message user { id phoneNumber twoFactorEnabled } } } ` // --- End Mutations --- export const Loading = () => <div>Loading...</div> export const Empty = () => <div>User settings not found.</div> export const Failure = ({ error }: CellFailureProps<FindTwoFactorSettingsQueryVariables>) => ( <div style={{ color: 'red' }}>Error: {error?.message}</div> ) export const Success = ({ twoFactorSettings }: CellSuccessProps<FindTwoFactorSettingsQuery, FindTwoFactorSettingsQueryVariables>) => { const [phoneNumber, setPhoneNumber] = useState(twoFactorSettings.phoneNumber || '') const formMethods = useForm() const [enable2FA, { loading: enableLoading }] = useMutation(ENABLE_2FA_MUTATION, { onCompleted: (data) => { if (data.enableTwoFactorAuth.success) { toast.success(data.enableTwoFactorAuth.message || '2FA Enabled!') setPhoneNumber(data.enableTwoFactorAuth.user.phoneNumber) // Update local state // QUERY refetch will update the cell state automatically } else { toast.error(data.enableTwoFactorAuth.message || 'Failed to enable 2FA.') } }, onError: (error) => { toast.error(error.message) }, // Refetch the user settings after mutation refetchQueries: [{ query: QUERY }], awaitRefetchQueries: true, }) const [disable2FA, { loading: disableLoading }] = useMutation(DISABLE_2FA_MUTATION, { onCompleted: (data) => { if (data.disableTwoFactorAuth.success) { toast.success(data.disableTwoFactorAuth.message || '2FA Disabled!') setPhoneNumber('') // Clear local state } else { toast.error(data.disableTwoFactorAuth.message || 'Failed to disable 2FA.') } }, onError: (error) => { toast.error(error.message) }, refetchQueries: [{ query: QUERY }], awaitRefetchQueries: true, }) const onEnableSubmit = (data) => { // Basic client-side format check (more robust validation server-side) if (!data.phoneNumber || !data.phoneNumber.match(/^\+[1-9]\d{1,14}$/)) { toast.error('Please enter a valid phone number in E.164 format (e.g., +15551234567).') return; } enable2FA({ variables: { phoneNumber: data.phoneNumber } }) } const onDisableClick = () => { if(confirm('Are you sure you want to disable Two-Factor Authentication?')) { disable2FA() } } return ( <div> <h2>Two-Factor Authentication (2FA)</h2> {twoFactorSettings.twoFactorEnabled ? ( <div> <p>Status: <strong style={{color: 'green'}}>Enabled</strong></p> <p>Registered Phone: {twoFactorSettings.phoneNumber}</p> <button onClick={onDisableClick} disabled={disableLoading}> {disableLoading ? 'Disabling...' : 'Disable 2FA'} </button> </div> ) : ( <div> <p>Status: <strong style={{color: 'red'}}>Disabled</strong></p> <p>Enable 2FA by adding your phone number below. A verification code will be sent via SMS for login confirmation.</p> <Form onSubmit={onEnableSubmit} formMethods={formMethods}> <Label name=""phoneNumber"" errorClassName=""error"">Phone Number (E.164 format: +1...)</Label> <TextField name=""phoneNumber"" placeholder=""+15551234567"" validation={{ required: true, pattern: { value: /^\+[1-9]\d{1,14}$/, message: ""Invalid E.164 format""} }} errorClassName=""error"" defaultValue={phoneNumber} // Use state for controlled component if preferred /> <FieldError name=""phoneNumber"" className=""error"" /> <Submit disabled={enableLoading}> {enableLoading ? 'Enabling...' : 'Enable 2FA'} </Submit> </Form> </div> )} </div> ) } // --- IMPORTANT: Backend Implementation Required --- /* The above frontend component relies on GraphQL queries and mutations (`getCurrentUserSettings`, `enableTwoFactorAuth`, `disableTwoFactorAuth`) that you need to implement on the backend (API side). Follow the same pattern used for the OTP service/SDL. Here are example snippets: // api/src/graphql/users.sdl.ts (o