messaging channels

Sent logo
Sent TeamMar 8, 2026 / messaging channels / MessageBird

How to Implement Two-Factor Authentication (2FA) in RedwoodJS with MessageBird OTP Verification

Learn how to build secure SMS two-factor authentication in RedwoodJS using MessageBird Verify API. Step-by-step tutorial with phone number verification, OTP codes, Prisma database, and GraphQL mutations for Node.js applications.

Learn how to implement two-factor authentication (2FA) in your RedwoodJS application using MessageBird's Verify API for secure SMS-based phone number verification. This comprehensive tutorial walks you through building a production-ready OTP (One-Time Password) verification system from scratch, adding an essential security layer to protect user accounts from unauthorized access and fraudulent activity.

Note: MessageBird rebranded to Bird in 2023. This guide uses the legacy MessageBird Node.js SDK (messagebird npm package) and REST API (developers.messagebird.com/api/verify/), which remain available and functional as of 2024-2025. The SDK was last updated in 2022. For new projects, consider the modern Bird Verify API (docs.bird.com/api/verify-api), which uses workspace-based authentication and supports additional channels (Email, WhatsApp). The concepts and flow in this tutorial apply to both APIs, though implementation details differ.

We will cover setting up your RedwoodJS project, integrating the MessageBird Node.js SDK, creating the necessary API services and frontend components, handling user verification status in the database, implementing security best practices, and deploying the final application.

Why Implement Two-Factor Authentication with OTP?

Goal: Add SMS-based two-factor authentication (2FA) to your RedwoodJS application using MessageBird's Verify API. Phone number verification confirms users possess the mobile number they claim, providing critical security for user registration, login protection, account recovery, and sensitive transactions.

Security Benefits: Two-factor authentication prevents common attack vectors including account takeover attacks, credential stuffing, bot-driven fake registrations, and unauthorized access even when passwords are compromised. By requiring users to verify a code sent to their phone, you ensure account access requires both something they know (password) and something they have (mobile device).

Technologies:

  • RedwoodJS: A full-stack, serverless web application framework built on React, GraphQL, and Prisma. Chosen for its integrated structure, developer experience, and suitability for building modern web applications.
  • Node.js: The runtime environment for RedwoodJS's API side.
  • React: Used for building the frontend user interface within RedwoodJS.
  • Prisma: The ORM used by RedwoodJS for database interactions. We'll use it to store user verification status.
  • MessageBird Verify API: A service that handles the complexities of generating and sending OTPs via SMS (or voice) and verifying user-provided tokens. Chosen for its simplicity, reliability, and clear API.
  • MessageBird Node.js SDK: Simplifies interaction with the MessageBird API from our RedwoodJS backend.

Architecture:

mermaid
graph LR
    A[User Browser (React Frontend)] -- GraphQL Mutation --> B(RedwoodJS API);
    B -- Request Verification (Phone Number) --> C{MessageBird Verify API};
    C -- Sends OTP --> D[(User's Phone)];
    A -- Submits OTP Token & Verification ID --> B;
    B -- Confirm Verification (ID + Token) --> C;
    C -- Verification Status --> B;
    B -- Updates User Status --> E[Database (Prisma)];
    B -- Success/Error Response --> A;

    style B fill:#f9f,stroke:#333,stroke-width:2px
    style C fill:#ccf,stroke:#333,stroke-width:2px
    style E fill:#ff9,stroke:#333,stroke-width:2px

Prerequisites:

  • Node.js (=20.x as of RedwoodJS 2024-2025 requirements) and Yarn (>=1.22.21) installed.
  • A MessageBird account with API credentials.
  • Basic understanding of RedwoodJS, React, GraphQL, and Prisma.
  • A PostgreSQL (or other Prisma-compatible) database accessible.

Final Outcome: A RedwoodJS application with a page where users can:

  1. Enter their phone number.
  2. Receive an OTP via SMS from MessageBird.
  3. Enter the received OTP to verify their number.
  4. Have their verification status updated in the database.

1. Setting up Your RedwoodJS Project for OTP Verification

Before implementing SMS two-factor authentication, you'll need to set up a RedwoodJS project and configure MessageBird API credentials for sending verification codes.

1.1 Create RedwoodJS Project:

Open your terminal and run:

bash
yarn create redwood-app ./redwood-messagebird-otp
cd redwood-messagebird-otp

Follow the prompts. Choose JavaScript or TypeScript as preferred (examples will use JavaScript, but concepts apply to both). Select a database provider (PostgreSQL recommended).

1.2 Environment Setup:

RedwoodJS uses .env files for environment variables. Prisma also uses this file for the DATABASE_URL.

  • Ensure your DATABASE_URL in .env points to your accessible database. If you created a new project, Redwood sets this up initially. Example for PostgreSQL:

    dotenv
    DATABASE_URL="postgresql://user:password@host:port/database?schema=public"
  • Obtain MessageBird API Key:

    1. Log in to your MessageBird Dashboard.
    2. Navigate to the "Developers" section in the left sidebar.
    3. Click on the "API access (REST)" tab.
    4. If you don't have a live API key, create one. Note: Test keys might not work for sending actual SMS messages needed for verification.
    5. Copy your Live API Key.
  • Add MessageBird Key to .env: Add the following line to your .env file, replacing YOUR_LIVE_API_KEY with the key you copied:

    dotenv
    MESSAGEBIRD_API_KEY=YOUR_LIVE_API_KEY

    Purpose: This variable securely stores your MessageBird API key, making it accessible to your RedwoodJS API side via process.env without hardcoding it.

1.3 Install MessageBird SDK:

Navigate to the api directory and install the MessageBird Node.js SDK:

bash
cd api
yarn add messagebird
cd .. # Return to project root

1.4 Project Structure:

RedwoodJS provides a conventional structure:

  • api/: Backend code (GraphQL schema, services, functions, database).
  • web/: Frontend code (React components, pages, layouts).
  • scripts/: Utility scripts.
  • prisma/: Database schema and migrations.

We will primarily work within api/src/services, api/src/graphql, api/src/functions (or directly use GraphQL), web/src/pages, web/src/components, and prisma/.


2. Configuring the Database Schema for Phone Verification

To implement OTP verification, your database needs to track user phone numbers and verification status. We'll use Prisma ORM to define the schema and manage phone authentication data.

2.1 Define Prisma Schema:

Open prisma/schema.prisma. Assuming a basic User model, add fields for phone verification:

prisma
// prisma/schema.prisma

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

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

// Define your User model (or adapt your existing one)
model User {
  id             Int      @id @default(autoincrement())
  email          String   @unique // Example field
  hashedPassword String   // Example field for auth
  salt           String   // Example field for auth
  resetToken     String?
  resetTokenExpiresAt DateTime?

  // --- Fields for Phone Verification ---
  phoneNumber    String?  @unique // Store in E.164 format (e.g., +14155552671)
  phoneVerified  Boolean  @default(false) // Track if the number is verified
  // Optional: Add fields if implementing full 2FA enable/disable later
  // twoFactorSecret String?
  // twoFactorEnabled Boolean @default(false)
  // ------------------------------------

  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt
}
  • phoneNumber: Stores the user's phone number. Using the E.164 format is crucial for international compatibility with APIs like MessageBird. Make it optional (?) and unique initially, depending on your sign-up flow.
  • phoneVerified: A boolean flag indicating whether the phoneNumber has been successfully verified via OTP. Defaults to false.

2.2 Apply Migrations:

Generate and apply the database migration:

bash
yarn rw prisma migrate dev --name add_phone_verification_to_user

This command creates a new SQL migration file based on your schema changes and applies it to your development database.


3. Building the OTP Verification API Service

Create a RedwoodJS service to handle the core two-factor authentication logic: sending verification codes via MessageBird SMS API and validating user-submitted OTP tokens.

3.1 Create Verification Service:

Generate a new service:

bash
yarn rw g service verification

This creates api/src/services/verifications/verifications.js (and potentially test/scenario files).

3.2 Initialize MessageBird Client:

In api/src/services/verifications/verifications.js, import and initialize the MessageBird client:

javascript
// api/src/services/verifications/verifications.js
import { db } from 'src/lib/db'
import { requireAuth } from 'src/lib/auth' // Assuming you have auth set up
import { logger } from 'src/lib/logger'

// Import and initialize MessageBird SDK
import messagebirdCallback from 'messagebird'
const messagebird = messagebirdCallback(process.env.MESSAGEBIRD_API_KEY)

// Service functions will go here...
  • Why Initialize Here? Initializing the client within the service keeps the MessageBird-related logic encapsulated. We use process.env.MESSAGEBIRD_API_KEY read from our .env file.

3.3 Sending SMS Verification Codes with MessageBird:

Implement the requestVerification function to send OTP codes via SMS. This function validates phone numbers, calls the MessageBird Verify API, and returns a verification ID for the next step.

javascript
// api/src/services/verifications/verifications.js
// ... (imports and initialization) ...

export const requestVerification = async ({ phoneNumber }) => {
  // Basic validation (more robust validation recommended)
  if (!phoneNumber || !/^\+[1-9]\d{1,14}$/.test(phoneNumber)) {
    // E.164 format check
    throw new Error('Invalid phone number format. Please use E.164 format (e.g., +14155552671).')
  }

  // Optional: Check if number is already verified for the current user
  // requireAuth() // Ensure user is logged in if associating with an account
  // const userId = context.currentUser.id
  // const user = await db.user.findUnique({ where: { id: userId }})
  // if (user.phoneNumber === phoneNumber && user.phoneVerified) {
  //   throw new Error('Phone number already verified.')
  // }

  logger.info(`Requesting verification for phone number: ${phoneNumber}`)

  const params = {
    originator: 'VerifyApp', // Your App Name or Sender ID (check country restrictions)
    template: 'Your verification code is %token.',
    // type: 'sms', // Default is SMS, can use 'tts' for voice, 'flash' for flash SMS
    // tokenLength: 6, // Default is 6 (range: 6-10 characters)
    // timeout: 30, // Default is 30 seconds (range: 30-172801 seconds / 2 days) per legacy API
    // maxAttempts: 1, // Default is 1 in legacy API (range: 1-10). Note: New Bird API defaults to 3.
  }

  return new Promise((resolve, reject) => {
    messagebird.verify.create(phoneNumber, params, (err, response) => {
      if (err) {
        logger.error({ err }, 'MessageBird verify.create error')
        // Provide a user-friendly error
        let userMessage = 'Failed to send verification code. Please try again later.'
        if (err.errors) {
          // Try to extract a more specific error if available
          const specificError = err.errors[0]
          if (specificError.code === 21) { // Invalid phone number error code
            userMessage = 'The provided phone number is invalid.'
          } else {
            userMessage = `Failed to send code: ${specificError.description}`
          }
          // You might want to map more specific error codes
        }
        return reject(new Error(userMessage))
      }

      logger.info({ verificationId: response.id }, 'Verification request sent successfully')
      // IMPORTANT: Return only the ID to the frontend
      resolve({ verificationId: response.id })
    })
  })
}

// ... (other service functions) ...
  • Input Validation: Crucial for security and usability. We perform a basic E.164 format check. More complex validation (e.g., using libraries like google-libphonenumber) is recommended for production.
  • requireAuth(): If verification is tied to an existing logged-in user account (typical for 2FA setup), uncomment and use requireAuth() to ensure the user context is available. You'd then likely associate the phoneNumber with context.currentUser.id before or after verification.
  • MessageBird Params:
    • originator: The sender ID displayed on the user's phone. Alphanumeric sender IDs have restrictions in some countries (like the US). A purchased virtual number in E.164 format is often more reliable.
    • template: The message text. %token is the placeholder MessageBird replaces with the OTP.
    • type, tokenLength, timeout: Optional parameters to customize the verification process.
  • Error Handling: We wrap the asynchronous callback in a Promise. The err object from MessageBird contains details. We log the full error for debugging but return a user-friendly message. Mapping specific MessageBird error codes (err.errors[0].code) to better user messages is good practice.
  • Return Value: Only the verificationId is returned. This ID is needed for the next step but doesn't reveal sensitive information.

3.4 Validating OTP Tokens and Confirming Verification:

Implement the confirmVerification function to validate the one-time password entered by users. This function verifies the token with MessageBird, updates the database upon successful authentication, and marks the phone number as verified.

javascript
// api/src/services/verifications/verifications.js
// ... (imports, initialization, requestVerification) ...

export const confirmVerification = async ({ verificationId, token, phoneNumber }) => {
  // Uncomment and use requireAuth if this action must be performed by a logged-in user
  // requireAuth()
  // const userId = context.currentUser.id

  if (!verificationId || !token) {
    throw new Error('Verification ID and token are required.')
  }
  // Basic token validation (e.g., check length if known)
  if (token.length !== 6) { // Assuming default length (6 digits)
    throw new Error('Invalid token format.')
  }

  logger.info(`Confirming verification ID: ${verificationId} with token`)

  return new Promise((resolve, reject) => {
    messagebird.verify.verify(verificationId, token, async (err, response) => {
      if (err) {
        logger.error({ err, verificationId }, 'MessageBird verify.verify error')
        let userMessage = 'Verification failed. Please check the code and try again.'
        if (err.errors) {
          const specificError = err.errors[0]
          if (specificError.code === 10) { // Token not found or expired
            userMessage = 'Invalid or expired verification code.'
          } else {
             userMessage = `Verification failed: ${specificError.description}`
          }
          // Map more error codes as needed
        }
        return reject(new Error(userMessage))
      }

      // --- Verification Successful ---
      logger.info({ verificationId, response }, 'Verification successful')

      // CRITICAL: Update the correct user record in the database.
      // The logic here depends HEAVILY on your application's signup and authentication flow.
      // - If verifying for a currently logged-in user, use their ID from context.
      // - If verifying during sign-up *before* login, using the unique phoneNumber might be appropriate,
      //   assuming the user record was created tentatively or the number is guaranteed unique at this stage.
      // - Ensure this step is secure and cannot be used to hijack accounts.
      try {
        // --- Option 1: Update for the currently logged-in user (use requireAuth above) ---
        /*
        const userId = context.currentUser.id // Assumes requireAuth() was called
        const updatedUser = await db.user.update({
          where: { id: userId },
          data: {
            phoneNumber: phoneNumber, // Store the verified number
            phoneVerified: true,
          },
        })
        logger.info({ userId: updatedUser.id }, 'Logged-in user phone number marked as verified')
        */

        // --- Option 2: Update based on phone number (e.g., during sign-up, assumes phoneNumber is unique) ---
        // !! Ensure this lookup is safe in your specific authentication flow !!
        const userToUpdate = await db.user.findUnique({ where: { phoneNumber: phoneNumber }})
        if (!userToUpdate) {
           // This might happen if the user wasn't created yet, or if the phone number isn't
           // the correct identifier at this stage. Indicates a potential flow issue.
           logger.error(`User not found for phone number during confirmation: ${phoneNumber}`)
           // Rejecting here prevents marking verification as successful if the user link failed.
           return reject(new Error('Associated user account not found. Verification incomplete.'))
        }

        // Perform the update for the found user
        const updatedUser = await db.user.update({
           where: { phoneNumber: phoneNumber }, // Or use { id: userToUpdate.id }
           data: { phoneVerified: true },
        })
        logger.info({ userId: updatedUser.id }, 'User phone number marked as verified via phone number lookup')
        // --- End Option 2 ---

        // Resolve the promise indicating overall success
        resolve({ success: true, message: 'Phone number verified successfully!' })

      } catch (dbError) {
        logger.error({ dbError, verificationId, phoneNumber }, 'Database update failed after successful MessageBird verification')
        // Important: MessageBird verification succeeded, but we failed to save the state.
        // This leaves the user in an inconsistent state. Log carefully and inform the user.
        // Consider alerting mechanisms or manual intervention flags for support.
        reject(new Error('Verification succeeded but failed to update your account status. Please contact support.'))
      }
    })
  })
}

// Add SDL definitions if not generating automatically
export const schema = gql`
  type VerificationResponse {
    verificationId: String!
  }

  type ConfirmationResponse {
    success: Boolean!
    message: String
  }

  type Mutation {
    # Use @skipAuth if verification can be initiated by anyone (e.g., during sign-up)
    requestVerification(phoneNumber: String!): VerificationResponse! @skipAuth
    # Use @requireAuth if verification is only for logged-in users adding/changing a number
    # requestVerification(phoneNumber: String!): VerificationResponse! @requireAuth

    # Adjust auth based on whether confirmation requires login. Often matches requestVerification.
    confirmVerification(verificationId: String!, token: String!, phoneNumber: String!): ConfirmationResponse! @skipAuth
    # Use @requireAuth if confirmation is only for logged-in users
    # confirmVerification(verificationId: String!, token: String!): ConfirmationResponse! @requireAuth
  }
`
  • Inputs: Requires verificationId (from step 1), token (user input), and critically, the phoneNumber that was verified. We need the phone number to know which user record to update in the database, especially if not relying on a logged-in session.
  • messagebird.verify.verify: The core MessageBird call to check the ID and token.
  • Error Handling: Similar to requestVerification, log detailed errors and return user-friendly messages. Handle specific errors like invalid/expired tokens (code 10).
  • Database Update: This is the critical step upon successful MessageBird verification.
    • Identify User: You must reliably identify which user record to update. Added comments and an example showing lookup via phoneNumber, emphasizing the security implications and dependency on the specific auth flow. Included a commented-out example for updating based on context.currentUser.id for logged-in scenarios. Added specific logging and error handling if the user lookup fails.
    • Update Fields: Set phoneVerified to true. Store the phoneNumber if it wasn't already associated or confirmed (shown in the logged-in user example).
    • Database Errors: Handle potential errors during the database update. If this fails after MessageBird succeeded, the user is in an inconsistent state. Log this carefully and inform the user appropriately (e.g., contact support).
  • Return Value: Return a success status and message.
  • GraphQL Schema Definition Language (SDL): Define the GraphQL mutations (requestVerification, confirmVerification) and their input/output types.
    • @skipAuth / @requireAuth: Use directives to control access. If anyone can initiate verification (like during sign-up), use @skipAuth. If only logged-in users can verify/add a number, use @requireAuth. Adjust based on your requirements.

4. Creating the Two-Factor Authentication UI

Build React components for the two-factor authentication flow where users enter their phone number, receive an SMS code, and submit the verification token.

4.1 Create Verification Page:

bash
yarn rw g page Verification /verification

This creates web/src/pages/VerificationPage/VerificationPage.js.

4.2 Create Verification Component:

It's good practice to encapsulate the verification logic in a dedicated component.

bash
yarn rw g component VerificationForm

This creates web/src/components/VerificationForm/VerificationForm.js.

4.3 Implement VerificationForm Component:

javascript
// web/src/components/VerificationForm/VerificationForm.js
import { useState } from 'react'
import { Form, TextField, Submit, FieldError, Label } from '@redwoodjs/forms'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { navigate, routes } from '@redwoodjs/router' // If redirecting on success

// GraphQL Mutations (defined in the service's SDL)
const REQUEST_VERIFICATION_MUTATION = gql`
  mutation RequestVerificationMutation($phoneNumber: String!) {
    requestVerification(phoneNumber: $phoneNumber) {
      verificationId
    }
  }
`

const CONFIRM_VERIFICATION_MUTATION = gql`
  mutation ConfirmVerificationMutation($verificationId: String!, $token: String!, $phoneNumber: String!) {
    confirmVerification(verificationId: $verificationId, token: $token, phoneNumber: $phoneNumber) {
      success
      message
    }
  }
`

const VerificationForm = () => {
  const [step, setStep] = useState(1) // 1: Enter phone, 2: Enter OTP, 3: Success
  const [phoneNumber, setPhoneNumber] = useState('')
  const [verificationId, setVerificationId] = useState(null)
  const [errorMessage, setErrorMessage] = useState(null)

  const [requestVerification, { loading: requestLoading }] = useMutation(
    REQUEST_VERIFICATION_MUTATION,
    {
      onCompleted: (data) => {
        setVerificationId(data.requestVerification.verificationId)
        setStep(2) // Move to OTP step
        setErrorMessage(null) // Clear previous errors
        toast.success('Verification code sent!')
      },
      onError: (error) => {
        console.error("Request Verification Error:", error)
        setErrorMessage(error.message || 'Failed to send code. Please try again.')
        toast.error(error.message || 'Failed to send code.')
      },
    }
  )

  const [confirmVerification, { loading: confirmLoading }] = useMutation(
    CONFIRM_VERIFICATION_MUTATION,
    {
      onCompleted: (data) => {
        if (data.confirmVerification.success) {
          toast.success(data.confirmVerification.message || 'Phone number verified successfully!')
          setErrorMessage(null)
          // Optional: Redirect or update UI state
          // navigate(routes.profile()) // Example redirect
          setStep(3) // Move to success step/message
        } else {
          // This case might occur if the mutation resolves but success is false (e.g., DB update failed after verify)
          const message = data.confirmVerification.message || 'Verification failed.'
          setErrorMessage(message)
          toast.error(message)
        }
      },
      onError: (error) => {
        console.error("Confirm Verification Error:", error)
        setErrorMessage(error.message || 'Verification failed. Please check code and try again.')
        toast.error(error.message || 'Verification failed.')
      },
    }
  )

  const onRequestSubmit = (data) => {
    setPhoneNumber(data.phoneNumber) // Store phone number for confirmation step
    setErrorMessage(null) // Clear errors on new submission
    requestVerification({ variables: { phoneNumber: data.phoneNumber } })
  }

  const onConfirmSubmit = (data) => {
     setErrorMessage(null) // Clear errors on new submission
    confirmVerification({
      variables: {
        verificationId: verificationId, // Use stored verificationId
        token: data.token,
        phoneNumber: phoneNumber // Pass the original phone number (needed for DB lookup in some flows)
       },
    })
  }

  return (
    <div>
      <h2>Phone Number Verification</h2>
      {errorMessage && <p style={{ color: 'red' }}>Error: {errorMessage}</p>}

      {step === 1 && (
        <Form onSubmit={onRequestSubmit} style={{ marginTop: '1rem' }}>
          <Label name="phoneNumber" errorClassName="error">Phone Number (E.164 format, e.g., +14155552671)</Label>
          <TextField
            name="phoneNumber"
            validation={{
              required: true,
              pattern: {
                value: /^\+[1-9]\d{1,14}$/,
                message: 'Please enter a valid phone number in E.164 format (+1...).',
              },
            }}
            errorClassName="error"
          />
          <FieldError name="phoneNumber" className="error-message" />

          <Submit disabled={requestLoading} style={{ marginTop: '1rem' }}>
            {requestLoading ? 'Sending...' : 'Send Verification Code'}
          </Submit>
        </Form>
      )}

      {step === 2 && (
        <Form onSubmit={onConfirmSubmit} style={{ marginTop: '1rem' }}>
           {/* We stored verificationId and phoneNumber in state, no need for hidden fields unless preferred */}
          <p>Enter the 6-digit code sent to {phoneNumber}:</p>

          <Label name="token" errorClassName="error">Verification Code</Label>
          <TextField
            name="token"
            validation={{
              required: true,
              pattern: {
                  value: /^\d{6}$/, // Assumes default 6 digits; adjust if tokenLength is changed via API params
                  message: 'Please enter the 6-digit code.',
              }
             }}
            errorClassName="error"
          />
          <FieldError name="token" className="error-message" />

          <Submit disabled={confirmLoading} style={{ marginTop: '1rem' }}>
            {confirmLoading ? 'Verifying...' : 'Verify Code'}
          </Submit>
           <button type="button" onClick={() => setStep(1)} disabled={confirmLoading || requestLoading} style={{ marginLeft: '1rem' }}>
             Change Phone Number
           </button>
        </Form>
      )}

       {step === 3 && (
          <div style={{ marginTop: '1rem', color: 'green', border: '1px solid green', padding: '1rem' }}>
             <p>Phone number successfully verified!</p>
             {/* Add link to next step or profile page */}
             {/* <Link to={routes.profile()}>Go to Profile</Link> */}
          </div>
       )}
    </div>
  )
}

export default VerificationForm
  • State Management: Uses useState to manage the current step (step), the phone number being verified (phoneNumber), the verificationId received from MessageBird, and any error messages (errorMessage).
  • GraphQL Mutations: Imports the REQUEST_VERIFICATION_MUTATION and CONFIRM_VERIFICATION_MUTATION. Uses Redwood's useMutation hook.
  • useMutation Hooks: Handle loading states, onCompleted, and onError. Uses toast for feedback. Corrected console.error quoting.
  • Forms: Uses Redwood's <Form> component.
    • Step 1 Form: Collects phone number. Corrected <Label> quotes (name="phoneNumber" is correct for Redwood linking). Includes E.164 validation.
    • Step 2 Form: Collects OTP token. Updated <p> tag and validation message to explicitly mention "6-digit". Added a comment next to the regex /^\d{6}$/ indicating it assumes the default length and might need adjustment if tokenLength is customized in the API call. Includes a button to go back.
  • Step 3: Displays a success message. Corrected the paragraph content.
  • Error Display: Shows errorMessage state.

4.4 Use Component in Page:

Update web/src/pages/VerificationPage/VerificationPage.js to use the component:

javascript
// web/src/pages/VerificationPage/VerificationPage.js
import { MetaTags } from '@redwoodjs/web'
import VerificationForm from 'src/components/VerificationForm/VerificationForm' // Import the component

const VerificationPage = () => {
  return (
    <>
      <MetaTags title="Verify Phone Number" description="Verify your phone number" />

      <h1>Verify Your Phone Number</h1>
      <VerificationForm /> {/* Render the form component */}
    </>
  )
}

export default VerificationPage

5. Essential Security Best Practices for OTP Authentication

Implementing two-factor authentication requires security measures beyond basic functionality to protect against brute force attacks, rate limiting abuse, and authentication bypass attempts.

  • Rate Limiting: Prevent abuse of the OTP sending mechanism.
    • Apply rate limiting on the requestVerification mutation endpoint. RedwoodJS doesn't have built-in rate limiting, but you can:
      • Implement it yourself using libraries like rate-limiter-flexible in your service or API function wrapper.
      • Use API Gateway features if deploying behind one (e.g., AWS API Gateway, Cloudflare).
    • MessageBird may also have its own rate limits.
  • Input Validation: We added basic validation. Use robust libraries (google-libphonenumber) for phone numbers and ensure token formats are strictly checked. Sanitize all input.
  • Brute Force Protection: Limit the number of confirmVerification attempts for a given verificationId. You might track attempts in memory (with limitations) or in your database temporarily. MessageBird's verify.verify call likely has internal protection, but adding a layer in your app is safer.
  • User Association: Critically, ensure the verification process is correctly tied to the intended user account, especially when modifying phoneVerified status. Use requireAuth and context.currentUser diligently if operating on logged-in users. Handle the sign-up flow carefully to prevent race conditions or incorrect associations, as highlighted in section 3.4.
  • API Key Security: Never commit your .env file containing the MESSAGEBIRD_API_KEY to version control. Use environment variable management provided by your deployment platform.
  • HTTPS: Ensure your entire application runs over HTTPS.

6. Error Handling and Logging for Production OTP Systems

  • Consistent Error Handling: Implement robust error handling for MessageBird API failures, network issues, and database errors in your two-factor authentication flow. Log detailed errors for backend debugging (logger.error) and return user-friendly messages (reject(new Error(userMessage))) to the frontend via GraphQL errors. Handle database update failures after successful verification carefully (see section 3.4).
  • Logging: RedwoodJS provides built-in logging (api/src/lib/logger.ts). Use logger.info, logger.warn, logger.error appropriately in your service functions to track the flow and diagnose issues. Log relevant data like verificationId (but not sensitive tokens or excessive PII).
  • Retry Mechanisms:
    • Frontend: The user can manually retry sending the code or submitting the token if the first attempt fails due to network issues or temporary MessageBird problems. The UI handles this (e.g., the "Change Phone Number" button effectively allows restarting the process).
    • Backend: Retrying the messagebird.verify.create or verify.verify calls automatically is generally not recommended, as it could lead to duplicate messages or unintended consequences. Let the user trigger retries. The exception might be the database update after successful MessageBird confirmation; if this fails transiently, a short, limited retry (e.g., using async-retry) could be considered, but be cautious about side effects and ensure idempotency if possible.

7. Troubleshooting Common OTP Verification Issues

  • MessageBird API Error Codes:
    • Authentication failed (Error Code 2): Check your MESSAGEBIRD_API_KEY in .env. Ensure it's a live key and correctly copied. Make sure the Redwood API server process has picked up the latest .env value (restart rw dev if needed).
    • Invalid phone number (Error Code 21): Ensure the number is in E.164 format (+ followed by country code and number) and is a valid, reachable number.
    • Token not found or expired (Error Code 10): The verificationId is invalid, the token was already used, or the default 30-second timeout expired before verify.verify was called.
    • Invalid token provided (Error Code specific details may vary): The user entered the wrong code. Check the token length and format.
    • Rate Limits Exceeded: You're sending too many requests too quickly. Implement rate limiting on your end (see Section 5) and check MessageBird's limits.
  • Database Update Failures: As mentioned in section 3.4 and 6, if messagebird.verify.verify succeeds but the db.user.update fails, the user's phone is verified with MessageBird, but your application doesn't reflect it. This requires careful logging and potentially manual intervention or a robust retry/reconciliation mechanism.
  • Sender ID Restrictions: Alphanumeric originator values might be replaced by generic numbers or blocked in some countries (e.g., USA, Canada). Using a purchased virtual number from MessageBird often provides better deliverability and branding consistency.
  • Cost: Sending SMS messages via MessageBird incurs costs. Monitor your usage and set up billing alerts if necessary.
  • Development vs. Production Keys: Ensure you are using your live MessageBird API key in production deployments. Test keys often have limitations (like not sending real SMS).
  • E.164 Format: Consistently enforce and validate the E.164 format (+14155552671) for phone numbers passed to the MessageBird API.


Frequently Asked Questions

What is OTP verification?

OTP (One-Time Password) verification is a security method that sends a temporary, unique code to a user's phone via SMS. Users must enter this code to verify their identity, providing two-factor authentication (2FA) by combining something they know (password) with something they have (mobile device).

How does MessageBird OTP work?

MessageBird generates a random verification code and sends it via SMS to the user's phone number. The user enters this code in your application, which then validates it with MessageBird's API. If the code matches and hasn't expired, the verification succeeds and your application can mark the phone number as verified.

Is two-factor authentication with SMS secure?

SMS-based 2FA is significantly more secure than passwords alone, protecting against credential theft and unauthorized access. While not immune to advanced attacks like SIM swapping, it provides a strong security layer for most applications and is widely supported across all mobile devices.

How long does an OTP code last?

By default, MessageBird OTP codes expire after 30 seconds in their legacy API. You can customize this timeout parameter when creating the verification request, with a range from 30 seconds up to 2 days depending on your security requirements.

Can I customize the OTP message?

Yes, you can customize the SMS message template using the template parameter in MessageBird's Verify API. Use %token as a placeholder for the verification code (e.g., "Your verification code is %token").