code examples

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

RedwoodJS OTP 2FA with Sinch Verification API: Complete SMS Authentication Guide

Build SMS two-factor authentication in RedwoodJS using Sinch Verification API. Step-by-step tutorial covering phone verification, dbAuth integration, GraphQL mutations, and production security best practices for Node.js applications.

How to Build OTP and 2FA with Sinch SMS and RedwoodJS

Implementing two-factor authentication (2FA) in RedwoodJS applications strengthens security by requiring users to verify their identity through SMS one-time passwords (OTP). This comprehensive guide demonstrates how to build SMS-based phone verification using Sinch's Verification API with RedwoodJS v8.6+ and its built-in dbAuth authentication system for production-ready 2FA implementation.

Quick Reference

FeatureImplementation
Authentication MethodSMS-based OTP with Sinch Verification API
FrameworkRedwoodJS v8.6+ with dbAuth
Node.js Versionv22 LTS (supported until April 2027)
DatabasePostgreSQL v9.6+ with Prisma ORM v5/v6
OTP Delivery Time30 seconds average
Verification Report Retention14 days
Conversion Rate ImprovementUp to 11% with multi-method fallbacks
Primary Use CasePhone number verification and 2FA login

Prerequisites for RedwoodJS 2FA Implementation

Before implementing SMS OTP authentication in your RedwoodJS application, ensure you have:

Note: RedwoodJS v8 introduced enhanced dbAuth features including improved session management and WebAuthn support for biometric authentication.

What You'll Build: RedwoodJS SMS Authentication System

You'll build upon RedwoodJS's built-in dbAuth system, adding a second verification step after successful password authentication. Sinch generates and verifies the OTP codes automatically, handling SMS delivery and code validation through their Verification API.

Project Overview: RedwoodJS Two-Factor Authentication

  • Goal: Enhance application security by adding an SMS-based OTP verification step after standard password login for RedwoodJS applications.

  • Problem Solved: Protects user accounts even if passwords are compromised by requiring access to the user's registered phone number.

  • Technologies:

    • RedwoodJS: Full-stack JavaScript/TypeScript framework. You'll leverage its structure, CLI, dbAuth, Prisma ORM, and GraphQL API.
    • Node.js: The underlying runtime environment.
    • Prisma: Database toolkit for schema definition, migrations, and type-safe database access.
    • Sinch Verification API: Third-party service for sending and managing OTPs via SMS. Sinch handles OTP generation and verification.
    • React: For the frontend components.
  • Architecture (Sinch-Generated OTP Flow):

    1. Browser/User: Sends Login Request (Email/Password) to RedwoodJS Frontend.
    2. RedwoodJS Frontend: Sends GraphQL Mutation (login) to RedwoodJS API (dbAuth).
    3. RedwoodJS API (dbAuth): Validates Credentials against the Database (Prisma).
    4. Database (Prisma): Confirms User Found & Password OK, responds to API.
    5. RedwoodJS API: Initiates Sinch Verification request to Sinch Verification API.
    6. Sinch Verification API: Sends OTP SMS to User's Phone.
    7. RedwoodJS API: Responds to Frontend: Needs OTP.
    8. RedwoodJS Frontend: Displays OTP Input to Browser/User.
    9. Browser/User: Submits OTP to Frontend.
    10. RedwoodJS Frontend: Sends GraphQL Mutation (verifyOtp) to API.
    11. RedwoodJS API: Verifies OTP with Sinch Verification API.
    12. Sinch Verification API: Confirms OTP Valid, responds to API.
    13. RedwoodJS API: Optionally updates User Record (e.g., isPhoneVerified) in Database.
    14. RedwoodJS API: Responds to Frontend: Success.
    15. RedwoodJS Frontend: Updates state to Logged In (Session Active) for Browser/User.
  • Prerequisites:

    • Node.js (LTS version recommended) and Yarn installed.
    • RedwoodJS CLI installed (yarn global add redwoodjs/cli).
    • Access to a database supported by Prisma (PostgreSQL recommended).
    • A Sinch account with access to the Verification API.
    • Basic understanding of RedwoodJS, React, GraphQL, and Prisma.
  • Outcome: A RedwoodJS application where users with a verified phone number, after entering the correct password, must enter an OTP code sent by Sinch to their registered phone number to complete the login process.

How to Set Up RedwoodJS Project with Authentication

