code examples

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

Implement OTP/2FA Phone Verification in RedwoodJS with Infobip SMS API

Build secure phone number verification with Infobip 2FA API in RedwoodJS. Complete guide with OTP implementation, code examples, and best practices.

Implement OTP/2FA Phone Verification in RedwoodJS with Infobip

Build a secure phone number verification system using Infobip's Two-Factor Authentication (2FA) API with One-Time Passwords (OTP) via SMS in your RedwoodJS application. This guide walks you through creating a complete OTP flow where users verify their phone numbers by receiving and entering a code.

Add a verification layer beyond email/password authentication – commonly used during registration or for sensitive actions.

Project Overview and Goals

Goal: Build a secure and reliable phone number verification system within a RedwoodJS application using Infobip's 2FA API for sending and verifying OTPs via SMS.

Problem Solved:

  • Verify user phone numbers to reduce fake accounts.
  • Add a layer of security (2FA) for user actions.
  • Integrate a third-party OTP provider (Infobip) into a RedwoodJS workflow.

Technologies Used:

  • RedwoodJS: Full-stack JavaScript/TypeScript framework for the web. Chosen for its integrated structure (React frontend, GraphQL API, Prisma ORM) which simplifies development.
  • Infobip 2FA API: Service for sending and verifying OTPs across various channels (SMS focus). Chosen for its clear API and dedicated 2FA workflow.
  • Node.js: Runtime environment for RedwoodJS's API side.
  • Prisma: Database toolkit used by RedwoodJS for database access and migrations.
  • GraphQL: Query language used by RedwoodJS for API communication between frontend and backend.
  • React: Library used by RedwoodJS for building the frontend user interface.
  • Axios (or Fetch): For making HTTP requests from the RedwoodJS API to the Infobip API.

System Architecture:

The phone verification process follows these steps:

  1. User Interaction (Browser): The user enters their phone number into the RedwoodJS web frontend.
  2. Frontend to Backend (Send OTP): The frontend sends a GraphQL mutation (sendOtp) containing the phone number to the RedwoodJS API backend.
  3. Backend Logic (Service): The GraphQL resolver calls the otp.ts service function.
  4. Service to Infobip (Send PIN): The RedwoodJS service makes an HTTP POST request to the Infobip 2FA API endpoint to send the PIN.
  5. Infobip to User: Infobip sends an SMS containing the OTP to the user's phone.
  6. User Interaction (Browser): The user receives the OTP and enters it into the RedwoodJS web frontend.
  7. Frontend to Backend (Verify OTP): The frontend sends a GraphQL mutation (verifyOtp) containing the pinId (received earlier) and the entered OTP to the RedwoodJS API backend.
  8. Backend Logic (Service): The GraphQL resolver calls the otp.ts service function.
  9. Service to Infobip (Verify PIN): The RedwoodJS service makes an HTTP POST request to the Infobip 2FA API endpoint to verify the PIN.
  10. Infobip to Service: Infobip returns the verification result (success or failure) to the RedwoodJS service.
  11. Service Logic (Optional DB Update): If verification is successful, the service can optionally update the user's record in the database via Prisma (e.g., set isPhoneNumberVerified to true).
  12. Service to Backend: The service returns the verification result to the GraphQL resolver.
  13. Backend to Frontend: The GraphQL resolver returns the result to the web frontend.
  14. Frontend to User: The frontend displays a success or failure message to the user.

Prerequisites:

  • Node.js v20 LTS "Iron" or v22 LTS "Jod" recommended for production (v24 Current also supported)
  • Yarn v1 or later
  • RedwoodJS CLI installed (yarn global add redwoodjs)
  • Infobip account (free trial available)
  • Basic understanding of RedwoodJS, React, GraphQL, and Node.js

Final Outcome: A RedwoodJS application with pages/components allowing users to:

  1. Enter their phone number.
  2. Trigger an OTP SMS message sent via Infobip.
  3. Enter the received OTP.
  4. Verify the OTP with Infobip through your application.
  5. (Optional) Update the user's status in the database upon successful verification.

1. Setting up the RedwoodJS Project

