code examples

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

RedwoodJS SMS 2FA with Plivo: Complete OTP Implementation Guide

Build secure two-factor authentication in RedwoodJS using Plivo SMS API. Step-by-step tutorial covering OTP generation, Redis storage, GraphQL mutations, and React components for phone verification.

Secure your RedwoodJS application with SMS-based two-factor authentication (2FA) using the Plivo API. This comprehensive tutorial shows you how to implement RedwoodJS SMS 2FA with one-time password (OTP) verification, protecting user accounts even when passwords are compromised. Learn how to add an extra layer of security that reduces unauthorized access by up to 99.9% according to industry security reports.

You'll integrate Plivo's messaging API with RedwoodJS, configure Redis for temporary OTP storage, implement GraphQL mutations for sending and verifying codes, and build React components for the user interface. By the end, you'll have a production-ready 2FA system with proper security measures including rate limiting, brute-force protection, and secure credential management.

This guide provides a complete walkthrough for integrating SMS-based Two-Factor Authentication (2FA) into your RedwoodJS application using the Plivo Messaging API. Adding 2FA enhances security by requiring users to provide a One-Time Password (OTP) sent to their mobile device, significantly reducing the risk of unauthorized account access even if passwords are compromised.

We will build a system where, after initial login (e.g., username/password - implementation not covered here), users are prompted to enter an OTP sent via SMS. We'll cover setting up RedwoodJS, configuring Plivo, implementing API logic for sending and verifying OTPs using Redis for temporary storage, and building the necessary front-end components.

Technologies Used:

  • RedwoodJS: A full-stack JavaScript/TypeScript framework for building modern web applications. Its structure separates API and web concerns, making integration clear.
  • Plivo: A cloud communications platform providing SMS API capabilities needed to send OTP messages.
  • Node.js: The underlying runtime environment for RedwoodJS.
  • Prisma: The database toolkit used by RedwoodJS for schema definition and data access.
  • Redis: An in-memory data structure store used here for temporary storage of OTPs, ensuring they expire automatically.
  • GraphQL: Used by RedwoodJS for API communication between the web and API sides.

System Architecture:

text
[ User Browser ] <--> [ Redwood Web Side (Pages/Components) ]
       |                          |
       | (GraphQL Request)        | (GraphQL Mutation Call)
       V                          V
[ Redwood API Side (GraphQL Server + Services) ]
       |                          |
       | (Plivo API Call)         | (Redis Operations)
       V                          V
[ Plivo SMS API ] <---------> [ Redis Cache ]
       |
       V