First, create a new RedwoodJS project and set up the basic database authentication.

  1. Create RedwoodJS App:

    bash
    yarn create redwood-app ./redwood-sinch-otp
    cd redwood-sinch-otp
  2. Configure Database:

    • Edit schema.prisma to connect to your database (e.g., PostgreSQL). Update the datasource db block:

      prisma
      // schema.prisma
      datasource db {
        provider = "postgresql" // Or your chosen DB
        url      = env("DATABASE_URL")
      }
      
      generator client {
        provider      = "prisma-client-js"
        binaryTargets = "native"
      }
    • Create a .env file in the project root and add your database connection string:

      plaintext
      # .env
      DATABASE_URL=postgresql://user:password@host:port/database
  3. Set up Redwood dbAuth:

    bash
    yarn rw setup auth dbAuth
    • Follow the post-install instructions printed in the terminal carefully. This involves adding fields to your User model in schema.prisma and configuring api/src/functions/auth.js.
  4. Define Initial User Schema:

    • Modify schema.prisma according to the dbAuth setup instructions. Your User model should look something like this initially:

      prisma
      // schema.prisma
      model User {
        id                  Int       @id @default(autoincrement())
        email               String    @unique
        hashedPassword      String
        salt                String
        resetToken          String?
        resetTokenExpiresAt DateTime?
      
        // Add other user fields as needed, like name
        name                String?
      }
  5. Apply Database Migrations:

    bash
    yarn rw prisma migrate dev --name initial-setup
  6. Generate Basic Auth Pages:

    bash
    yarn rw generate dbAuth
    • This creates Login, Signup, Forgot Password, and Reset Password pages and components. Follow the post-install instructions, especially regarding redirect configuration after login/signup (e.g., in web/src/pages/LoginPage/LoginPage.js).
  7. Generate SESSION_SECRET:

    • Run yarn rw g secret and copy the output.

    • Add it to your .env file:

      plaintext
      # .env
      DATABASE_URL=...
      SESSION_SECRET=paste_your_secret_here
    • Important: Keep SESSION_SECRET private and unique for each environment. Do not commit it to version control.

At this point, you should have a functional RedwoodJS app with basic email/password authentication. Test the signup and login flow.

Modifying Database Schema for Phone Number Verification

We need to add fields to the User model to store the user's phone number and track whether it has been verified (which determines if the OTP step is required). Since Sinch will generate and manage the OTP value and its expiry, we don't need fields in our database to store the OTP itself.

  1. Update schema.prisma:

    prisma
    // schema.prisma
    model User {
      id                  Int       @id @default(autoincrement())
      email               String    @unique
      phone               String?   @unique // Add phone number (make required later if needed)
      isPhoneVerified     Boolean   @default(false) // Track if phone verified (for OTP enforcement)
      // We don't need otp or otpExpiresAt if Sinch handles generation/verification
      hashedPassword      String
      salt                String
      resetToken          String?
      resetTokenExpiresAt DateTime?
      name                String?
    }
    • phone: Stores the user's phone number in E.164 format (e.g., +15551234567). Initially optional to allow signup without it.
    • isPhoneVerified: Boolean flag. We'll only enforce OTP for users where this is true. This flag should be set after the user successfully verifies their phone number for the first time (typically via a profile settings page).
  2. Apply Migration:

    bash
    yarn rw prisma migrate dev --name add-phone-verification-fields

Integrating Sinch Verification API with RedwoodJS

Now, let's set up the Sinch integration to send OTP SMS messages.

  1. Get Sinch Credentials:

    • Log in to your Sinch Dashboard.
    • Navigate to Verification > Apps.
    • Create a new Verification App or use an existing one.
    • Find your Application Key and Application Secret.
    • Ensure SMS is enabled as a verification method for your app within the Sinch settings.
  2. Store Credentials Securely:

    • Add the Sinch Key and Secret to your .env file:

      plaintext
      # .env
      DATABASE_URL=...
      SESSION_SECRET=...
      SINCH_APP_KEY=your_sinch_application_key
      SINCH_APP_SECRET=your_sinch_application_secret
    • Never commit these keys to version control. Add .env to your .gitignore file (Redwood does this by default).

  3. Sinch API Endpoint:

    • The primary Sinch Verification API endpoints we'll use are:
      • Initiate Verification: POST https://verificationapi-v1.sinch.com/verification/v1/verifications
      • Report Verification (Verify Code): PUT https://verificationapi-v1.sinch.com/verification/v1/verifications/number/{phoneNumber}

Implementing Core OTP Logic in RedwoodJS