Create a new RedwoodJS project.

  1. Create RedwoodJS app: Open your terminal and run:

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

    Follow the prompts (choose TypeScript or JavaScript). This guide uses TypeScript examples where applicable, but the concepts remain the same for JavaScript.

  2. Configure environment variables: Never hardcode Infobip credentials. Use environment variables instead. Open the .env file at the root of your project and add the following lines (get these values in the next step):

    plaintext
    # .env
    INFOBIP_BASE_URL=YOUR_INFOBIP_BASE_URL
    INFOBIP_API_KEY=YOUR_INFOBIP_API_KEY
    INFOBIP_2FA_APP_ID=YOUR_INFOBIP_2FA_APPLICATION_ID
    INFOBIP_2FA_MESSAGE_ID=YOUR_INFOBIP_2FA_MESSAGE_TEMPLATE_ID
    • INFOBIP_BASE_URL: The specific base URL provided by Infobip for your account (e.g., xxxxx.api.infobip.com).
    • INFOBIP_API_KEY: Your secret API key from the Infobip portal.
    • INFOBIP_2FA_APP_ID: The ID of the 2FA Application you'll create in Infobip.
    • INFOBIP_2FA_MESSAGE_ID: The ID of the 2FA Message Template you'll create in Infobip.

    Important: Add .env to your .gitignore file if it's not already there to prevent committing secrets. Redwood's default .gitignore includes it.

  3. Install dependencies: Install axios to make HTTP requests from the backend service to Infobip:

    bash
    yarn workspace api add axios

2. Configuring Infobip 2FA Service

Configure the necessary components within your Infobip account before writing code.

  1. Log in to Infobip: Access your Infobip Portal.
  2. Get API Key and Base URL:
    • Navigate to the homepage or API section. Your unique Base URL displays prominently (e.g., xxxxx.api.infobip.com).
    • Go to Manage API Keys (often under account settings or developer tools). Create a new API key if you don't have one. Give it a descriptive name (e.g., RedwoodJS OTP App Key).
    • Copy the API Key and the Base URL. Paste them into your .env file for INFOBIP_API_KEY and INFOBIP_BASE_URL.
  3. Create a 2FA Application:
    • In the Infobip portal, navigate to the "Apps" section (or search for "Apps").
    • Click Create App or similar.
    • Select or search for the 2FA application type.
    • Configure the application settings:
      • Name: RedwoodJS Verification (or similar)
      • PIN Attempts: 5 (Default, adjust as needed)
      • Allow Multiple PIN Verifications: true (Recommended during testing)
      • PIN Time To Live (TTL): 5m (5 minutes, adjust as needed)
      • Verify PIN Limit: 1/3s (Rate limit verification attempts)
      • Send PIN Per Application Limit: 10000/1d (Adjust based on expected traffic)
      • Send PIN Per Phone Number Limit: 5/1d (Crucial to prevent abuse)
      • Enabled: true
    • Save the application.
    • After creation, Infobip provides an Application ID. Copy this ID and paste it into your .env file for INFOBIP_2FA_APP_ID.
  4. Create a 2FA Message Template:
    • Within the 2FA Application you just created, find the section for Message Templates.
    • Click Create Template or similar.
    • Configure the template:
      • PIN Type: NUMERIC
      • Message Text: Your verification code for Your App Name is: {{pin}} (Customize "Your App Name" and the surrounding text for your application, but keep the {{pin}} placeholder)
      • PIN Length: 6 (Commonly 4 or 6 digits)
      • Sender ID: Choose an available Sender ID or use a default provided by Infobip (e.g., Infobip 2FA, Verify). This might require registration depending on the country.
    • Save the message template.
    • Infobip provides a Message Template ID. Copy this ID and paste it into your .env file for INFOBIP_2FA_MESSAGE_ID.

Your .env file should now have all four Infobip values filled in.

3. Creating the Database Schema

Create a basic User model to associate the verified phone number with.

  1. Define the schema: Open api/db/schema.prisma and define a simple User model. If you already have one, add phoneNumber and isPhoneNumberVerified fields:

    prisma
    // api/db/schema.prisma
    
    datasource db {
      provider = "sqlite" // Or "postgresql", "mysql"
      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?
      // Add fields for OTP verification
      phoneNumber           String?  @unique // Store in E.164 format (ITU-T standard)
      isPhoneNumberVerified Boolean  @default(false)
      createdAt             DateTime @default(now())
      updatedAt             DateTime @updatedAt
    }
    • phoneNumber: Stores the user's phone number in E.164 format (e.g., +14155552671) per ITU-T Recommendation E.164 (November 2010).
    • isPhoneNumberVerified: Flag to track verification status.
  2. Apply migrations: Run the Prisma migrate command to create/update the database table:

    bash
    yarn rw prisma migrate dev

    Enter a name for the migration when prompted (e.g., addUserPhoneVerification).

4. Implementing Core Functionality (API Service)