[ User's Mobile Device (SMS) ]

Prerequisites:

  • Node.js: Version 18.x or later.
  • Yarn: Package manager (installed with Node.js or separately).
  • RedwoodJS CLI: Install globally: npm install -g redwoodjs@latest
  • Plivo Account: Sign up for a free trial at Plivo.com.
  • Plivo Phone Number: Purchase an SMS-enabled phone number through your Plivo console (Phone Numbers > Buy Numbers). Ensure it's capable of sending messages to your target regions.
  • Plivo API Credentials: Obtain your Auth ID and Auth Token from the Plivo console dashboard.
  • Redis Instance: A running Redis server accessible from your application (local or cloud-hosted). Get the connection URL (e.g., redis://localhost:6379).

Final Outcome:

By the end of this guide, your RedwoodJS application will have a functional SMS 2FA flow. Users associated with a phone number will be required to verify an OTP sent via Plivo SMS before gaining full access after their initial login step.

How Do You Set Up a RedwoodJS Project for SMS 2FA?

First, create a new RedwoodJS application if you don't have one already.

bash
# Choose TypeScript when prompted for robustness
yarn create redwood-app ./redwood-plivo-2fa
cd redwood-plivo-2fa

This command scaffolds a new RedwoodJS project with the necessary structure, including api and web sides.

How Do You Configure Plivo and Redis for OTP Delivery?

We need to install the necessary libraries and configure environment variables to securely store credentials.

1. Install Dependencies:

Install the Plivo Node helper library and the Redis client library on the API side.

bash
yarn workspace api add plivo ioredis

2. Configure Environment Variables:

RedwoodJS uses a .env file for environment variables. Create one in the project root if it doesn't exist. Add your Plivo credentials, Plivo phone number, and Redis connection URL.

plaintext
# .env

# Plivo Credentials (Get from Plivo Console Dashboard)
PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN

# Plivo SMS-enabled Phone Number (Must be in E.164 format, e.g., +14155551212)
# Replace this example with your actual Plivo number
PLIVO_PHONE_NUMBER=+1xxxxxxxxxx

# Redis Connection URL
# Use redis://localhost:6379 for local development
# Replace with your production/cloud Redis URL for deployment
REDIS_URL=redis://localhost:6379
  • PLIVO_AUTH_ID / PLIVO_AUTH_TOKEN: Found on your Plivo Console dashboard. Used to authenticate API requests.
  • PLIVO_PHONE_NUMBER: The SMS-enabled number you purchased in Plivo, in E.164 format (e.g., +14155551212). This will be the sender ID for OTP messages. Replace the example value.
  • REDIS_URL: The connection string for your Redis instance. The example redis://localhost:6379 is suitable for local development but must be replaced with your production Redis URL when deploying.

Important: Add .env to your .gitignore file to prevent accidentally committing sensitive credentials. RedwoodJS automatically loads these variables.

How Do You Modify the Database Schema for 2FA?

We'll assume you have a User model. We need to add fields to store the user's phone number (required for sending OTPs) and track their 2FA status.

1. Edit Schema:

Modify your api/db/schema.prisma file:

prisma
// api/db/schema.prisma

datasource db {
  provider = ""postgresql"" // Or your chosen provider
  url      = env(""DATABASE_URL"")
}

generator client {
  provider = ""prisma-client-js""
}

model User {
  id                  Int       @id @default(autoincrement())
  email               String    @unique
  hashedPassword      String?   // Assuming you have password auth
  salt                String?   // Assuming you have password auth
  resetToken          String?
  resetTokenExpiresAt DateTime?

  // --- Add these fields for 2FA ---
  phoneNumber         String?   @unique // Store in E.164 format (e.g., +14155551212)
  isTwoFactorEnabled  Boolean   @default(false)
  // We don't store the OTP itself in the DB, Redis handles temporary storage
  // --- End 2FA fields ---

  // Add other fields as needed
  createdAt           DateTime  @default(now())
  updatedAt           DateTime  @updatedAt
}
  • phoneNumber: Stores the user's verified phone number in E.164 format (e.g., +14155551212). Make it unique if needed.
  • isTwoFactorEnabled: A flag indicating if the user has successfully set up and enabled 2FA.

2. Apply Migrations:

Generate and apply the database migration:

bash
yarn rw prisma migrate dev --name add_2fa_fields

This command updates your database schema to include the new fields.

How Do You Implement the API Logic for OTP Verification?

Now, let's build the API logic for requesting and verifying OTPs.

1. Create Redis Client Utility:

Create a utility file to manage the Redis connection.

typescript
// api/src/lib/redis.ts
import Redis from 'ioredis'
import { logger } from 'src/lib/logger'

let redisInstance: Redis | null = null

export const getRedisClient = (): Redis => {
  if (!redisInstance) {
    try {
      if (!process.env.REDIS_URL) {
        throw new Error('REDIS_URL environment variable is not set.')
      }
      redisInstance = new Redis(process.env.REDIS_URL, {
        // Optional: Add more Redis options here if needed
        maxRetriesPerRequest: 3,
        lazyConnect: true, // Connect only when needed
      })

      redisInstance.on('error', (err) => {
        logger.error({ err }, 'Redis Client Error')
        // Optional: Implement logic to handle connection errors gracefully
      })

      redisInstance.on('connect', () => {
        logger.info('Connected to Redis successfully.')
      })

      // Attempt to connect explicitly to catch initial errors early if desired
      // redisInstance.connect().catch(err => logger.error({ err }, 'Initial Redis connection failed'));

    } catch (error) {
      logger.error({ error }, 'Failed to initialize Redis client')
      // Depending on your app's needs, you might throw the error
      // or return a mock/null client to prevent crashes
      throw error // Re-throw to indicate critical failure
    }
  }
  return redisInstance
}

// Function to generate OTP Redis key
export const getOtpRedisKey = (userId: number): string => {
  return `otp:${userId}`
}

// OTP expiry time in seconds (e.g., 5 minutes)
export const OTP_EXPIRY_SECONDS = 300

2. Create Plivo Client Utility:

Similarly, create a utility for the Plivo client.

typescript
// api/src/lib/plivo.ts
import { PlivoClient } from 'plivo'
import { logger } from './logger'

let plivoClientInstance: PlivoClient | null = null

export const getPlivoClient = (): PlivoClient => {
  if (!plivoClientInstance) {
    if (!process.env.PLIVO_AUTH_ID || !process.env.PLIVO_AUTH_TOKEN) {
      logger.error('Plivo Auth ID or Token missing in environment variables.')
      throw new Error(
        'Plivo credentials are not configured in environment variables.'
      )
    }
    try {
      plivoClientInstance = new PlivoClient(
        process.env.PLIVO_AUTH_ID,
        process.env.PLIVO_AUTH_TOKEN
      )
      logger.info('Plivo client initialized.')
    } catch (error) {
      logger.error({ error }, 'Failed to initialize Plivo client')
      throw error
    }
  }
  return plivoClientInstance
}

3. Generate GraphQL Schema Definition (SDL) and Service:

Use the Redwood generator to scaffold the GraphQL types and mutations, along with the service file.

bash
yarn rw g sdl twoFactorAuth

This command creates api/src/graphql/twoFactorAuth.sdl.ts and api/src/services/twoFactorAuth/twoFactorAuth.ts.

4. Define GraphQL Mutations:

Modify the generated SDL file (api/src/graphql/twoFactorAuth.sdl.ts) to define the mutations for requesting and verifying OTPs.

graphql
# api/src/graphql/twoFactorAuth.sdl.ts
export const schema = gql`
  type MutationResult {
    success: Boolean!
    message: String
  }

  type Mutation {
    """"""
    Requests an OTP to be sent via SMS to the user's registered phone number.
    Assumes user is already authenticated (e.g., via session).
    Requires a functional Redwood Auth setup.
    """"""
    requestOtp: MutationResult! @requireAuth

    """"""
    Verifies the OTP provided by the user.
    Assumes user is already authenticated.
    Requires a functional Redwood Auth setup.
    """"""
    verifyOtp(otp: String!): MutationResult! @requireAuth

    """"""
    Enables 2FA for the user after successful verification (e.g., during setup).
    Requires prior OTP verification within the same flow. (Logic handled in service)
    Requires a functional Redwood Auth setup.
    """"""
    enableTwoFactorAuth: MutationResult! @requireAuth
  }
`
  • @requireAuth: Ensures only authenticated users can call these mutations. Make sure your api/src/lib/auth.ts is configured correctly, providing currentUser in the context.

5. Implement Service Logic:

Now, implement the core logic in api/src/services/twoFactorAuth/twoFactorAuth.ts.

typescript
// api/src/services/twoFactorAuth/twoFactorAuth.ts
import { requireAuth } from 'src/lib/auth' // Make sure requireAuth populates context.currentUser
import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'
import { getPlivoClient } from 'src/lib/plivo'
import { getRedisClient, getOtpRedisKey, OTP_EXPIRY_SECONDS } from 'src/lib/redis'
import { RedwoodGraphQLError } from '@redwoodjs/graphql-server'

// Helper to generate a 6-digit OTP
const generateOtp = (): string => {
  return Math.floor(100000 + Math.random() * 900000).toString()
}

// Note: A functional Redwood Auth setup providing `context.currentUser` is a prerequisite for these resolvers.
export const twoFactorAuthResolvers = {
  Mutation: {
    requestOtp: async () => {
      requireAuth() // Ensures user is logged in and context.currentUser is available
      const userId = context.currentUser.id
      const redis = getRedisClient()
      const plivo = getPlivoClient()

      try {
        const user = await db.user.findUnique({ where: { id: userId } })

        if (!user || !user.phoneNumber) {
          throw new RedwoodGraphQLError(
            'User or phone number not found. Cannot send OTP.'
          )
        }

        // IMPORTANT: Implement rate limiting here for production!
        // Check last request time in Redis for userId/phoneNumber and block if too frequent.
        // This example does not include rate limiting code.

        const otp = generateOtp()
        const redisKey = getOtpRedisKey(userId)

        // Store OTP in Redis with expiry
        await redis.setex(redisKey, OTP_EXPIRY_SECONDS, otp)
        logger.info(`OTP generated and stored in Redis for user ${userId}`)

        // Send SMS via Plivo
        if (!process.env.PLIVO_PHONE_NUMBER) {
           throw new Error('PLIVO_PHONE_NUMBER environment variable is not set.')
        }

        const messageResponse = await plivo.messages.create(
          process.env.PLIVO_PHONE_NUMBER, // Sender ID (Your Plivo number)
          user.phoneNumber, // Destination (User's number in E.164)
          `Your verification code is: ${otp}` // Message text
          // Optional: Add more Plivo options like { log: true }
        )

        logger.info(
          `OTP SMS sent via Plivo for user ${userId}. Message UUID: ${messageResponse.messageUuid[0]}`
        )

        return { success: true, message: 'OTP sent successfully.' }
      } catch (error) {
        logger.error({ error, userId }, 'Failed to request OTP')
        // Provide a generic error to the client
        throw new RedwoodGraphQLError(
          'Failed to send OTP. Please try again later.'
        )
      }
    },

    verifyOtp: async (_root: unknown, { otp }: { otp: string }) => {
      requireAuth() // Ensures user context
      const userId = context.currentUser.id
      const redis = getRedisClient()

      try {
        const redisKey = getOtpRedisKey(userId)
        const storedOtp = await redis.get(redisKey)

        if (!storedOtp) {
          return { success: false, message: 'OTP expired or not found. Please request a new one.' }
        }

        // IMPORTANT: Implement brute-force protection here for production!
        // Track failed attempts in Redis for redisKey. After N failures, invalidate the OTP (e.g., redis.del(redisKey)).
        // This example does not include brute-force protection code.

        if (storedOtp === otp) {
          // OTP is correct
          // CRITICAL: Delete the OTP immediately after successful verification to prevent reuse
          await redis.del(redisKey)

          // Optional: Set a flag indicating successful verification for this session/flow,
          // useful if enableTwoFactorAuth needs to confirm recent verification.
          // Example: await redis.setex(`otp_verified:${userId}`, 600, 'true'); // 10 min window

          logger.info(`OTP verified successfully for user ${userId}`)
          return { success: true, message: 'OTP verified successfully.' }
        } else {
          // Invalid OTP
          logger.warn(`Invalid OTP attempt for user ${userId}`)
          // Increment failed attempt counter in Redis here (for brute-force protection)
          return { success: false, message: 'Invalid OTP provided.' }
        }
      } catch (error) {
        logger.error({ error, userId }, 'Failed to verify OTP')
        throw new RedwoodGraphQLError(
          'Failed to verify OTP. Please try again later.'
        )
      }
    },

    enableTwoFactorAuth: async () => {
        requireAuth(); // Ensures user context
        const userId = context.currentUser.id;
        const redis = getRedisClient(); // Needed only if checking verification flag

        try {
            // Optional but Recommended Security Check:
            // Ensure OTP was successfully verified very recently before enabling.
            // Requires setting a flag in Redis during verifyOtp success.
            // const verificationFlagKey = `otp_verified:${userId}`;
            // const isVerified = await redis.get(verificationFlagKey);
            // if (!isVerified) {
            //     logger.warn(`User ${userId} attempted to enable 2FA without recent OTP verification.`);
            //     return { success: false, message: 'Please verify your phone number with an OTP first.' };
            // }

            // Update user status in the database
            await db.user.update({
                where: { id: userId },
                data: { isTwoFactorEnabled: true },
            });

            // Optional: Clean up the verification flag from Redis if used
            // await redis.del(verificationFlagKey);

            logger.info(`2FA enabled successfully for user ${userId}`);
            return { success: true, message: 'Two-Factor Authentication enabled.' };
        } catch (error) {
            logger.error({ error, userId }, 'Failed to enable 2FA');
            throw new RedwoodGraphQLError('Failed to enable Two-Factor Authentication.');
        }
    },
  },
}

// Merge resolvers - Ensure this structure matches your setup if you have multiple services.
// If this is the only service, you might export directly or use Redwood's default merge.
// Example: export const services = { twoFactorAuth: twoFactorAuthResolvers }

Key points in the service:

  • Authentication: requireAuth() ensures only logged-in users access these functions. context.currentUser.id retrieves the user ID (requires a functional Redwood Auth setup).
  • OTP Generation: A simple 6-digit random number is generated.
  • Redis Storage: redis.setex(key, expiry, otp) stores the OTP with an expiration time (OTP_EXPIRY_SECONDS).
  • Plivo SMS: plivo.messages.create() sends the SMS. Ensure src (sender) and dst (destination) numbers are in E.164 format.
  • Verification: redis.get(key) retrieves the stored OTP. It's compared against the user's input.
  • Security: Crucially, the OTP is deleted from Redis (redis.del(key)) immediately after successful verification to prevent reuse.
  • Error Handling: try...catch blocks handle potential errors from Redis or Plivo, logging them and returning user-friendly errors via RedwoodGraphQLError.
  • Enabling 2FA: The enableTwoFactorAuth mutation updates the user's record in the database. It's recommended to add a check here to ensure verifyOtp was successful recently (e.g., by checking a temporary flag set in Redis during verifyOtp).
  • Missing Protections: Note that the example code does not include implementations for rate limiting (on requestOtp) or brute-force protection (on verifyOtp), which are essential for production security.

How Do You Build the Web Interface for OTP Challenge?

Now, let's create the user interface for the 2FA challenge.

1. Generate Page and Component:

bash
# Page to display the OTP input form
yarn rw g page TwoFactorChallenge /2fa-challenge

# Optional: Component for the OTP form itself
yarn rw g component OtpInputForm

2. Define GraphQL Mutations for the Web:

Create a file to define the GraphQL mutations that the web side will use. Redwood's build process will use these definitions to generate types.

graphql
# web/src/graphql/mutations/twoFactorAuthMutations.gql

# Matches the operation name used in the OtpInputForm component
mutation RequestOtpMutation {
  requestOtp {
    success
    message
  }
}

# Matches the operation name used in the OtpInputForm component
mutation VerifyOtpMutation($otp: String!) {
  verifyOtp(otp: $otp) {
    success
    message
  }
}

# If you have a separate flow for enabling 2FA after first verification
mutation EnableTwoFactorAuthMutation {
  enableTwoFactorAuth {
    success
    message
  }
}

3. Implement the OTP Input Component (OtpInputForm):

This component will contain the form elements.

typescript
// web/src/components/OtpInputForm/OtpInputForm.tsx
import { useState } from 'react'
import { Form, TextField, Submit, FieldError } from '@redwoodjs/forms'
import { useMutation, gql } from '@redwoodjs/web' // Import gql
import { toast } from '@redwoodjs/web/toast'
// import { navigate, routes } from '@redwoodjs/router' // Only needed if navigating from here

// Define the mutations using gql. Ensure the operation names match those in the .gql file
// Redwood's build process uses these definitions (especially from .gql files) to generate types,
// but we still pass the gql definition directly to useMutation here.
const REQUEST_OTP_MUTATION = gql`
  mutation RequestOtpMutation { # Matches name in .gql file
    requestOtp {
      success
      message
    }
  }
`

const VERIFY_OTP_MUTATION = gql`
  mutation VerifyOtpMutation($otp: String!) { # Matches name in .gql file
    verifyOtp(otp: $otp) {
      success
      message
    }
  }
`

interface OtpInputFormProps {
  phoneNumberHint?: string;
  onSuccess: () => void; // Callback on successful verification
}

const OtpInputForm = ({ phoneNumberHint, onSuccess }: OtpInputFormProps) => {
  const [otp, setOtp] = useState('');
  const [resendDisabled, setResendDisabled] = useState(false);

  const [verifyOtp, { loading: verifyLoading, error: verifyError }] = useMutation(
    VERIFY_OTP_MUTATION,
    {
      onCompleted: (data) => {
        if (data.verifyOtp.success) {
          toast.success('Verification Successful!');
          onSuccess(); // Call the success handler (e.g., navigate to dashboard)
        } else {
          toast.error(data.verifyOtp.message || 'Invalid OTP.');
        }
      },
      onError: (error) => {
        toast.error(error.message || 'Verification failed. Please try again.');
      },
    }
  );

  const [requestOtp, { loading: requestLoading, error: requestError }] = useMutation(
    REQUEST_OTP_MUTATION,
    {
      onCompleted: (data) => {
        if (data.requestOtp.success) {
          toast.success('New OTP sent.');
          setResendDisabled(true);
          // Re-enable resend after a delay (e.g., 60 seconds)
          setTimeout(() => setResendDisabled(false), 60000);
        } else {
          toast.error(data.requestOtp.message || 'Failed to send OTP.');
        }
      },
      onError: (error) => {
        toast.error(error.message || 'Failed to request OTP.');
      },
    }
  );

  const onSubmit = (data: { otp: string }) => {
    verifyOtp({ variables: { otp: data.otp } });
  };

  const handleResend = () => {
    if (!resendDisabled && !requestLoading) {
      requestOtp();
    }
  };

  return (
    <Form onSubmit={onSubmit} className=""space-y-4"">
      <h2 className=""text-xl font-semibold"">Enter Verification Code</h2>
      {phoneNumberHint && (
        <p className=""text-sm text-gray-600"">
          Enter the 6-digit code sent to your phone number ending in {phoneNumberHint}.
        </p>
      )}

      <div>
        <label htmlFor=""otp"" className=""block text-sm font-medium text-gray-700"">
          Verification Code
        </label>
        <TextField
          name=""otp""
          id=""otp""
          value={otp}
          onChange={(e) => setOtp(e.target.value)}
          maxLength={6}
          className=""mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm""
          validation={{
            required: { value: true, message: 'OTP is required' },
            pattern: {
              value: /^\d{6}$/,
              message: 'OTP must be 6 digits',
            },
          }}
        />
        <FieldError name=""otp"" className=""mt-1 text-xs text-red-600"" />
        {verifyError && <p className=""mt-1 text-xs text-red-600"">{verifyError.message}</p>}
      </div>

      <div className=""flex items-center justify-between"">
        <Submit
          disabled={verifyLoading || requestLoading}
          className=""inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50""
        >
          {verifyLoading ? 'Verifying...' : 'Verify Code'}
        </Submit>

        <button
          type=""button""
          onClick={handleResend}
          disabled={resendDisabled || requestLoading}
          className=""text-sm font-medium text-indigo-600 hover:text-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed""
        >
          {requestLoading ? 'Sending...' : resendDisabled ? 'Resend Code (wait 60s)' : 'Resend Code'}
        </button>
        {requestError && <p className=""mt-1 text-xs text-red-600"">{requestError.message}</p>}
      </div>
    </Form>
  );
};

export default OtpInputForm;

4. Implement the Challenge Page (TwoFactorChallengePage):

This page will display the OtpInputForm. You'll likely navigate to this page after a successful primary login if 2FA is enabled for the user.

typescript
// web/src/pages/TwoFactorChallengePage/TwoFactorChallengePage.tsx
import { MetaTags, useMutation, gql } from '@redwoodjs/web' // Added useMutation, gql if needed for initial request
import { navigate, routes } from '@redwoodjs/router'
import OtpInputForm from 'src/components/OtpInputForm/OtpInputForm'
// Import useAuth or obtain user details needed (like phone number hint)
import { useAuth } from 'src/auth' // Assuming you use Redwood's auth
import { toast } from '@redwoodjs/web/toast' // Added toast import
// import { useEffect } from 'react' // Add if using useEffect for initial request

// Define mutation if needed for initial request on page load
// const REQUEST_OTP_MUTATION = gql`
//   mutation RequestOtpMutation {
//     requestOtp {
//       success
//       message
//     }
//   }
// `

const TwoFactorChallengePage = () => {
  // --- Fetch user details to display hint ---
  // This is an EXAMPLE using Redwood's useAuth().
  // Adapt this logic based on YOUR specific authentication setup
  // to retrieve the logged-in user's phone number.
  const { currentUser } = useAuth()

  // Create a hint (e.g., last 4 digits) from the user's phone number
  // Handle cases where the number might not be available
  const phoneNumberHint = currentUser?.phoneNumber
    ? `****${currentUser.phoneNumber.slice(-4)}` // Example: Show last 4 digits
    : 'your registered number'; // Fallback message

  // If you are NOT using Redwood's built-in auth or currentUser isn't populated here,
  // you will need a different way to get the phone number hint (e.g., pass it during navigation).
  // const phoneNumberHint = '****1234'; // Remove this placeholder if using real logic

  const handleSuccess = () => {
    // Navigate to the main application area after successful 2FA
    // Replace 'dashboard' with your actual target route name
    toast.success('Login successful!'); // Provide feedback
    navigate(routes.dashboard())
  }

  // Optional: Trigger initial OTP request when page loads?
  // Usually, the requestOtp mutation should be triggered *before* navigating
  // to this page (e.g., immediately after password verification).
  // If not, you might need to call requestOtp here using useEffect and useMutation.
  // Example (if needed):
  // const [requestOtp] = useMutation(REQUEST_OTP_MUTATION);
  // useEffect(() => {
  //   requestOtp();
  // }, [requestOtp]);

  return (
    <>
      <MetaTags title=""Two Factor Challenge"" description=""Enter your verification code"" />

      <div className=""min-h-screen bg-gray-100 flex flex-col justify-center py-12 sm:px-6 lg:px-8"">
        <div className=""sm:mx-auto sm:w-full sm:max-w-md"">
          <h1 className=""mt-6 text-center text-3xl font-extrabold text-gray-900"">
            Two-Factor Authentication
          </h1>
        </div>

        <div className=""mt-8 sm:mx-auto sm:w-full sm:max-w-md"">
          <div className=""bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"">
            <OtpInputForm
              phoneNumberHint={phoneNumberHint}
              onSuccess={handleSuccess}
            />
          </div>
        </div>
      </div>
    </>
  )
}

export default TwoFactorChallengePage

Integration Points:

  • Login Flow: Modify your existing login logic (where you verify username/password). After successful primary authentication:
    1. Fetch the user's record, including isTwoFactorEnabled and phoneNumber.
    2. Check if user.isTwoFactorEnabled is true.
    3. If true:
      • Call the requestOtp mutation (API side).
      • On success, navigate the user to the TwoFactorChallengePage (/2fa-challenge). You might pass the phoneNumberHint as state during navigation if not easily accessible via useAuth on the challenge page.
    4. If false: Proceed directly to the main application area (e.g., dashboard).
  • 2FA Setup Flow (Enabling 2FA): This requires a separate UI, typically in user settings:
    1. User indicates they want to enable 2FA.
    2. Prompt for their phone number (if not already collected). Store it on the user record.
    3. Initiate phone verification: Call requestOtp.
    4. Present an OTP input form (similar to OtpInputForm).
    5. User enters OTP, call verifyOtp.
    6. If verifyOtp succeeds: Call the enableTwoFactorAuth mutation to set isTwoFactorEnabled = true in the database. Confirm success to the user.
    7. If verifyOtp fails: Show an error and allow retries/resend.

What Security Measures Should You Implement?

  • Rate Limiting: Crucial. Implement rate limiting on the requestOtp mutation (API side) to prevent SMS Pumping fraud and abuse. Use Redis to track request timestamps per user ID or phone number and block excessive requests within a time window (e.g., max 1 request per 60 seconds, max 5 requests per hour). The provided code lacks this.
  • OTP Expiry: Use a short expiry time for OTPs (e.g., 5 minutes), enforced by Redis setex. Clearly communicate the expiry time to the user.
  • Brute Force Protection: Crucial. Limit the number of verifyOtp attempts for a given OTP/user. Track failed attempts in Redis (e.g., increment a counter with TTL). After N (e.g., 5) failures, invalidate the current OTP (redis.del(key)) and force the user to request a new one. Consider temporary account lockouts after repeated failures across multiple OTPs. The provided code lacks this.
  • Secure Credential Storage: Never hardcode API keys or tokens. Use environment variables (.env) and ensure .env is in .gitignore. Use your deployment platform's secret management.
  • Input Validation: Sanitize and validate all user inputs (phone numbers - E.164 format, OTP format - digits only, expected length) on both the client and server sides.
  • Session Management: Ensure robust session management. The 2FA verification step must be securely tied to the authenticated user's session. Redwood's auth setup helps here.
  • Phishing Awareness: Educate users never to share their OTPs. OTPs should only be entered on your legitimate website.
  • Use E.164 Format: Consistently use the E.164 format (+countrycodePhoneNumber) for phone numbers when storing them and when interacting with the Plivo API.

How Do You Handle Errors and Logging?

  • API Errors: The service code includes try...catch blocks. Log detailed errors (including Plivo/Redis errors) using Redwood's logger. Return generic, user-friendly errors via RedwoodGraphQLError to avoid leaking sensitive details.
  • Plivo Errors: Check Plivo's API response codes and error messages in their logs. Handle specific scenarios like insufficient funds, invalid number format, carrier filtering, or blocked numbers. Consult Plivo's documentation for error codes.
  • Redis Errors: Handle potential Redis connection issues or command errors gracefully. Log errors. If Redis is temporarily unavailable, the 2FA flow will likely fail; ensure this is handled cleanly.
  • Web Errors: Use onError callbacks in useMutation hooks to display user-friendly messages using toast or other UI elements. Log unexpected client-side errors.

How Do You Test Your 2FA Implementation?

  • Unit Tests (API): Use Jest (built into Redwood) to test the service functions (requestOtp, verifyOtp, enableTwoFactorAuth). Mock the Plivo client (getPlivoClient), Redis client (getRedisClient), Prisma (db), and authentication context (context.currentUser, requireAuth). Verify logic like OTP generation, Redis calls (setex, get, del), Plivo calls (messages.create), database updates, and return values under various success and failure conditions.

    typescript
    // Example structure for api/src/services/twoFactorAuth/twoFactorAuth.test.ts
    import { twoFactorAuthResolvers } from './twoFactorAuth' // Adjust import based on export
    import { getPlivoClient } from 'src/lib/plivo'
    import { getRedisClient, OTP_EXPIRY_SECONDS } from 'src/lib/redis'
    import { db } from 'src/lib/db'
    import { requireAuth } from 'src/lib/auth' // Mock this
    
    // --- Mocks ---
    jest.mock('src/lib/plivo');
    // ... rest of the test file structure
  • Integration Tests: Test the flow end-to-end, from the web component triggering the mutation, through the API service, interacting with (mocked) Plivo/Redis, and updating the database. Redwood's testing utilities can help here.

  • Web Component Tests: Test the OtpInputForm and TwoFactorChallengePage components using Jest and Redwood's testing setup (@redwoodjs/testing/web). Verify rendering, form input handling, validation, state changes, useMutation calls, and callbacks (onSuccess). Mock useAuth, navigate, toast, and useMutation.

To deepen your understanding of SMS authentication and RedwoodJS development, explore these related resources:

Frequently Asked Questions

How secure is SMS-based two-factor authentication?

SMS 2FA significantly improves security over passwords alone, reducing unauthorized access by up to 99.9%. However, it's vulnerable to SIM swapping and SMS interception. For maximum security, consider implementing app-based authenticators (TOTP) or hardware keys as alternatives or additional options alongside SMS.

What is the cost of implementing Plivo SMS 2FA?

Plivo charges per SMS sent, with pricing varying by destination country. Most regions cost between $0.0065-$0.02 per message. With proper rate limiting (preventing abuse) and assuming 2-3 OTP requests per successful login, costs typically range from $0.01-$0.06 per user login with 2FA enabled.

How do I handle users in countries where Plivo SMS doesn't work?

Implement fallback authentication methods like email-based OTP, authenticator apps (TOTP), or backup codes. Check Plivo's coverage before enabling 2FA for international users, and provide clear guidance on alternative verification methods in your UI.

Can I use this implementation with RedwoodJS dbAuth?

Yes, this tutorial is designed to integrate with RedwoodJS dbAuth. The requireAuth() directive and context.currentUser work seamlessly with dbAuth. You'll need to modify your login flow in the authentication service to trigger the OTP challenge after successful password verification.

How do I prevent SMS pumping fraud with Plivo?

Implement aggressive rate limiting (max 3 OTP requests per hour per user), monitor unusual patterns (same IP requesting OTPs for multiple numbers), use CAPTCHA before OTP requests, validate phone numbers against known patterns, and set up Plivo spending alerts. Track costs in your monitoring dashboard.

What's the best OTP expiry time for security and user experience?

5 minutes (300 seconds) balances security and usability well. Shorter times (2-3 minutes) improve security but may frustrate users with delayed SMS delivery. Longer times (10+ minutes) reduce security. Always allow users to request a new code if theirs expires.

How do I implement rate limiting for OTP requests in Redis?

Store a counter in Redis with a key like otp_requests:${userId}:${timeWindow} using INCR and set expiry with EXPIRE. Check the counter before sending OTP - if it exceeds your threshold (e.g., 5 per hour), reject the request. This prevents abuse and SMS pumping fraud.

Should I store phone numbers in E.164 format in the database?

Yes, always store phone numbers in E.164 format (+14155551212). This ensures compatibility with Plivo API, enables accurate validation, prevents formatting issues across regions, and simplifies internationalization. Validate and normalize numbers to E.164 before storing.

Frequently Asked Questions

how to implement 2fa in redwoodjs

Implement 2FA in RedwoodJS by integrating the Plivo Messaging API, configuring Redis for OTP storage, and creating necessary frontend components. This involves setting up environment variables, modifying your Prisma schema, implementing API logic for sending and verifying OTPs, and building the necessary web components.

what is plivo used for in 2fa

Plivo is a cloud communications platform that provides the SMS API capabilities for sending the One-Time Passwords (OTPs) to the user's mobile device during the Two-Factor Authentication process.

why use redis for 2fa otp storage

Redis, an in-memory data store, is used for temporary storage of OTPs due to its speed and automatic expiry feature. This enhances security by ensuring OTPs have a limited lifespan and are not persistently stored in a database.

when should 2fa be enabled for a user

2FA should be enabled after the user successfully verifies their phone number with an OTP. This ensures they control the provided number and are ready to use 2FA.

how to set up plivo for redwoodjs 2fa

Set up Plivo by creating an account, purchasing an SMS-enabled phone number, obtaining API credentials (Auth ID and Auth Token), and storing these, along with your Plivo phone number, as environment variables in your RedwoodJS project's .env file.

what is the role of prisma in redwoodjs 2fa

Prisma, RedwoodJS's database toolkit, is used to modify the database schema. You need to add `phoneNumber` and `isTwoFactorEnabled` fields to your User model to support 2FA functionality.

what redis connection url should I use

For local development, use `redis://localhost:6379`. In production, replace this with the connection string provided by your Redis hosting service. Make sure this URL is stored securely in your environment variables.

what are the prerequisites for redwoodjs 2fa

Prerequisites include Node.js v18+, Yarn, RedwoodJS CLI, a Plivo account with an SMS-enabled number and API credentials, and a running Redis instance (local or cloud-hosted).

how does the redwoodjs 2fa system work

The system sends an OTP via Plivo to the user's phone number after initial login. The user then enters this OTP on the website, which is verified against the value stored in Redis. Upon successful verification, the user is granted full access.

how to configure environment variables for plivo

Configure Plivo environment variables by adding `PLIVO_AUTH_ID`, `PLIVO_AUTH_TOKEN`, and `PLIVO_PHONE_NUMBER` (in E.164 format) to your `.env` file in the project root. Make sure to add `.env` to your `.gitignore` file.

how to handle 2fa errors in redwoodjs

Handle errors using try-catch blocks in your API service and onError callbacks in your web-side useMutation hooks. Log detailed errors server-side but provide generic, user-friendly error messages to the client for security.

why is rate limiting important for 2fa

Rate limiting on the requestOtp mutation is crucial to prevent SMS pumping attacks. This limits how frequently a user can request new OTPs, mitigating abuse.

how to protect against brute-force attacks on 2fa

Protect against brute-force attacks by tracking failed OTP verification attempts in Redis. After a set number of failed attempts, invalidate the OTP and potentially lock the user's account temporarily.

can I test the 2fa implementation in redwoodjs

Yes, you can test both the API and web sides of your 2FA implementation. Use Jest to create unit and integration tests, mocking external services like Plivo and Redis for isolated testing.