We'll create services and modify the dbAuth handler to manage the OTP request and verification flow using Sinch.

  1. Install node-fetch (if needed): RedwoodJS typically includes fetch globally, but explicitly adding it can sometimes avoid issues, especially in older Node versions or specific deployment environments. If you encounter fetch is not defined errors on the API side, install it:

    bash
    yarn workspace api add node-fetch@2 # Use v2 for CommonJS compatibility if needed

    And import it where needed: import fetch from 'node-fetch'

  2. Create OTP Helper Functions (Optional):

    • Since Sinch generates the OTP, we don't need generateOtp or getOtpExpiry functions for the core flow. You can keep api/src/lib/otp.ts for other potential helpers or remove it if unused.
  3. Create Sinch Service:

    • Generate a Redwood service to encapsulate Sinch interactions:

      bash
      yarn rw g service sinch
    • Implement functions to initiate verification (sendOtpSms) and verify the code (verifySinchOtp):

      typescript
      // api/src/services/sinch/sinch.ts
      import fetch from 'node-fetch' // Or rely on global fetch if available
      import type { Response as FetchResponse } from 'node-fetch' // If using node-fetch v2
      import { logger } from 'src/lib/logger'
      
      const SINCH_API_BASE_URL = 'https://verificationapi-v1.sinch.com/verification/v1'
      const APP_KEY = process.env.SINCH_APP_KEY
      const APP_SECRET = process.env.SINCH_APP_SECRET
      
      interface SinchVerificationResponse {
        id?: string // Verification ID from Sinch
        // Add other relevant fields based on Sinch docs if needed
        [key: string]: any
      }
      
      interface SinchReportResponse {
        id?: string
        method?: string
        status?: string // e.g., "SUCCESSFUL", "FAIL", "DENIED"
        reason?: string
        reference?: string
        source?: string
        // Add other potential fields
      }
      
      interface SinchErrorResponse {
        errorCode: number
        message: string
        reference?: string
      }
      
      // Initiates the Sinch SMS OTP verification process. Sinch generates and sends the code.
      export const sendOtpSms = async (phoneNumber: string): Promise<{ success: boolean; verificationId?: string; error?: string }> => {
        if (!APP_KEY || !APP_SECRET) {
          logger.error('Sinch App Key or Secret not configured.')
          return { success: false, error: 'SMS service configuration error.' }
        }
      
        // Basic E.164 format check (improve as needed)
        if (!/^\+[1-9]\d{1,14}$/.test(phoneNumber)) {
          logger.error(`Invalid phone number format provided: ${phoneNumber}`)
          return { success: false, error: 'Invalid phone number format.' }
        }
      
        const basicAuth = Buffer.from(`${APP_KEY}:${APP_SECRET}`).toString('base64')
        const verificationUrl = `${SINCH_API_BASE_URL}/verifications`
      
        logger.info(`Requesting OTP SMS via Sinch for phone: ${phoneNumber.substring(0, 5)}...`) // Log partially obscured number
      
        try {
          const response: FetchResponse = await fetch(verificationUrl, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'Authorization': `Basic ${basicAuth}`,
            },
            body: JSON.stringify({
              identity: { type: 'number', endpoint: phoneNumber },
              method: 'sms',
              // No 'custom' or 'code' field here; Sinch generates the OTP.
            }),
          })
      
          const responseBody = await response.json()
      
          if (!response.ok) {
            const errorData = responseBody as SinchErrorResponse
            logger.error({ sinchError: errorData }, `Sinch API error (${response.status}) initiating verification: ${errorData.message}`)
            return { success: false, error: `Failed to send OTP: ${errorData.message || 'Unknown Sinch error'}` }
          }
      
          const successData = responseBody as SinchVerificationResponse
          logger.info(`Sinch verification initiated successfully. ID: ${successData.id}`)
          // Return the verification ID if needed, though verifying by number is also possible
          return { success: true, verificationId: successData.id }
      
        } catch (error) {
          logger.error(error, 'Error calling Sinch verification initiation API')
          return { success: false, error: 'Failed to contact OTP service.' }
        }
      }
      
      // Verifies the OTP code entered by the user against Sinch.
      export const verifySinchOtp = async (phoneNumber: string, code: string): Promise<{ success: boolean; error?: string }> => {
        if (!APP_KEY || !APP_SECRET) {
          logger.error('Sinch App Key or Secret not configured.')
          return { success: false, error: 'SMS service configuration error.' }
        }
      
        const basicAuth = Buffer.from(`${APP_KEY}:${APP_SECRET}`).toString('base64')
        // Use the Report Verification endpoint by number
        const reportUrl = `${SINCH_API_BASE_URL}/verifications/number/${encodeURIComponent(phoneNumber)}`
      
        logger.info(`Verifying OTP with Sinch for phone: ${phoneNumber.substring(0, 5)}...`)
      
        try {
          const response: FetchResponse = await fetch(reportUrl, {
            method: 'PUT', // Use PUT for the report endpoint
            headers: {
              'Content-Type': 'application/json',
              'Authorization': `Basic ${basicAuth}`,
            },
            body: JSON.stringify({
              method: 'sms', // Specify the method being verified
              sms: { code: code }, // Provide the code entered by the user
            }),
          })
      
          const responseBody = await response.json()
      
          if (!response.ok) {
            const errorData = responseBody as SinchErrorResponse
            // Sinch might return 404 if no verification active, or 400 for bad code format etc.
            logger.error({ sinchError: errorData }, `Sinch API verification error (${response.status}): ${errorData.message}`)
            // Map common Sinch errors to user-friendly messages if desired
            if (response.status === 400 || response.status === 404) {
              return { success: false, error: 'Invalid or expired OTP code.' }
            }
            return { success: false, error: `OTP verification failed: ${errorData.message || 'Sinch error'}` }
          }
      
          const reportData = responseBody as SinchReportResponse
          // Check the status field in the response body according to Sinch documentation
          if (reportData.status === 'SUCCESSFUL') {
            logger.info(`Sinch verification successful for ${phoneNumber.substring(0, 5)}...`)
            return { success: true }
          } else {
            logger.warn(`Sinch verification status: ${reportData.status} (Reason: ${reportData.reason || 'N/A'}) for ${phoneNumber.substring(0, 5)}...`)
            return { success: false, error: 'Invalid or expired OTP code.' } // Generic message for failed statuses
          }
      
        } catch (error) {
          logger.error(error, 'Error calling Sinch verification report API')
          return { success: false, error: 'Failed to contact OTP verification service.' }
        }
      }
  4. Create GraphQL Mutations for OTP:

    • Generate the SDL and service stubs:

      bash
      yarn rw g sdl otp --no-crud
    • Define the mutations in the SDL:

      graphql
      # api/src/graphql/otp.sdl.ts
      export const schema = gql`
        type Mutation {
          """
          Requests an OTP to be sent via Sinch to the user's registered phone number.
          Requires standard authentication (valid session cookie from initial password login).
          Used for resending OTP during login or for initial phone verification.
          """
          requestOtp: OtpResponse! @requireAuth
      
          """
          Verifies the OTP entered by the user against Sinch.
          Requires standard authentication. If successful, the login process is complete.
          """
          verifyOtp(otp: String!): OtpResponse! @requireAuth
        }
      
        type OtpResponse {
          success: Boolean!
          message: String
          # Add any other relevant fields, e.g., remaining attempts if tracked
        }
      `
    • Implement the service logic:

      typescript
      // api/src/services/otp/otp.ts
      import { db } from 'src/lib/db'
      import { requireAuth, context } from 'src/lib/auth' // Ensure context is imported if used directly
      import { logger } from 'src/lib/logger'
      import { sendOtpSms, verifySinchOtp } from 'src/services/sinch/sinch'
      import type { MutationResolvers } from 'types/graphql' // Use generated types
      
      // Helper to get current authenticated user's phone details
      const getCurrentUserPhone = async () => {
        const user = context.currentUser
        if (!user?.id) {
          // requireAuth should prevent this, but double-check
          throw new Error("User not authenticated.")
        }
        // Fetch directly using the authenticated user's ID from context
        const dbUser = await db.user.findUnique({
          where: { id: user.id },
          select: { phone: true, isPhoneVerified: true }
        })
        if (!dbUser?.phone) {
          // This case might occur if phone is optional and not set yet
          throw new Error("User does not have a registered phone number.")
        }
        // Return phone number and verification status
        return { phone: dbUser.phone, isPhoneVerified: dbUser.isPhoneVerified }
      }
      
      export const requestOtp: MutationResolvers['requestOtp'] = async () => {
        // requireAuth() is handled by the directive on the SDL
        const { phone } = await getCurrentUserPhone() // Gets phone, throws if no user or no phone
      
        // Call Sinch to initiate verification (Sinch sends the code)
        const result = await sendOtpSms(phone)
      
        if (!result.success) {
          // Log the specific error internally, return generic message
          logger.error(`Failed to request OTP for user ${context.currentUser.id}: ${result.error}`)
          return { success: false, message: result.error || 'Failed to initiate OTP.' } // Or a more generic message
        }
      
        logger.info(`OTP requested successfully via Sinch for user ${context.currentUser.id}`)
        return { success: true, message: 'A new OTP has been sent to your registered phone number.' }
      }
      
      export const verifyOtp: MutationResolvers['verifyOtp'] = async ({ otp }) => {
        // requireAuth() handled by directive
        const userId = context.currentUser.id
        const { phone, isPhoneVerified } = await getCurrentUserPhone() // Get phone and verification status
      
        // Validate OTP format (basic example - adjust length based on Sinch config)
        if (!/^\d{4,8}$/.test(otp)) {
          return { success: false, message: 'Invalid OTP format.' }
        }
      
        // Verify the OTP with Sinch using the phone number and the user-provided code
        const result = await verifySinchOtp(phone, otp)
      
        if (!result.success) {
          // Log failure, return generic message to avoid information leakage
          logger.warn(`OTP verification failed for user ${userId}: ${result.error}`)
          return { success: false, message: result.error || 'Invalid or expired OTP.' }
        }
      
        // --- OTP Verified Successfully with Sinch ---
        logger.info(`OTP verified successfully via Sinch for user ${userId}`)
      
        // If the phone wasn't already marked as verified, update the flag now.
        // This is crucial if using this flow for initial phone verification too.
        if (!isPhoneVerified) {
          await db.user.update({
            where: { id: userId },
            data: {
              isPhoneVerified: true,
            },
          })
          logger.info(`Marked phone as verified for user ${userId}`)
        }
      
        // IMPORTANT: Login is now complete.
        // Redwood's standard session cookie issued during the initial password
        // login (`logIn` function) is already active and valid. No further session
        // action is typically needed here. The frontend will navigate.
      
        return { success: true, message: 'Verification successful. Logging you in...' }
      }
  5. Modify dbAuth Login:

    • Edit api/src/functions/auth.js (or .ts). Modify the login.handler to check if OTP is required and trigger the initial Sinch SMS send if necessary.

      javascript
      // api/src/functions/auth.js (Illustrative - adapt to your exact setup)
      import { db } from 'src/lib/db'
      import { logger } from 'src/lib/logger' // Ensure logger is imported
      // Import your Sinch service function
      import { sendOtpSms } from 'src/services/sinch/sinch' // Adjust import path if needed
      import { DbAuthHandler } from '@redwoodjs/auth-dbauth-api' // Ensure correct import
      
      export const handler = DbAuthHandler(db, {
        dbAuthHandlerOptions: {
          // ... other options (cookie settings, secret from env)
          cookie: {
            // Example cookie settings (adjust as needed)
            secure: process.env.NODE_ENV === 'production',
            sameSite: 'Lax', // Or 'Strict' or 'None' (if needed with secure)
            path: '/',
          },
          secret: process.env.SESSION_SECRET, // Load from environment
          authFields: { // Ensure these match your User model
            id: 'id',
            username: 'email', // Using email as the login username field
            hashedPassword: 'hashedPassword',
            salt: 'salt',
            // Add other fields if dbAuth needs them (e.g., roles)
          },
          login: {
            // Custom handler executed *after* password validation succeeds
            handler: async (user) => {
              // 'user' object here is the user record found by email/password check
              logger.info(`User ${user.id} passed password check.`);
      
              // Check if this user has a verified phone number requiring OTP
              // We need to query the DB again as the 'user' object from dbAuth
              // might not include custom fields like phone/isPhoneVerified.
              const userWithPhone = await db.user.findUnique({
                where: { id: user.id },
                select: { phone: true, isPhoneVerified: true }
              });
      
              if (userWithPhone?.phone && userWithPhone.isPhoneVerified) {
                logger.info(`User ${user.id} has a verified phone and requires OTP verification.`);
      
                // --- Trigger OTP Send via Sinch ---
                // This should run asynchronously. We don't wait for it here.
                // Error handling should be done within sendOtpSms (logging).
                // We pass the user's verified phone number.
                sendOtpSms(userWithPhone.phone).catch(e => {
                  // Log errors from this background task
                  logger.error(e, `Background OTP send failed for user ${user.id}`);
                  // Consider alerting/monitoring for persistent failures here
                });
      
                // IMPORTANT: Indicate to the frontend that OTP is required.
                // Throwing a specific error is a common way to signal this.
                // The frontend `useAuth().logIn()` call will catch this error.
                throw new Error('AUTH_OTP_REQUIRED');
      
              } else {
                // No OTP needed (no phone, phone not set, or phone not verified)
                logger.info(`User ${user.id} logging in without OTP (phone: ${userWithPhone?.phone ? 'present' : 'missing'}, verified: ${userWithPhone?.isPhoneVerified ?? 'N/A'}).`);
                // Proceed with standard login: dbAuth will set the session cookie.
                return user;
              }
            },
          },
          // ... other handlers (signup, forgotPassword, resetPassword)
        },
      })
    • Handling AUTH_OTP_REQUIRED: If you throw this error, the useAuth().logIn() call on the frontend will fail. You'll need to catch this specific error message in your LoginPage component and transition the UI to the OTP input state instead of treating it as a generic login failure.

    • Note on "Fire and Forget": The sendOtpSms(...).catch(...) call runs in the background. If the Sinch API call fails silently (e.g., network issue, temporary Sinch outage), the user might not receive the OTP, but the login flow will still proceed to the OTP entry step. For production systems, consider using a more robust background job queue (like Redis with BullMQ) to handle sending the SMS, allowing for retries and better error monitoring. However, for simplicity, this guide uses the basic "fire and forget" approach with logging.