Create a RedwoodJS service to handle communication with the Infobip API.

  1. Generate the service:

    bash
    yarn rw g service otp

    This creates api/src/services/otp/otp.ts and associated test/scenario files.

  2. Implement service logic: Update api/src/services/otp/otp.ts with the following logic:

    typescript
    // File: api/src/services/otp/otp.ts
    import type { MutationResolvers } from 'types/graphql'
    import axios from 'axios'
    import { RedwoodError } from '@redwoodjs/api'
    
    import { logger } from 'src/lib/logger'
    // If you have user authentication setup:
    // import { db } from 'src/lib/db'
    // import { requireAuth } from 'src/lib/auth'
    
    // Type for Infobip Send PIN Response
    interface InfobipSendPinResponse {
      pinId: string
      to: string
      ncStatus: string // e.g., 'NC_DESTINATION_REACHABLE'
      smsStatus: string // e.g., 'MESSAGE_SENT'
    }
    
    // Type for Infobip Verify PIN Response
    interface InfobipVerifyPinResponse {
      pinId: string
      verified: boolean
      msisdn: string // Phone number
      attemptsRemaining?: number // Optional, depends on API version/config
    }
    
    const {
      INFOBIP_BASE_URL,
      INFOBIP_API_KEY,
      INFOBIP_2FA_APP_ID,
      INFOBIP_2FA_MESSAGE_ID,
    } = process.env
    
    // Basic phone number validation (E.164-like).
    // **Critical for production:** Replace this with a robust library
    // like 'google-libphonenumber' (or its JS ports like 'libphonenumber-js')
    // to handle various international formats correctly.
    const isValidPhoneNumber = (phoneNumber: string): boolean => {
      const phoneRegex = /^\+[1-9]\d{1,14}$/
      return phoneRegex.test(phoneNumber)
    }
    
    /**
     * Sends an OTP PIN code via Infobip SMS.
     * @param phoneNumber The recipient's phone number in E.164 format (e.g., +14155552671).
     * @returns The pinId required for verification.
     */
    export const sendOtp = async ({ phoneNumber }: { phoneNumber: string }): Promise<string> => {
      logger.info({ phoneNumber }, 'Attempting to send OTP')
    
      if (
        !INFOBIP_BASE_URL ||
        !INFOBIP_API_KEY ||
        !INFOBIP_2FA_APP_ID ||
        !INFOBIP_2FA_MESSAGE_ID
      ) {
        logger.error('Infobip environment variables are not configured.')
        throw new RedwoodError('Server configuration error.')
      }
    
      if (!isValidPhoneNumber(phoneNumber)) {
        logger.warn({ phoneNumber }, 'Invalid phone number format provided.')
        throw new RedwoodError('Invalid phone number format. Use E.164 format (e.g., +14155552671).')
      }
    
      const infobipUrl = `https://${INFOBIP_BASE_URL}/2fa/2/pin`
      const headers = {
        Authorization: `App ${INFOBIP_API_KEY}`,
        'Content-Type': 'application/json',
        Accept: 'application/json',
      }
      const payload = {
        applicationId: INFOBIP_2FA_APP_ID,
        messageId: INFOBIP_2FA_MESSAGE_ID,
        from: 'YourAppName', // Optional: Can override sender ID if allowed. Customize as needed.
        to: phoneNumber,
      }
    
      try {
        logger.debug({ url: infobipUrl, payload }, 'Sending request to Infobip Send PIN API')
        const response = await axios.post<InfobipSendPinResponse>(infobipUrl, payload, { headers })
        logger.info({ responseData: response.data }, 'Received response from Infobip Send PIN API')
    
        if (!response.data || !response.data.pinId) {
          logger.error({ response: response.data }, 'Infobip Send PIN response missing pinId.')
          throw new RedwoodError('Failed to initiate OTP process.')
        }
    
        // Important: The pinId is returned to the client for use in the verify step.
        return response.data.pinId
    
      } catch (error) {
        logger.error({ error: error?.response?.data || error.message }, 'Error calling Infobip Send PIN API')
        // Provide a generic error to the client, log the specific details
        throw new RedwoodError('Could not send verification code. Try again later.')
      }
    }
    
    /**
     * Verifies an OTP PIN code with Infobip.
     * @param pinId The ID received from the sendOtp call.
     * @param pin The 4 or 6-digit PIN entered by the user.
     * @returns True if verification succeeds, false otherwise.
     */
    export const verifyOtp = async ({ pinId, pin }: { pinId: string; pin: string }): Promise<boolean> => {
      logger.info({ pinId }, 'Attempting to verify OTP')
    
      if (
        !INFOBIP_BASE_URL ||
        !INFOBIP_API_KEY
      ) {
        logger.error('Infobip environment variables are not configured.')
        throw new RedwoodError('Server configuration error.')
      }
    
      // Basic PIN validation (adjust length as needed)
      if (!pin || !/^\d{4,6}$/.test(pin)) {
         logger.warn('Invalid PIN format provided.')
         throw new RedwoodError('Invalid PIN format. Enter the 4 or 6 digit code.')
      }
    
    
      const infobipUrl = `https://${INFOBIP_BASE_URL}/2fa/2/pin/${pinId}/verify`
      const headers = {
        Authorization: `App ${INFOBIP_API_KEY}`,
        'Content-Type': 'application/json',
        Accept: 'application/json',
      }
      const payload = {
        pin: pin,
      }
    
      try {
        logger.debug({ url: infobipUrl, payload }, 'Sending request to Infobip Verify PIN API')
        const response = await axios.post<InfobipVerifyPinResponse>(infobipUrl, payload, { headers })
        logger.info({ responseData: response.data }, 'Received response from Infobip Verify PIN API')
    
        if (!response.data) {
          logger.error('Empty response from Infobip Verify PIN API.')
          throw new Error('Verification failed.') // Internal error
        }
    
        if (response.data.verified) {
          logger.info({ pinId, msisdn: response.data.msisdn }, 'OTP verification successful')
    
          // --- Optional: Update User Record ---
          // If you need to link this verification to a user:
          // 1. Ensure this function is called within an authenticated context (`requireAuth`)
          // 2. Get the currentUser: `const user = context.currentUser`
          // 3. Find the user and update their status:
          // try {
          //   await db.user.update({
          //     where: { id: user.id /* or email: user.email */ },
          //     data: { isPhoneNumberVerified: true, phoneNumber: response.data.msisdn /* store verified number */ },
          //   })
          //   logger.info({ userId: user.id }, 'User phone number marked as verified.')
          // } catch (dbError) {
          //   logger.error({ dbError }, 'Failed to update user verification status in DB.')
          //   // Decide if this should cause the overall verification to fail.
          //   // For now, we'll still return true if Infobip verification passed.
          // }
          // --- End Optional ---
    
          return true
        } else {
          logger.warn({ pinId, attemptsRemaining: response.data.attemptsRemaining }, 'OTP verification failed (Incorrect PIN or expired).')
          return false
        }
    
      } catch (error) {
        // Infobip might return 4xx errors for known issues (e.g., wrong PIN, expired)
        // Check the error structure and potentially provide more specific feedback
        const errorData = error?.response?.data
        const statusCode = error?.response?.status
        const messageId = errorData?.requestError?.serviceException?.messageId;
    
        if (statusCode === 400 && messageId === `PIN_NOT_FOUND`) {
           logger.warn({ pinId }, 'Verification failed: PIN ID not found or expired.')
           // Don't throw, return false as it's a verification failure, not a server error.
           return false;
        }
         if (statusCode === 400 && messageId === `WRONG_PIN`) {
           logger.warn({ pinId }, 'Verification failed: Wrong PIN entered.')
           return false;
        }
        // Log other errors more generally
        logger.error({ error: errorData || error.message }, 'Error calling Infobip Verify PIN API')
        // Rethrow as a generic error unless handled above
        throw new RedwoodError('Could not verify code. Try again later.')
      }
    }
    
    // Note: RedwoodJS automatically maps GraphQL mutations/queries to service functions.
    // Define the GraphQL schema next.
    • Define interfaces for expected responses.
    • Retrieve environment variables securely.
    • Add basic validation for phone numbers and PINs (use a proper library like google-libphonenumber for production phone validation).
    • Construct API requests using axios, including the Authorization header.
    • Crucially, sendOtp returns the pinId provided by Infobip. This ID links the sending request to the verification request.
    • verifyOtp uses the pinId and the user-provided pin to check with Infobip.
    • Include robust error handling and logging. Catch specific Infobip errors (like WRONG_PIN, PIN_NOT_FOUND) to return false instead of throwing a server error.
    • An optional section shows where you would update the user's record in the database upon successful verification. This typically requires the function to be called within an authenticated context.

5. Building the API Layer (GraphQL)

Now, let's expose our service functions through the GraphQL API.

  1. Generate the SDL (Schema Definition Language) file:

    bash
    yarn rw g sdl otp

    This creates api/src/graphql/otp.sdl.ts.

  2. Define the GraphQL Schema: Open api/src/graphql/otp.sdl.ts and define the mutations and types.

    typescript
    // File: api/src/graphql/otp.sdl.ts
    export const schema = gql`
      # Response type for sending OTP
      type SendOtpResponse {
        success: Boolean!
        pinId: String # Only returned on success
        message: String!
      }
    
      # Response type for verifying OTP
      type VerifyOtpResponse {
        success: Boolean!
        message: String!
      }
    
      type Mutation {
        """"""
        Sends an OTP code to the provided phone number via Infobip.
        Requires the phone number in E.164 format (e.g., +14155552671).
        """"""
        sendOtp(phoneNumber: String!): SendOtpResponse! @skipAuth
        # Use @requireAuth if the user must be logged in to request an OTP for their own number.
        # If @skipAuth, ensure proper rate limiting is in place.
    
        """"""
        Verifies the OTP code using the pinId received from sendOtp and the user-entered pin.
        """"""
        verifyOtp(pinId: String!, pin: String!): VerifyOtpResponse! @skipAuth
        # Use @requireAuth if verification should update the logged-in user's status.
        # The service logic needs adjustment to use context.currentUser if @requireAuth is used.
      }
    `
    • We define two mutations: sendOtp and verifyOtp.
    • We create corresponding response types (SendOtpResponse, VerifyOtpResponse) to provide clear feedback (success status, pinId, and messages).
    • @skipAuth is used here for simplicity, assuming this might be part of a registration flow where the user isn't logged in yet. If this is for logged-in users, replace @skipAuth with @requireAuth and adjust the service logic to use context.currentUser to associate the verification with the correct user.
    • Security Note: Using @skipAuth requires careful consideration of rate limiting on the API endpoints to prevent abuse.