Building the Frontend OTP Components in RedwoodJS

Modify the LoginPage to handle the OTP step.

  1. Add State for OTP:

    • In web/src/pages/LoginPage/LoginPage.js (or .tsx), add state to manage the UI flow:

      jsx
      // web/src/pages/LoginPage/LoginPage.js
      import React, { useState, useEffect } from 'react' // Import React if needed
      import { navigate, routes } from '@redwoodjs/router'
      import { MetaTags } from '@redwoodjs/web'
      import { toast } from '@redwoodjs/web/toast'
      import { Form, Label, TextField, PasswordField, Submit, FieldError } from '@redwoodjs/forms'
      import { useAuth } from '@redwoodjs/auth'
      // Import OTP mutations (define below or import)
      import { useMutation, gql } from '@redwoodjs/web'
      
      // Define GraphQL Mutations (place outside component or in a separate file)
      const REQUEST_OTP_MUTATION = gql`
        mutation RequestOtpMutation {
          requestOtp {
            success
            message
          }
        }
      `
      
      const VERIFY_OTP_MUTATION = gql`
        mutation VerifyOtpMutation($otp: String!) {
          verifyOtp(otp: $otp) {
            success
            message
          }
        }
      `
      
      const LoginPage = () => {
        const { isAuthenticated, logIn, currentUser, loading: authLoading } = useAuth() // Use authLoading
        const [uiState, setUiState] = useState('login') // 'login', 'otp', 'loading'
        // const [usernameForOtp, setUsernameForOtp] = useState('') // Not usually needed with currentUser
      
        useEffect(() => {
          // If already authenticated (e.g., page refresh with valid cookie), redirect
          if (isAuthenticated) {
            navigate(routes.home()) // Or dashboard
          }
        }, [isAuthenticated])
      
        const onSubmitLogin = async (data) => {
          setUiState('loading')
          const response = await logIn({ username: data.username, password: data.password })
      
          if (response.message) {
            // This usually indicates success *before* OTP check in our custom handler
            // We rely on the error or lack thereof to determine flow
            // toast(response.message); // Might be confusing here
          } else if (response.error) {
            // Check for our specific OTP error from the backend
            if (response.error.message === 'AUTH_OTP_REQUIRED') {
              toast('Password correct. Please enter the OTP sent to your phone.')
              // setUsernameForOtp(data.username); // Store if needed, though currentUser should be set
              setUiState('otp') // Show the OTP form
            } else {
              // Handle other login errors (invalid credentials, etc.)
              toast.error(response.error.message || 'Login Failed')
              setUiState('login') // Return to login form
            }
          } else {
            // Login successful immediately (no OTP was required)
            toast.success('Welcome back!')
            navigate(routes.home()) // Redirect to appropriate page
            // No need to setUiState here as navigate will unmount component
          }
          // If we didn't navigate or switch to OTP, reset loading state
          if (uiState === 'loading' && response.error?.message !== 'AUTH_OTP_REQUIRED') {
            setUiState('login')
          }
        }
      
        // Handler for OTP form submission (implemented in Step 3)
        const onSubmitOtp = async (data) => {
          setUiState('loading') // Set loading state for OTP verification
          // Call the verifyOtp mutation (see Step 3)
          verifyOtp({ variables: { otp: data.otp } });
        }
      
        // Handler for resending OTP (implemented in Step 3)
        const handleResendOtp = () => {
          setUiState('loading') // Set loading state for resend request
          // Call the requestOtp mutation (see Step 3)
          requestOtp();
        }
      
        // --- OTP Mutation Hooks (see Step 3) ---
        const [verifyOtp] = useMutation(VERIFY_OTP_MUTATION, {
          onCompleted: (data) => {
            if (data.verifyOtp.success) {
              toast.success(data.verifyOtp.message || 'Login successful!')
              // Auth state should update automatically via useAuth, triggering useEffect
              // Navigate on successful verification
              navigate(routes.home())
            } else {
              toast.error(data.verifyOtp.message || 'OTP Verification Failed')
              setUiState('otp') // Stay on OTP form on failure
            }
          },
          onError: (error) => {
            toast.error(error.message || 'An error occurred during OTP verification.')
            setUiState('otp') // Stay on OTP form on error
          }
        });
      
        const [requestOtp] = useMutation(REQUEST_OTP_MUTATION, {
           onCompleted: (data) => {
             if (data.requestOtp.success) {
               toast.success(data.requestOtp.message || 'New OTP sent.')
             } else {
               toast.error(data.requestOtp.message || 'Failed to send new OTP.')
             }
             setUiState('otp') // Stay on OTP form after request attempt
           },
           onError: (error) => {
             toast.error(error.message || 'An error occurred requesting a new OTP.')
             setUiState('otp') // Stay on OTP form on error
           }
        });
      
      
        return (
          <>
            <MetaTags title="Login" />
      
            <h1>Login</h1>
      
            {uiState === 'login' && (
              // Standard Login Form
              <Form onSubmit={onSubmitLogin}>
                <Label name="username" errorClassName="error">Username (Email)</Label>
                <TextField name="username" validation={{ required: true }} errorClassName="error" />
                <FieldError name="username" className="error" />
      
                <Label name="password" errorClassName="error">Password</Label>
                <PasswordField name="password" validation={{ required: true }} errorClassName="error" />
                <FieldError name="password" className="error" />
      
                <Submit disabled={authLoading || uiState === 'loading'}>{authLoading || uiState === 'loading' ? 'Logging in...' : 'Login'}</Submit>
              </Form>
            )}
      
            {uiState === 'otp' && (
              // OTP Input Form (see Step 2)
              <Form onSubmit={onSubmitOtp}>
                {/* OTP Form content here */}
              </Form>
            )}
      
            {uiState === 'loading' && (
              <div>Loading...</div> // Generic loading indicator
            )}
      
            {/* Links to signup, forgot password etc. */}
          </>
        )
      }
      
      export default LoginPage
  2. Create OTP Input Form:

    • Add the form JSX that renders when uiState is 'otp'.

      jsx
      // Inside the return statement of LoginPage component, replace the placeholder:
      {uiState === 'otp' && (
        <Form onSubmit={onSubmitOtp}>
          <h2>Enter OTP</h2>
          <p>Enter the 6-digit code sent by SMS to your registered phone number.</p>
      
          <Label name="otp" errorClassName="error">OTP Code</Label>
          <TextField
            name="otp"
            validation={{
              required: true,
              pattern: { value: /^\d{6}$/, message: 'Enter 6 digits' } // Adjust pattern/length if Sinch uses different length
            }}
            errorClassName="error"
            maxLength="6" // Match expected length
          />
          <FieldError name="otp" className="error" />
      
          <Submit disabled={uiState === 'loading'}>{uiState === 'loading' ? 'Verifying...' : 'Verify OTP'}</Submit>
      
          <button type="button" onClick={handleResendOtp} disabled={uiState === 'loading'}>
            {uiState === 'loading' ? 'Sending...' : 'Resend OTP'}
          </button>
        </Form>
      )}