RedwoodJS automatically maps the sendOtp and verifyOtp mutations defined here to the service functions of the same name we created earlier.

6. Integrating with the Frontend (Web Side)

Let's create the UI components for users to interact with the OTP flow.

  1. Generate Pages: We need two pages: one to request the OTP and one to verify it.

    bash
    yarn rw g page RequestOtp /request-otp
    yarn rw g page VerifyOtp /verify-otp/{pinId}
    • The VerifyOtp page includes a route parameter {pinId} to receive the pinId generated by the sendOtp step.
  2. Implement RequestOtp Page: Update the file web/src/pages/RequestOtpPage/RequestOtpPage.tsx.

    typescript
    // File: web/src/pages/RequestOtpPage/RequestOtpPage.tsx
    import { useState } from 'react'
    import { MetaTags, useMutation } from '@redwoodjs/web'
    import { navigate, routes } from '@redwoodjs/router'
    import { toast, Toaster } from '@redwoodjs/web/toast'
    import {
      Form,
      TextField,
      Submit,
      Label,
      FieldError,
    } from '@redwoodjs/forms'
    
    const SEND_OTP_MUTATION = gql`
      mutation SendOtpMutation($phoneNumber: String!) {
        sendOtp(phoneNumber: $phoneNumber) {
          success
          pinId
          message
        }
      }
    `
    
    const RequestOtpPage = () => {
      const [phoneNumber, setPhoneNumber] = useState('')
      const [sendOtp, { loading, error }] = useMutation(SEND_OTP_MUTATION, {
        onCompleted: (data) => {
          if (data.sendOtp.success && data.sendOtp.pinId) {
            toast.success(data.sendOtp.message || 'OTP sent successfully!')
            // Navigate to the verification page, passing the pinId
            navigate(routes.verifyOtp({ pinId: data.sendOtp.pinId }))
          } else {
            toast.error(data.sendOtp.message || 'Failed to send OTP.')
          }
        },
        onError: (error) => {
           toast.error(error.message || 'An error occurred while sending OTP.')
        }
      })
    
      const onSubmit = (data: { phoneNumber: string }) => {
        // Add '+' if user didn't include it, basic normalization
        const formattedNumber = data.phoneNumber.startsWith('+')
          ? data.phoneNumber
          : `+${data.phoneNumber}`
    
        // Optional: Add more robust frontend validation here if needed
        // Example: using a library like 'libphonenumber-js'
    
        sendOtp({ variables: { phoneNumber: formattedNumber } })
      }
    
      return (
        <>
          <MetaTags title=""Request OTP"" description=""Request OTP page"" />
          <Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
    
          <h1>Request Phone Verification</h1>
          <p>Enter your phone number in E.164 format (e.g., +14155552671) to receive a verification code.</p>
    
          <Form onSubmit={onSubmit} className=""rw-form-wrapper"">
            {error && <div style={{ color: 'red' }}>Error: {error.message}</div>}
    
            <Label name=""phoneNumber"" errorClassName=""rw-label rw-label-error"">
              Phone Number
            </Label>
            <TextField
              name=""phoneNumber""
              placeholder=""+14155552671""
              validation={{
                required: true,
                // Basic pattern - improve for production
                pattern: {
                  value: /^\+?[1-9]\d{1,14}$/,
                  message: 'Please enter a valid phone number starting with + and country code.',
                },
              }}
              value={phoneNumber}
              onChange={(e) => setPhoneNumber(e.target.value)}
              className=""rw-input""
              errorClassName=""rw-input rw-input-error""
            />
            <FieldError name=""phoneNumber"" className=""rw-field-error"" />
    
            <div className=""rw-button-group"">
              <Submit disabled={loading} className=""rw-button rw-button-blue"">
                {loading ? 'Sending...' : 'Send Code'}
              </Submit>
            </div>
          </Form>
        </>
      )
    }
    
    export default RequestOtpPage
    • Uses Redwood Forms for input and validation.
    • Uses the useMutation hook to call the sendOtp GraphQL mutation.
    • Displays loading states and errors using loading and error from the hook and Redwood Toast.
    • On successful OTP send, it retrieves the pinId from the response and navigates the user to the VerifyOtpPage, passing the pinId in the URL.
  3. Implement VerifyOtp Page: Update the file web/src/pages/VerifyOtpPage/VerifyOtpPage.tsx.

    typescript
    // File: web/src/pages/VerifyOtpPage/VerifyOtpPage.tsx
    import { useState } from 'react'
    import { MetaTags, useMutation } from '@redwoodjs/web'
    import { navigate, routes } from '@redwoodjs/router'
    import { toast, Toaster } from '@redwoodjs/web/toast'
    import {
      Form,
      TextField,
      Submit,
      Label,
      FieldError,
    } from '@redwoodjs/forms'
    
    // Define the mutation
    const VERIFY_OTP_MUTATION = gql`
      mutation VerifyOtpMutation($pinId: String!, $pin: String!) {
        verifyOtp(pinId: $pinId, pin: $pin) {
          success
          message
        }
      }
    `
    
    interface VerifyOtpPageProps {
      pinId: string // Received from route parameter
    }
    
    const VerifyOtpPage = ({ pinId }: VerifyOtpPageProps) => {
      const [pin, setPin] = useState('')
      const [verifyOtp, { loading, error }] = useMutation(VERIFY_OTP_MUTATION, {
        onCompleted: (data) => {
          if (data.verifyOtp.success) {
            toast.success(data.verifyOtp.message || 'Phone number verified successfully!')
            // Redirect to dashboard or next step after successful verification
            setTimeout(() => navigate(routes.home()), 2000) // Example redirect
          } else {
            toast.error(data.verifyOtp.message || 'Verification failed. Please check the code and try again.')
          }
        },
        onError: (error) => {
           toast.error(error.message || 'An error occurred during verification.')
        }
      })
    
      const onSubmit = (data: { pin: string }) => {
        verifyOtp({ variables: { pinId: pinId, pin: data.pin } })
      }
    
      return (
        <>
          <MetaTags title=""Verify OTP"" description=""Verify OTP page"" />
          <Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
    
          <h1>Verify Your Phone</h1>
          {/* Note: Adjust '{6}' and input validation below based on the 'PIN Length' configured in your Infobip Message Template */}
          <p>Enter the 6-digit code sent to your phone.</p>
    
          <Form onSubmit={onSubmit} className=""rw-form-wrapper"">
            {error && <div style={{ color: 'red' }}>Error: {error.message}</div>}
    
            <Label name=""pin"" errorClassName=""rw-label rw-label-error"">
              Verification Code (PIN)
            </Label>
            <TextField
              name=""pin""
              placeholder=""123456"" // Adjust placeholder based on PIN length
              maxLength={6} // Match PIN length configured in Infobip
              validation={{
                required: true,
                pattern: {
                  value: /^\d{4,6}$/, // Match 4 or 6 digits - adjust if your PIN length is fixed
                  message: 'Please enter the numeric code.',
                },
              }}
               value={pin}
               onChange={(e) => setPin(e.target.value)}
              className=""rw-input""
              errorClassName=""rw-input rw-input-error""
            />
            <FieldError name=""pin"" className=""rw-field-error"" />
    
             {/* Hidden field to carry pinId, though not strictly needed for submission */}
             {/* as it's passed directly in the mutation variables */}
             {/* <HiddenField name=""pinId"" value={pinId} /> */}
    
    
            <div className=""rw-button-group"">
              <Submit disabled={loading} className=""rw-button rw-button-blue"">
                {loading ? 'Verifying...' : 'Verify Code'}
              </Submit>
            </div>
          </Form>
           <p style={{ marginTop: '1rem' }}>
             Didn't receive a code?{' '}
             <button onClick={() => navigate(routes.requestOtp())} className=""rw-button rw-button-small"">
               Request a new one
             </button>
           </p>
        </>
      )
    }
    
    export default VerifyOtpPage
    • Receives the pinId as a prop via the route parameter.
    • Uses Redwood Forms for the PIN input.
    • Calls the verifyOtp mutation using the received pinId and the user-entered pin.
    • Displays success or failure messages using Toast.
    • On success, it navigates the user away (e.g., to a dashboard or back home).
    • Includes a link back to the request page in case the user didn't receive the code.
    • Includes a note reminding the developer to adjust the hardcoded digit count based on their Infobip configuration.
  4. Update Routes: Ensure your routes in web/src/Routes.tsx are correctly set up:

    typescript
    // File: web/src/Routes.tsx
    import { Router, Route, Set } from '@redwoodjs/router'
    import MainLayout from 'src/layouts/MainLayout/MainLayout' // Example layout
    
    const Routes = () => {
      return (
        <Router>
          <Set wrap={MainLayout}>
            <Route path=""/request-otp"" page={RequestOtpPage} name=""requestOtp"" />
            <Route path=""/verify-otp/{pinId}"" page={VerifyOtpPage} name=""verifyOtp"" />
            {/* Add other routes like home, login, signup etc. */}
            <Route path=""/"" page={HomePage} name=""home"" />
            <Route notfound page={NotFoundPage} />
          </Set>
        </Router>
      )
    }
    
    export default Routes

7. Adding Security Features

  • Rate Limiting: This is CRITICAL for OTP endpoints, especially if they @skipAuth.
    • Infobip Limits: We configured limits (sendPinPerPhoneNumberLimit, verifyPinLimit) in the Infobip App setup. These provide a baseline.
    • API Gateway/Platform Limits: If deploying to Vercel, Netlify, AWS Lambda, etc., configure platform-level rate limiting on the GraphQL endpoint (/graphql).
    • Application-Level (Advanced): For more granular control, you could integrate middleware like graphql-shield or custom logic in your services (using Redis or the database) to track request frequency per user ID, IP address, or phone number. Start with Infobip and platform limits first.
  • Input Validation:
    • Backend: The service already includes basic validation. Use libraries like zod or yup within the service layer for more complex rules on phone numbers or PINs if needed. RedwoodJS allows easy integration with these. Remember the importance of robust phone number validation using libraries like google-libphonenumber or libphonenumber-js.
    • Frontend: Redwood Forms provides good basic validation as shown. Use libraries like libphonenumber-js for robust frontend phone number parsing and validation before even sending to the backend.
  • Secure API Key Storage: Handled via .env and environment variables in deployment – never commit keys.
  • HTTPS: Ensure your deployment uses HTTPS everywhere. Platforms like Vercel/Netlify handle this automatically.
  • Prevent Enumeration: Be cautious about error messages. Don't reveal whether a phone number exists in your system during the OTP request phase if it's unauthenticated. The current messages are reasonably generic.