Frequently Asked Questions About RedwoodJS SMS 2FA

What is the difference between Sinch SMS API and Sinch Verification API?

The Sinch Verification API is purpose-built for phone number verification workflows, handling OTP generation, delivery, and validation automatically. It achieves up to 11% higher conversion rates through multiple verification methods (SMS, FlashCall, Data) with automatic fallbacks. The standard SMS API requires manual OTP generation and validation logic. For 2FA implementations, always use the Verification API.

Does Sinch Verification API work with RedwoodJS v7?

While technically compatible, RedwoodJS v7 is no longer actively maintained. This guide targets RedwoodJS v8.6+, which includes enhanced dbAuth features like improved session management and WebAuthn support. Upgrade to v8 using the official upgrade guide before implementing 2FA.

How long does Sinch retain verification reports?

Sinch retains verification reports and delivery receipts for 14 days after creation. Ensure your application processes callbacks and stores necessary verification data (verification IDs, timestamps, success status) within this retention window for compliance and auditing purposes.

Can you use Sinch OTP verification without storing phone numbers in the database?

No. You must store phone numbers in your database to implement 2FA effectively. The phone field (E.164 format) and isPhoneVerified boolean flag are essential for determining which users require OTP verification during login. Without database storage, you cannot enforce 2FA consistently across sessions.