8. Implementing Error Handling and Logging

  • Backend:
    • The service uses try...catch blocks around axios calls.
    • Redwood's built-in logger (api/src/lib/logger.ts) is used to log informational messages, warnings, and errors, including details from Infobip API errors where available. This is crucial for debugging.
    • Specific Infobip errors (like wrong PIN, expired PIN) are caught and translated into user-friendly false return values or specific error messages, rather than generic server errors.
  • Frontend:
    • The useMutation hook provides loading and error states, used to give feedback to the user (disable buttons, show error messages).
    • Redwood's <Toaster> component is used to display non-blocking success or error notifications.
    • Specific error messages returned from the GraphQL API are displayed when possible.

This setup provides a solid foundation for integrating Infobip OTP into your RedwoodJS application. Remember to adapt the database interactions, authentication context (@requireAuth vs @skipAuth), and validation logic to your specific application requirements.

Frequently Asked Questions (FAQ)

How do I implement OTP verification in RedwoodJS?

Implement OTP verification in RedwoodJS by creating a service that communicates with an SMS provider (like Infobip 2FA API), exposing GraphQL mutations for sending and verifying OTPs, and building React components for user interaction. Store phone numbers in E.164 format and use Prisma to track verification status in your database.

What is Infobip 2FA and how does it work?

Infobip 2FA is a dedicated two-factor authentication service that manages the complete OTP lifecycle. It generates time-limited PINs, sends them via SMS to users' phones, and verifies user-submitted codes. Configure rate limits and PIN settings in the Infobip portal to prevent abuse while maintaining security.

How do I format phone numbers for SMS verification?

Store and validate phone numbers in E.164 format (e.g., +14155552671) per ITU-T Recommendation E.164 (November 2010). Use libphonenumber-js for production-grade validation that handles international formats correctly. E.164 ensures consistent formatting across all carriers and countries.

Should I use @skipAuth or @requireAuth for OTP endpoints?

Use @skipAuth for registration flows where users aren't logged in yet, but implement strict rate limiting to prevent abuse. Use @requireAuth when adding phone verification to existing user accounts. With @requireAuth, access context.currentUser in your service to update the correct user's verification status.

How do I prevent OTP SMS spam and abuse?

Implement multi-layer rate limiting: (1) Configure Infobip limits in your 2FA Application (e.g., 5 SMS per phone number per day), (2) Enable platform-level rate limiting on your GraphQL endpoint, and (3) Consider Redis-based application-level tracking for granular control. Never use @skipAuth without rate limiting.

What Node.js version should I use for RedwoodJS?

Use Node.js v20 LTS "Iron" or v22 LTS "Jod" for production RedwoodJS applications as of October 2025. These versions receive long-term support with security updates. RedwoodJS also supports v24 Current. Avoid odd-numbered versions which have shorter support cycles.

How do I handle Infobip OTP errors in RedwoodJS?

Catch specific Infobip error codes (PIN_NOT_FOUND, WRONG_PIN) and return false for verification failures rather than throwing server errors. Log detailed error information server-side using RedwoodJS's built-in logger for debugging, but provide generic user-facing error messages to prevent information disclosure.