What happens if the Sinch SMS fails to deliver?

If Sinch fails to deliver an SMS (network issues, invalid number, carrier problems), the user won't receive the OTP code. Sinch's Verification API automatically tracks delivery status and can trigger fallback methods (FlashCall, Data verification) if configured. Implement robust error handling and allow users to request new codes with rate limiting (e.g., 3 attempts per hour).

How much does Sinch Verification API cost per verification?

Sinch charges per verification attempt, with pricing varying by destination country. Check your Sinch Dashboard pricing page for current rates. Implement rate limiting (per-IP, per-user, per-phone) to prevent abuse and control costs. Failed verifications still incur charges, so validate phone number formats before initiating verification.

Can you implement OTP verification for signup instead of login?

Yes. Modify the signup handler in api/src/functions/auth.js instead of login. After creating the user account, initiate Sinch verification and mark the account as "pending verification" until the user completes OTP validation. This prevents fake account creation and ensures valid contact information.

What phone number format does Sinch require?

Sinch requires E.164 format for all phone numbers: +[country code][subscriber number] without spaces, hyphens, or parentheses. Examples: +14155552671 (US), +442071234567 (UK), +8190123456789 (Japan). Validate format using regex /^\+[1-9]\d{1,14}$/ on both client and server before sending to Sinch.

How do you test Sinch OTP integration in development?

Sinch provides test credentials in your Dashboard for development. Use your personal phone number for initial testing. For automated testing, consider mock services that simulate Sinch's Verification API responses, or use Sinch's sandbox environment if available. Never test with production credentials committed to version control.

Does RedwoodJS dbAuth support WebAuthn for biometric 2FA?

Yes. RedwoodJS v8 introduced WebAuthn support for biometric authentication (TouchID, FaceID, USB fingerprint readers) alongside traditional passwords. You can implement both SMS OTP and WebAuthn as 2FA options, allowing users to choose their preferred second factor. See the dbAuth v8 documentation for WebAuthn implementation details.

Conclusion: Production-Ready RedwoodJS 2FA Implementation

You've successfully implemented SMS-based OTP two-factor authentication in your RedwoodJS application using Sinch's Verification API. This implementation significantly enhances account security by requiring both password knowledge and phone access for login.

Key Implementation Highlights:

  • Sinch Verification API handles OTP generation, delivery, and validation automatically with 14-day report retention
  • RedwoodJS v8.6+ dbAuth provides enhanced session management and optional WebAuthn support for biometric authentication
  • Node.js v22 LTS offers long-term support until April 2027 for production stability
  • Prisma v5/v6 enables type-safe database operations with PostgreSQL v9.6+ compatibility
  • E.164 phone format validation prevents delivery failures and ensures global compatibility