Can I use Infobip OTP with RedwoodJS authentication?

Yes. Integrate OTP verification with RedwoodJS auth by calling your verification service within authenticated contexts. After successful OTP verification, update the user's isPhoneNumberVerified field in your database using Prisma. Access the current user via context.currentUser in your service functions.

How long does an Infobip OTP code remain valid?

Configure PIN Time To Live (TTL) in your Infobip 2FA Application settings. Common values are 5 minutes (5m) or 10 minutes (10m). Balance security (shorter TTL) with user experience (longer TTL for users in poor network conditions). Infobip automatically expires codes after the configured TTL.

Frequently Asked Questions

How to integrate Infobip 2FA into RedwoodJS?

Integrate Infobip's 2FA into your RedwoodJS app by first setting up a new Redwood project and configuring environment variables for your Infobip credentials. Then, configure the 2FA service in the Infobip portal, including creating a 2FA application and message template. Finally, implement the backend service, GraphQL API, and frontend components to handle the OTP flow within your application.

What is the Infobip 2FA Application ID used for?

The Infobip 2FA Application ID is a unique identifier for your 2FA application within the Infobip platform. It is required when making API calls to Infobip for sending and verifying OTPs, and it should be stored securely as an environment variable in your RedwoodJS project.

Why does RedwoodJS use environment variables for Infobip credentials?

RedwoodJS uses environment variables to store sensitive information like Infobip API keys and application IDs. This practice prevents these secrets from being hardcoded into your application's source code, which improves security and makes managing these credentials easier across different environments.

How to send OTP SMS messages with Infobip in RedwoodJS?

To send OTPs, create a RedwoodJS service that makes a POST request to the Infobip 2FA API. This service should use environment variables for your Infobip credentials and the user's phone number to send the OTP message. The response from Infobip will include a `pinId` that's essential for later verification.

What is the pinId returned by Infobip's sendOtp API?

The `pinId` is a unique identifier for a specific OTP sending request. It's returned by Infobip after you send an OTP and is crucial for the verification step. Your RedwoodJS app must store this `pinId` temporarily and send it back to Infobip when verifying the user-entered OTP.

When should I use @requireAuth for the sendOtp mutation?

Use `@requireAuth` for the `sendOtp` GraphQL mutation when users need to be logged in to request an OTP, such as when adding 2FA to an existing account. If the OTP is part of a registration process where users aren't logged in yet, you might use `@skipAuth`, but ensure robust rate limiting is in place to prevent abuse.

How to verify Infobip OTP in RedwoodJS?

Create a RedwoodJS service that calls the Infobip verification API using the received `pinId` and the OTP entered by the user. This will return a success or failure status, and the service can optionally update the user's record in the database if verification succeeds.

What is the best format for storing phone numbers?

The E.164 format is recommended for storing phone numbers, as it's an international standard. An example of this format is +14155552671. Use a robust library like `google-libphonenumber` or `libphonenumber-js` for proper validation and normalization.

How to set up rate limiting for Infobip OTP API calls?

Implement rate limiting at multiple levels: configure limits within your Infobip 2FA application settings, leverage API gateway or platform-level rate limiting (e.g., on Vercel or Netlify), and optionally add application-level limits within your services using middleware or custom logic to prevent abuse.

How to handle errors when integrating with Infobip?

Implement robust error handling in your RedwoodJS service and frontend components. Use `try...catch` blocks around API calls, log errors using Redwood's `logger`, and display user-friendly error messages in the frontend using Redwood's `<Toaster>` component.

Can I customize the Infobip OTP message content?

Yes, you can customize the message content within the Infobip Message Template, but you must keep the {{pin}} placeholder so Infobip can correctly insert the generated OTP into the message. Also, ensure any customizations comply with Infobip's guidelines and local regulations for SMS messaging.

What are the prerequisites for implementing Infobip OTP in Redwood?

You need Node.js, Yarn, the RedwoodJS CLI, an Infobip account, and a basic understanding of RedwoodJS, React, GraphQL, and Node.js to implement Infobip OTP.

How to improve phone number validation in RedwoodJS?

Use a specialized library like `google-libphonenumber` or its JavaScript port `libphonenumber-js` for robust phone number parsing and validation on both the backend and frontend of your RedwoodJS application. This will ensure you handle various international number formats correctly and prevent common validation errors.

Why is input validation important for OTP security?

Input validation helps prevent malicious actors from exploiting vulnerabilities. In the context of OTP, validating phone numbers and PINs protects against invalid input, injection attacks, and other security risks.

What RedwoodJS features simplify frontend form handling?

RedwoodJS Forms provide built-in components and utilities for creating and managing forms, including input validation and error handling. This simplifies frontend development and improves the user experience.