Next Steps to Enhance Your Implementation:

  1. Add Multi-Method Verification: Implement FlashCall and Data verification as fallbacks for SMS delivery failures
  2. Implement Rate Limiting: Protect against abuse with per-IP, per-user, and per-phone attempt limits
  3. Set Up Monitoring: Track verification conversion rates, delivery times, and failure patterns via Sinch analytics
  4. Enable WebAuthn: Offer biometric 2FA options (TouchID, FaceID) alongside SMS OTP in RedwoodJS v8
  5. Implement Backup Codes: Generate one-time recovery codes for users who lose phone access
  6. Add Phone Verification UI: Create profile pages for users to add, verify, and manage phone numbers
  7. Configure Production Environment: Set up proper environment variables, HTTPS, and security headers for deployment

Additional Resources:

Your RedwoodJS application now provides robust two-factor authentication that protects user accounts even when passwords are compromised. The Sinch Verification API's multi-method approach with automatic fallbacks ensures high delivery rates and optimal user experience.

Frequently Asked Questions

Can I customize the OTP length with Sinch?

Yes. Check Sinch's documentation, as the length and format of the OTP may be configurable in your Sinch account settings. Ensure that your frontend validation and the backend handling align with the configured OTP length from Sinch.

How to set up two-factor authentication in RedwoodJS?

Two-factor authentication (2FA) can be implemented in RedwoodJS using the Sinch Verification API. This involves modifying your RedwoodJS schema, setting up your Sinch account and configuring your RedwoodJS application to communicate with the Sinch API. This will add an extra layer of security by requiring an OTP sent via SMS in addition to the standard password login.

What is the Sinch Verification API used for in RedwoodJS?

The Sinch Verification API is used to send and verify one-time passwords (OTPs) via SMS messages for two-factor authentication. Sinch handles OTP generation, sending, and verification, simplifying the implementation process in RedwoodJS. You'll need a Sinch account and API credentials to use this service.

Why use Sinch for OTP in RedwoodJS?

Sinch offers dedicated verification features that handle OTP generation and verification. This offloads complexity from your RedwoodJS application and allows you to focus on the integration and user flow rather than managing OTP complexities directly. Sinch is a third-party service specifically designed for this purpose.

How to integrate Sinch API with RedwoodJS?

To integrate Sinch, obtain your API key and secret from your Sinch dashboard, and store them securely in your `.env` file. Create a Redwood service to encapsulate Sinch API calls (initiate verification and verify OTP). Modify your `dbAuth` handler to trigger OTP requests after successful password validation and add GraphQL mutations for requesting and verifying OTPs on the frontend.

What fields need to be added to the Prisma schema for 2FA?

You need to add `phone` (String, optional initially) and `isPhoneVerified` (Boolean, default false) to your `User` model in `schema.prisma`. The `phone` field stores the user's phone number, while `isPhoneVerified` tracks whether their phone has been verified through the OTP process.

How does Sinch handle OTP generation and verification?

Sinch generates the OTP and sends it directly to the user's phone number via SMS. Your RedwoodJS application only needs to initiate the verification request and then verify the code entered by the user against the Sinch API, no need for server-side OTP generation or storage.

When should a user receive an OTP during login?

Users receive an OTP after successfully entering their correct password *only if* they have a registered phone number, and the `isPhoneVerified` flag on their user record is set to `true`. This ensures that OTP is only enforced for users who have completed the phone verification process.

How to modify the RedwoodJS LoginPage for OTP input?

Add state variables to manage UI transitions between the login form and the OTP input form. Catch the `AUTH_OTP_REQUIRED` error thrown by the `dbAuth` handler after successful password validation to trigger the OTP input state. Implement a submission handler for the OTP form that calls a `verifyOtp` GraphQL mutation.

What is the 'AUTH_OTP_REQUIRED' error?

This specific error is thrown by the custom login handler in `api/src/functions/auth.js` after a successful password check *if* the user has a verified phone number and requires OTP. It signals to the frontend to transition to the OTP input state.

How to handle resending OTPs in RedwoodJS?

Implement a `requestOtp` GraphQL mutation that calls the Sinch API to resend an OTP to the user's registered phone number. Add a button to your OTP input form that triggers this mutation. Include loading state handling to disable the resend button while the request is in progress.

How to test 2FA implementation in RedwoodJS?

After completing the setup and implementation steps, create a user account, register a phone number, and set the `isPhoneVerified` flag to `true`. Then, test the login process by entering the correct password, and verify that you are prompted for the OTP sent via SMS.

How to update the isPhoneVerified flag in RedwoodJS?

The `isPhoneVerified` flag should be updated in the `verifyOtp` resolver after Sinch successfully verifies the user-provided OTP. This typically occurs during the user's initial phone verification or in a profile update where the user adds/changes their phone number.

What if the Sinch API call fails during login?

The "fire and forget" approach used in the guide for sending the initial SMS relies heavily on logging to track failures. For production, using a background job queue (like Redis with BullMQ) for sending SMS with retry mechanisms is recommended.