code examples

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

TOTP 2FA for Next.js with Twilio Verify: Complete Implementation Guide 2025

Implement Time-based One-Time Password (TOTP) two-factor authentication in Next.js using Twilio Verify API and Authy. Includes RFC 6238 compliance, security best practices, and production-ready code examples.

Enhance your Next.js application's security by adding Time-based One-Time Password (TOTP) Two-Factor Authentication (2FA) using Twilio Verify and the Twilio Authy app. This guide walks you through the complete implementation, enabling your users to secure their accounts by verifying their identity through codes generated on their Authy-linked device after initial password login.

This implementation adds a robust security layer, mitigating risks associated with compromised passwords and unauthorized access. By the end of this guide, you'll have a functional 2FA flow integrated into your Next.js application.

TOTP Standard Compliance: This implementation follows RFC 6238 (TOTP: Time-Based One-Time Password Algorithm), an IETF standard published in May 2011. TOTP extends the HMAC-based One-Time Password (HOTP) algorithm by using time as the moving factor, providing short-lived OTP values with enhanced security (source: RFC 6238).

What You'll Build

Integrate Twilio Verify's TOTP functionality into an existing Next.js application that already includes basic user registration, login (with JWT), and a protected dashboard. The integration modifies the login flow:

  1. First-time Login After Registration/Enabling 2FA: User logs in (password check succeeds, JWT issued) → User scans a QR code with the Twilio Authy app → User enters the code from Authy → Account is verified and linked → User accesses the dashboard.
  2. Subsequent Logins: User logs in (password check succeeds, JWT issued) → User enters the current code from the Authy app → Code is verified → User accesses the dashboard.

Problem You'll Solve:

Add a second layer of security beyond just a password, ensuring that even if a user's password is compromised, access requires physical possession of their registered device running the Authy app.

Technologies You'll Use:

  • Next.js: React framework for the frontend and API routes (acting as your Node.js backend). You'll use the Pages Router as per the example project.
  • Twilio Verify API: Handles the generation, validation, and lifecycle management of TOTP factors and challenges via the Authy app. The Verify API v2 provides comprehensive 2FA capabilities including TOTP, SMS, voice, email, and passkeys (source: Twilio Verify API Documentation).
  • Twilio Authy App: Mobile application (iOS/Android) your end-users use to scan QR codes and generate TOTP codes. TOTP provides enhanced security over SMS/email channels as codes work offline and no PII (personally identifiable information) is required (source: Twilio Verify TOTP Overview).
  • MongoDB Atlas: Cloud database for storing user credentials and TOTP factor details.
  • Node.js: Runtime environment for Next.js API routes.
  • twilio Node.js Helper Library: Simplifies interaction with the Twilio API.
  • jsonwebtoken: For managing user sessions via JWTs (assumed to be handled by the starter project).
  • mongoose: ODM for interacting with MongoDB.

What You'll Need Before Starting:

  • Node.js (v16 or later recommended) installed
  • npm or yarn package manager
  • A free Twilio account
  • Twilio Authy app installed on your smartphone (iOS or Android)
  • A free MongoDB Atlas account
  • ngrok installed and authenticated (optional: useful for testing with a public URL, but not required for local TOTP functionality)
  • Basic understanding of Next.js, React, and REST APIs

TOTP Security Benefits:

  • Enhanced Security: Tokens automatically expire (default 30-second window per RFC 6238), and there are no OTPs for fraudsters to intercept, making TOTP more secure than SMS, email, or voice channels.
  • Offline Functionality: Generate and verify TOTP tokens without internet connectivity as long as device time is synchronized.
  • Privacy-Preserving: TOTP doesn't require phone numbers, so no PII is stored in the Twilio Verify system.
  • Lower Cost: TOTP verifications typically cost less than SMS or voice-based 2FA.

TOTP Technical Specifications (RFC 6238 Compliance):

  • Time Step (X): Default 30 seconds. Configure in Twilio Verify Service between 20–60 seconds (source: Twilio Verify Service API).
  • Code Length: Default 6 digits. Configure between 3–8 digits in Twilio Verify Service.
  • Skew: Default 1 time-step. Twilio allows 0–2 time-steps to account for clock drift between client and server.
  • Algorithm: HMAC-SHA-1 (default), with optional HMAC-SHA-256 or HMAC-SHA-512 support per RFC 6238.
  • Time Base (T0): Unix epoch (January 1, 1970, 00:00:00 UTC).

System Architecture:

Note: Ensure your publishing platform supports Mermaid diagram rendering. The diagram below illustrates the intended flow – verify it against your actual implementation.

mermaid
graph LR
    A[User Browser] -- Login Request (User/Pass) --> B(Next.js Frontend);
    B -- API Call /api/users/authenticate --> C{Next.js API Route};
    C -- Query User --> D[(MongoDB Atlas)];
    C -- Password Check OK / JWT Issued --> C;
    alt First Time 2FA / Not Authenticated (user.authenticated == false)
        C -- Redirect --> E{Scan QR Page (/account/scan)};
        E -- Request Factor --> F(API Route /api/code/create);
        F -- Create Factor --> G[Twilio Verify API];
        G -- Binding URI --> F;
        F -- Binding URI --> E;
        E -- Display QR Code --> A;
        A -- User Scans QR --> H(Authy App);
        H -- Generate Code --> A;
        A -- Submit Code --> I{Verify Code Page (/account/code)};
        I -- Verify Request --> J(API Route /api/code/verify);
        J -- Verify New Factor --> G;
        G -- Verification Status (verified) --> J;
        J -- Update user.authenticated=true, store factorSid --> D;
        J -- Success Response --> I;
        I -- Redirect --> K[Dashboard];
    else Subsequent Logins / Already Authenticated (user.authenticated == true)
        C -- Redirect --> I;
        A -- Enter Code --> I;
        I -- Challenge Request --> L(API Route /api/code/challenge);
        L -- Create Challenge (using stored factorSid) --> G;
        G -- Challenge Status (approved) --> L;
        L -- Success Response --> I;
        I -- Redirect --> K;
    end

    style G fill:#F9BBB7,stroke:#333,stroke-width:2px
    style D fill:#97C491,stroke:#333,stroke-width:2px

What You'll Have When You're Done: A Next.js application where users must complete a TOTP verification step using the Authy app after password authentication to access protected routes.


1. Set Up Your Project

Use a starter project that includes basic authentication and database setup.

Clone the Starter Project: Open your terminal and navigate to the directory where you want your project. Clone the repository and install dependencies:

bash
git clone https://github.com/DesmondSanctity/twilio-authy.git
cd twilio-authy
npm install

Important Note: This guide assumes the twilio-authy starter repository exists, is accessible, and contains the necessary base components: Next.js (v13.2.4 with Pages Router is mentioned), base JWT authentication, Mongoose setup, and helper functions like apiHandler and fetchWrapper. Verify the repository's contents and README match these assumptions before proceeding.

Set Up MongoDB Atlas:

  • Go to the MongoDB Atlas website and sign up or log in.
  • Create a new project if necessary.
  • Create a Database: Click "Build a Database." Choose the free M0 tier, select a cloud provider and region. Leave cluster tier settings as default. Name your cluster (e.g., twilio-2fa-demo).
  • Create Database User: Under "Security" → "Database Access," click "Add New Database User." Enter a username (e.g., twilioUser) and generate or create a strong password. Save this password securely. Grant the user "Read and write to any database" privileges (for simplicity in this demo; restrict in production).
  • Configure Network Access: Under "Security" → "Network Access," click "Add IP Address." Select "ALLOW ACCESS FROM ANYWHERE" (0.0.0.0/0). Click "Confirm." (Again, restrict this to specific IPs in production).
  • Get Connection String: Go back to "Database" under "Deployment." Find your cluster and click "Connect." Select "Drivers." Choose "Node.js" and the latest version. Copy the connection string provided. It looks like mongodb+srv://<username>:<password>@<cluster-url>/?retryWrites=true&w=majority.

Set Up Twilio:

  • Log in to your Twilio Console.
  • Find Account SID and Auth Token: On the main dashboard, you'll find your Account SID and Auth Token. Copy these values.
  • Create a Twilio Verify Service:
    • Navigate to "Explore Products" in the left sidebar (or use the search bar).
    • Find and click on "Verify."
    • In the Verify sidebar, click "Services."
    • Click "Create service now."
    • Give your service a friendly name (e.g., NextJS Authy Demo).
    • Under "Verification Channels," ensure TOTP is enabled (disable others like SMS/Call if not needed).
    • Click "Continue."
    • Review settings (defaults work fine for this guide). Configure code length if desired.
    • TOTP Configuration Options (Optional):
      • Issuer: Defaults to service friendly name. This appears in the Authy app to identify your service.
      • Time Step: Defaults to 30 seconds (20–60 second range). Defines how often new TOTP codes are generated.
      • Code Length: Defaults to 6 digits (3–8 digit range). Balance security with user experience.
      • Skew: Defaults to 1 time-step (0–2 range). Allows validation of codes from adjacent time windows to handle clock drift (source: Twilio Verify Service API).
    • Click "Complete Setup" or "Save."
    • Copy the Service SID: On the service's settings page, find and copy the Service SID (starts with VA...).

Security Note – API Key Authentication (Production Recommendation): For production applications, Twilio strongly recommends using API Keys instead of your Account SID and Auth Token. API Keys provide better security through:

  • Credential rotation without downtime
  • Restricted permissions and scope
  • Separate credentials for different environments
  • Easier revocation if compromised

To create an API Key: Navigate to Account → API Keys & Tokens in the Twilio Console. Use the API Key SID as the username and API Key Secret as the password (source: Twilio Verify API Authentication).

Configure Environment Variables:

Create a file named .env in the root directory of your twilio-authy project. Add the following variables, replacing the placeholders with your actual credentials:

Code
# MongoDB Connection String (replace <password> and other placeholders)
MONGODB_URI=mongodb+srv://twilioUser:<YOUR_MONGODB_PASSWORD>@<your-cluster-url>/?retryWrites=true&w=majority

# JWT Secret (use a long, random, secure string)
JWT_SECRET=your-very-secure-and-random-jwt-secret-key

# Twilio Credentials
TWILIO_ACCOUNT_SID=<Your Twilio Account SID starting with AC...>
TWILIO_AUTH_TOKEN=<Your Twilio Auth Token>
TWILIO_VERIFY_SERVICE_SID=<Your Twilio Verify Service SID starting with VA...>
  • MONGODB_URI: Your MongoDB Atlas connection string, with the <password> placeholder replaced by the database user password you created.
  • JWT_SECRET: A long, random string used to sign JSON Web Tokens for session management. Keep this secret.
  • TWILIO_ACCOUNT_SID: Your main Twilio account identifier. Find this on the Twilio Console dashboard.
  • TWILIO_AUTH_TOKEN: Your Twilio secret key. Find this on the Twilio Console dashboard. Treat this like a password.
  • TWILIO_VERIFY_SERVICE_SID: The unique identifier for the Twilio Verify service you created. Find this in the Verify service settings in the Twilio Console.

Why Use Environment Variables? Environment variables keep sensitive credentials out of your source code, making your application more secure and configurable across different environments (development, staging, production).


2. Understand the Authentication Flow

The starter project has a basic Register → Login → Dashboard flow. You'll modify this to incorporate 2FA:

  • JWT Issuance: The initial login process (e.g., in userService.login calling /api/users/authenticate) verifies the password and, upon success, issues a JSON Web Token (JWT). This JWT maintains the user's session for subsequent API requests.

  • 2FA Check Timing: The 2FA verification step happens after the initial password authentication and JWT issuance but before granting access to the final protected resources (like the dashboard). The JWT proves the password was correct; the 2FA code proves possession of the registered device.

  • TOTP Time Synchronization Considerations (RFC 6238): TOTP relies on synchronized time between the client (Authy app) and validation server (Twilio). Due to potential clock drift, RFC 6238 recommends:

    • Maximum 1 time-step network delay allowance for validation
    • Resynchronization mechanisms if drift exceeds thresholds
    • Twilio's skew parameter (default 1, max 2) handles this by validating codes from adjacent time windows
    • For 30-second time steps with skew=1, the maximum acceptable time drift is approximately 89 seconds (29s in current window + 60s for 2 backward steps) (source: RFC 6238 Section 5.2)
  • Initial Setup Flow (First Time Enabling 2FA):

    1. User registers or logs in (password check succeeds, JWT issued).
    2. System checks if user.authenticated (a flag in the User model) is false.
    3. Redirect to /account/scan.
    4. /account/scan page calls /api/code/create endpoint.
    5. /api/code/create uses Twilio Verify to create a newFactor (TOTP type) for the user. Twilio returns a binding.uri.
    6. Use the binding.uri to generate a QR code displayed on the /account/scan page.
    7. User scans the QR code using the Twilio Authy app. Authy now generates codes for this account.
    8. User is redirected or navigated to /account/code.
    9. User enters the 6-digit code from Authy into the form on /account/code.
    10. Form submits to /api/code/verify.
    11. /api/code/verify uses Twilio Verify to validate the newFactor using the submitted code.
    12. On success, Twilio confirms the factor is verified. The API updates the user's record in MongoDB (sets authenticated: true and stores factorSid). Temporary binding keys (user.keys) are cleared.
    13. Redirect to /dashboard.
  • Subsequent Login Flow (User Already Has 2FA Enabled):

    1. User logs in (password check succeeds, JWT issued).
    2. System checks if user.authenticated is true.
    3. Redirect to /account/code.
    4. User enters the current 6-digit code from Authy into the form on /account/code.
    5. Form submits to /api/code/challenge.
    6. /api/code/challenge uses Twilio Verify to create and check a challenge using the user's stored factorSid and the submitted code.
    7. On success (challenge status is approved), redirect to /dashboard.

3. Implement Your Backend API (Next.js API Routes)

The core logic resides in helper functions and Next.js API routes. The starter project already has user authentication logic; you'll add the Twilio-specific parts.

Location: helpers/api/user-repo.js (Data access and Twilio logic), pages/api/code/ (API endpoints)

Update Your Mongoose Schema: Ensure your Mongoose User schema (likely defined in helpers/api/db.js or a similar location in the starter project) includes these fields:

  • factorSid: { type: String } – Store the Twilio Factor SID after successful verification.
  • authenticated: { type: Boolean, default: false } – Track if the user has completed the initial 2FA setup.
  • keys: { type: Object } – Store factor.binding information temporarily (including the URI for the QR code) between factor creation and verification. CRITICAL SECURITY WARNING: See security notes below.

CRITICAL SECURITY WARNING – Storing Sensitive Factor Data:

The example code stores factor.binding (which includes binding.secret) temporarily in the MongoDB keys field. This is NOT recommended for production due to these security risks:

  1. Secret Exposure: The binding.secret is cryptographic material you should treat like a password. Storing it in plaintext in the database increases your attack surface.
  2. Compliance Risk: Storing secrets without encryption may violate security compliance standards (PCI DSS, SOC 2, etc.).
  3. Data Breach Impact: If your database is compromised, attackers gain access to TOTP secrets and can generate valid codes.

Production-Ready Alternatives:

  1. Session Storage (Recommended): Store the binding URI in server-side session storage (e.g., Redis, encrypted session cookies) instead of MongoDB. Clear the session after successful verification.
  2. Encryption at Rest: If database storage is required, encrypt the keys field using application-level encryption (AES-256) with proper key management (AWS KMS, Azure Key Vault, HashiCorp Vault).
  3. Time-Limited Tokens: Implement automatic cleanup of unverified factors after a short timeout (e.g., 15 minutes).
  4. No Storage: Pass the binding URI directly to the frontend after factor creation without storing it server-side. This requires the user to complete verification in the same session.

MongoDB Atlas provides encryption at rest by default, but application-level encryption provides additional defense-in-depth (source: MongoDB Security Best Practices).

Key Helper Functions (helpers/api/user-repo.js):

These functions interact directly with the Twilio Node.js SDK. Ensure the Twilio client is initialized (the starter project likely does this using the environment variables).

javascript
// helpers/api/user-repo.js
// ... (existing code for User model, connectDb, jwtMiddleware, errorHandler)

// Make sure you have the Twilio client initialized
const accountSid = process.env.TWILIO_ACCOUNT_SID;
const authToken = process.env.TWILIO_AUTH_TOKEN;
const serviceSid = process.env.TWILIO_VERIFY_SERVICE_SID;
const client = require('twilio')(accountSid, authToken); // Already present in starter

// ... (existing usersRepo functions: authenticate, getAll, etc.)

// Add these new functions to the file:
async function createFactor({ name, identity }) {
    // 'identity' should be the user's unique ID (e.g., MongoDB ObjectId as string)
    // 'name' is a friendly name for the factor (e.g., user's email or username)
    const user = await User.findById(identity);
    if (!user) throw 'User not found';
    if (!serviceSid) throw 'Twilio Verify Service SID not configured';

    console.log(`Creating factor for identity: ${identity} in service: ${serviceSid}`);

    try {
        const factor = await client.verify.v2
            .services(serviceSid)
            .entities(identity.toString()) // Ensure identity is a string
            .newFactors.create({
                friendlyName: `${name}'s Authy TOTP`,
                factorType: 'totp'
            });

        console.log(`Factor created: ${factor.sid}`);

        // IMPORTANT: Temporarily store the binding URI and Factor SID.
        // The factorSid needs to be saved *after* verification.
        // The binding URI is needed *before* verification to show the QR code.
        // The factorSid is stored temporarily here so verifyNewFactor can find it.
        // SECURITY WARNING: Storing secrets like factor.binding (which includes binding.secret)
        // directly in the DB, even temporarily, is NOT recommended for production.
        // See Security Considerations section.
        // 
        // PRODUCTION ALTERNATIVES:
        // 1. Store binding URI in server-side session storage (Redis) instead of DB
        // 2. Encrypt the keys field with AES-256 and use secure key management (AWS KMS, etc.)
        // 3. Implement automatic cleanup of unverified factors after 15-minute timeout
        // 4. Pass binding URI directly to frontend without server-side storage
        user.keys = factor.binding; // Contains binding.uri, binding.secret etc.
        user.factorSid = factor.sid; // Temporarily store SID, confirm on verify
        await user.save();

        // Ensure the User schema includes 'keys' (Object) and 'factorSid' (String) fields.

        return factor; // Return the full factor object including binding info
    } catch (error) {
        console.error('Error creating Twilio factor:', error);
        // Map Twilio errors to user-friendly messages if needed
        throw `Failed to create 2FA factor: ${error.message}`;
    }
}

async function verifyNewFactor({ identity, code }) {
    // 'identity' is the user's unique ID
    // 'code' is the TOTP code entered by the user
    const user = await User.findById(identity);
    if (!user) throw 'User not found';
    if (!user.factorSid) throw 'Factor SID not found for user, cannot verify. Please try setup again.'; // Should have been set temporarily by createFactor
    if (!serviceSid) throw 'Twilio Verify Service SID not configured';

    console.log(`Verifying factor ${user.factorSid} for identity: ${identity} with code: ${code}`);

    try {
        const factor = await client.verify.v2
            .services(serviceSid)
            .entities(identity.toString())
            .factors(user.factorSid)
            .update({ authPayload: code }); // authPayload is the TOTP code

        console.log(`Factor verification status: ${factor.status}`);

        if (factor.status === 'verified') {
            // Verification successful! Now permanently mark user as authenticated
            // and confirm the factorSid. Clear temporary binding keys.
            user.authenticated = true; // Ensure User schema has 'authenticated' (Boolean, default: false)
            user.keys = undefined; // Clear temporary binding info after verification - IMPORTANT for security
            await user.save();
            console.log(`User ${identity} successfully verified and marked as authenticated.`);
        } else {
            // Verification failed
             console.log(`Factor verification failed for ${identity}. Status: ${factor.status}`);
             throw 'Invalid verification code.';
        }

        return factor; // Return the factor object with updated status
    } catch (error) {
        console.error(`Twilio API Error verifying factor (${error.code || 'N/A'}): ${error.message}`, { status: error.status });
        let userMessage = `Failed to verify code: ${error.message || error}`;
        
        // Enhanced error handling with Twilio-specific error codes
        // Reference: https://www.twilio.com/docs/api/errors
        if (error.code === 60202) { // Max check attempts reached
            userMessage = "Too many verification attempts. Please try again later.";
        } else if (error.code === 60200 || error.status === 400) { // Invalid parameter
             userMessage = "Invalid verification code format.";
        } else if (error.code === 60203) { // Max send attempts reached
            userMessage = "Maximum verification attempts exceeded. Please wait before trying again.";
        } else if (error.status === 404) {
             userMessage = "Verification session expired or factor not found. Please try setting up 2FA again.";
        } else if (error.status === 429) { // Rate limit exceeded
            userMessage = "Too many requests. Please wait a moment and try again.";
        }
        throw userMessage; // Throw a user-friendly message
    }
}

async function createChallenge({ identity, factorSid, code }) {
    // 'identity' is the user's unique ID
    // 'factorSid' is the SID of the already verified TOTP factor for this user
    // 'code' is the current TOTP code entered by the user
    const user = await User.findById(identity);
    if (!user) throw 'User not found';
    if (!factorSid) throw 'Factor SID is required for challenge.';
    if (!serviceSid) throw 'Twilio Verify Service SID not configured';
     if (!user.authenticated || user.factorSid !== factorSid) {
         // Security check: ensure the factorSid belongs to this authenticated user
         console.warn(`Challenge attempt failed: User ${identity} state invalid (authenticated: ${user.authenticated}, stored SID: ${user.factorSid}, provided SID: ${factorSid})`);
         throw 'Invalid factor or user state for challenge.';
     }

    console.log(`Creating challenge for identity: ${identity}, factor: ${factorSid} with code: ${code}`);

    try {
        const challenge = await client.verify.v2
            .services(serviceSid)
            .entities(identity.toString())
            .challenges.create({
                authPayload: code, // The TOTP code
                factorSid: factorSid
            });

        console.log(`Challenge created: ${challenge.sid}, Status: ${challenge.status}`);

        // Check if the challenge was immediately approved (code was correct)
        if (challenge.status !== 'approved') {
             console.log(`Challenge for ${identity} not approved. Status: ${challenge.status}`);
            throw 'Invalid verification code.';
        }

         console.log(`Challenge approved for ${identity}.`);
        return challenge; // Return the challenge object
    } catch (error) {
        console.error(`Twilio API Error creating challenge (${error.code || 'N/A'}): ${error.message}`, { status: error.status });
        let userMessage = `Failed to verify code: ${error.message || error}`;
        
        // Enhanced error handling with Twilio-specific error codes
         if (error.code === 60202) { // Max check attempts reached
            userMessage = "Too many verification attempts. Please try again later.";
        } else if (error.code === 60200 || error.status === 400) { // Invalid parameter
             userMessage = "Invalid verification code format.";
        } else if (error.code === 60203) { // Max send attempts reached
            userMessage = "Maximum verification attempts exceeded. Please wait before trying again.";
        } else if (error.status === 404) {
             userMessage = "Verification factor not found for this user.";
        } else if (error.status === 429) { // Rate limit exceeded
            userMessage = "Too many requests. Please wait a moment and try again.";
        }
        throw userMessage; // Throw a user-friendly message
    }
}


// IMPORTANT: Update the usersRepo export to include the new functions
export const usersRepo = {
    authenticate,
    getAll,
    getById,
    create,
    update,
    delete: _delete, // Ensure this maps correctly if _delete is used
    // Add the new functions here:
    createFactor,
    verifyNewFactor,
    createChallenge
};

// ... (rest of the file, including apiHandler)

API Endpoints (pages/api/code/):

Create the pages/api/code directory if it doesn't exist. Then create the following files:

  1. pages/api/code/create.js (Creates the initial TOTP factor)

    javascript
    // pages/api/code/create.js
    import { apiHandler, usersRepo } from 'helpers/api';
    
    // Ensure jwtMiddleware or similar auth check protects this route if needed
    // Or rely on the frontend only calling this after successful login
    export default apiHandler({
        post: createFactorHandler
    });
    
    async function createFactorHandler(req, res) {
        // Assumes user identity is available, e.g., from JWT middleware or passed in body
        // Example: If identity comes from JWT: const identity = req.user.id;
        // Example: If identity comes from body: const identity = req.body.identity;
        const identity = req.body.identity; // Adjust based on how user ID is passed
        const name = req.body.name; // User's name/email for factor naming
    
        if (!name || !identity) {
           return res.status(400).json({ message: 'User name and identity are required' });
        }
        try {
           const factor = await usersRepo.createFactor({ name, identity });
           // Only return necessary non-secret info to the frontend
           // Crucially, the frontend needs factor.binding.uri for the QR code
           return res.status(200).json({
               sid: factor.sid,
               binding: {
                   uri: factor.binding?.uri // Send only the URI
               }
           });
        } catch (error) {
             console.error("API Error creating factor:", error);
             return res.status(500).json({ message: error.message || 'Failed to create factor' });
        }
    }
  2. pages/api/code/verify.js (Verifies the factor after QR scan)

    javascript
    // pages/api/code/verify.js
    import { apiHandler, usersRepo } from 'helpers/api';
    
    export default apiHandler({
        post: verifyNewFactorHandler
    });
    
    async function verifyNewFactorHandler(req, res) {
        // Expects { identity: 'user._id', code: '123456' } in req.body
        const { identity, code } = req.body;
        if (!identity || !code) {
            return res.status(400).json({ message: 'User identity and code are required' });
        }
         try {
            const factor = await usersRepo.verifyNewFactor({ identity, code });
             // Frontend only needs to know success/failure status
             return res.status(200).json({ status: factor.status }); // Should be 'verified' on success
         } catch (error) {
             console.error("API Error verifying factor:", error);
             // Provide the user-friendly message thrown by usersRepo
             return res.status(400).json({ message: error.message || 'Verification failed' });
         }
    }
  3. pages/api/code/challenge.js (Verifies code for subsequent logins)

    javascript
    // pages/api/code/challenge.js
    import { apiHandler, usersRepo } from 'helpers/api';
    
    export default apiHandler({
        post: createChallengeHandler
    });
    
    async function createChallengeHandler(req, res) {
        // Expects { identity: 'user._id', factorSid: 'user.factorSid', code: '123456' } in req.body
        const { identity, factorSid, code } = req.body;
        if (!identity || !factorSid || !code) {
            return res.status(400).json({ message: 'Identity, factor SID, and code are required' });
        }
        try {
            const challenge = await usersRepo.createChallenge({ identity, factorSid, code });
            // Frontend only needs success/failure status
            return res.status(200).json({ status: challenge.status }); // Should be 'approved' on success
        } catch (error) {
             console.error("API Error creating challenge:", error);
             // Provide the user-friendly message thrown by usersRepo
             return res.status(400).json({ message: error.message || 'Verification failed' });
        }
    }

Explanation:

  • We define asynchronous functions in user-repo.js that encapsulate the calls to the Twilio SDK (createFactor, verifyNewFactor, createChallenge). These handle finding the user, calling Twilio, updating the user record in MongoDB, and implementing enhanced error handling.
  • Each API route (create.js, verify.js, challenge.js) imports the apiHandler (provides error handling, method routing) and the usersRepo.
  • They define a handler function for the POST method, which calls the corresponding usersRepo function with data from the request body (req.body).
  • Responses are sent back to the client, typically indicating success (status: 'verified' or status: 'approved') or failure with a user-friendly error message derived from the backend logic. We avoid sending sensitive data like the full factor or user object back to the client unnecessarily.

4. Frontend Service Implementation

The frontend service acts as an intermediary between the UI components and the backend API routes.

Location: services/user.service.js

Update this file to include functions that call the new API endpoints. The starter uses RxJS (BehaviorSubject) for state management and a fetchWrapper for making API calls.

javascript
// services/user.service.js
import { BehaviorSubject } from 'rxjs';
import getConfig from 'next/config';
import Router from 'next/router';

import { fetchWrapper } from 'helpers'; // Assuming fetchWrapper handles base URL, headers, errors etc.

const { publicRuntimeConfig } = getConfig();
const baseUrl = `${publicRuntimeConfig.apiUrl}/users`; // Base URL for user actions
const codeBaseUrl = `${publicRuntimeConfig.apiUrl}/code`; // Base URL for code actions

// Ensure initial user state includes 'authenticated' and 'factorSid' if available from localStorage
const userSubject = new BehaviorSubject(process.browser && JSON.parse(localStorage.getItem('user')));

export const userService = {
    user: userSubject.asObservable(),
    get userValue() { return userSubject.value },
    login,
    logout,
    register,
    getAll, // Assuming these exist from starter
    getById,
    update,
    delete: _delete, // Use the delete function name from starter

    // Add the new service functions
    createFactor,
    verifyNewFactor,
    createChallenge
};

// ... (existing login, logout, register functions)
// The login function is assumed to handle password auth and store the user object (including authenticated status and factorSid if present) via userSubject and localStorage.

async function createFactor(name, identity) {
    // Calls POST /api/code/create
    // Returns { sid, binding: { uri } } on success
    return await fetchWrapper.post(`${codeBaseUrl}/create`, { name, identity });
}

async function verifyNewFactor(identity, code) {
    // Calls POST /api/code/verify
    // Returns { status: 'verified' } on success
    const response = await fetchWrapper.post(`${codeBaseUrl}/verify`, { identity, code });

    // If verification is successful, update local state/storage
    if (response && response.status === 'verified') {
        // Update the user stored in localStorage and notify subscribers
        let storedUser = JSON.parse(localStorage.getItem('user'));
        if (storedUser && storedUser.user) {
            storedUser.user.authenticated = true; // Mark as authenticated in local state
             // NOTE: The actual factorSid is already stored in the DB by the backend.
             // We might need to re-fetch the user or rely on the JWT containing updated claims
             // if the frontend needs the factorSid immediately after verification.
             // For this flow, we primarily update the 'authenticated' flag.
            localStorage.setItem('user', JSON.stringify(storedUser));
            userSubject.next(storedUser); // Notify subscribers with updated user data
            console.log(""Local user state updated to authenticated."");
        }
    }
    return response; // Return the full response { status: 'verified' } or error object from fetchWrapper
}

async function createChallenge(identity, factorSid, code) {
    // Calls POST /api/code/challenge
    // Returns { status: 'approved' } on success
    const response = await fetchWrapper.post(`${codeBaseUrl}/challenge`, { identity, factorSid, code });

    // If challenge is successful, the user is effectively logged in for this session.
    // No state change typically needed here as routing logic will proceed to dashboard.
    // The backend JWT middleware should grant access based on the valid token.
     if (response && response.status === 'approved') {
         console.log(""Challenge approved."");
         // Optionally update local state if needed, but routing usually handles next step
     }

    return response; // Return the full response { status: 'approved' } or error object from fetchWrapper
}

// ... (rest of the existing functions like getAll, getById, update, _delete)

Explanation:

  • We add three new async functions: createFactor, verifyNewFactor, and createChallenge.
  • Each function uses the fetchWrapper (provided by the starter) to make a POST request to the corresponding API endpoint created in the previous step (/api/code/*).
  • They pass the required data (identity, name, code, factorSid) in the request body.
  • verifyNewFactor has additional logic: upon successful verification (response.status === 'verified'), it updates the user object stored in localStorage and notifies any subscribers via userSubject.next() to reflect the authenticated: true state. This ensures the UI reacts correctly.
  • createChallenge primarily relies on the API response to determine success; routing logic in the component usually handles the next step upon approval.

5. Frontend UI Implementation

We need to modify the login page logic and create two new pages: one to scan the QR code (scan.js) and one to enter the TOTP code (code.js).

1. Modify Login Page (pages/account/login.js):

Update the onSubmit function to route users based on their authenticated status after successful password login.

javascript
// pages/account/login.js
// ... imports (React, useEffect, useRouter, Link, useForm, yupResolver, Yup, Alert, userService, alertService)

export default Login;

function Login() {
    const router = useRouter();
    // ... (rest of the component setup: form options, onSubmit, useEffect for redirect)

    // Update the onSubmit function
    async function onSubmit({ username, password }) {
        alertService.clear();
        try {
            // Attempt login (password check, JWT issuance, user state update)
            await userService.login(username, password);

            // Login successful, now check 2FA status from the updated user state
            const user = userService.userValue?.user; // Access user data from the service

            if (user) {
                if (user.authenticated) {
                    // User has already set up and verified 2FA before
                    console.log("User authenticated, redirecting to code entry.");
                    // Redirect to enter current TOTP code, passing along any returnUrl
                    const returnUrl = router.query.returnUrl || '/dashboard';
                    router.push({ pathname: '/account/code', query: { returnUrl } });
                } else {
                    // First time setup for this user (or 2FA not yet verified)
                    console.log("User not authenticated, redirecting to QR scan setup.");
                    // Redirect to scan QR code page
                    router.push('/account/scan');
                }
            } else {
                // Should not happen if login was successful, but handle defensively
                console.error("User data not found after successful login.");
                alertService.error('Login succeeded but user data is missing. Please try again.');
            }

        } catch (error) {
            alertService.error(error);
        }
    }

    // ... (rest of the component including the form JSX)
}

Frequently Asked Questions

How to implement two-factor authentication in Next.js?

Implement 2FA in Next.js by integrating Twilio Verify's TOTP with the Twilio Authy app. This involves modifying your login flow to require users to verify their identity with a code generated on their Authy-linked device after initial password login. This enhances security by requiring both password and device possession for access.

What is TOTP 2FA using Twilio Verify?

TOTP 2FA with Twilio Verify is a two-factor authentication method using time-based one-time passwords. Users verify their identity through codes generated by the Twilio Authy app on their registered device, adding an extra layer of security beyond just a password.

Why use Twilio Authy for two-factor authentication?

Twilio Authy provides a convenient and secure way for users to generate TOTP codes on their mobile devices. This makes it easier to implement 2FA and enhances user experience compared to other methods like email or SMS codes.

When should I implement TOTP 2FA in my Next.js app?

Implement TOTP 2FA as soon as possible to protect user accounts. If your Next.js application handles sensitive data or requires a high level of security, integrating 2FA should be a priority during development.

How to set up MongoDB Atlas for 2FA?

Set up a free MongoDB Atlas account, create a cluster and database user, configure network access (restrict in production), and obtain the connection string. Store the connection string, including username and password, securely as an environment variable (MONGODB_URI).

How to add a new user to MongoDB Atlas?

Add a new user in MongoDB Atlas by navigating to "Security" -> "Database Access", clicking "Add New Database User", providing a username and strong password, and granting the user appropriate read/write permissions.

What is the 'factorSid' in Twilio Verify?

The `factorSid` is a unique identifier generated by Twilio Verify for each TOTP factor. It's crucial for managing and verifying the user's 2FA configuration. It's stored securely in your database after successful verification.

What is the 'binding.uri' used for in Twilio 2FA?

The `binding.uri` returned by Twilio Verify when creating a new factor is used to generate the QR code displayed to the user. The user scans this QR code with their Authy app to link their device and start generating codes.

How to generate and display the QR code for Authy?

Use the `binding.uri` received from Twilio Verify's `createFactor` call. This URI contains all the information needed for the Authy app to register the user's device. A QR code library or service can generate the QR code image for display on your `/account/scan` page.

How to verify the Authy code entered by the user?

Call the Twilio Verify API's `verifyNewFactor` function (during initial setup) or `createChallenge` function (during subsequent logins) with the user's identity, factorSid (for challenges), and the entered code. Twilio responds with the verification status.

How to store Twilio credentials securely?

Store your Twilio Account SID, Auth Token, and Verify Service SID as environment variables in a `.env` file. This keeps sensitive credentials out of your source code and enables configuration for different environments.

How does the Next.js frontend interact with Twilio Verify API?

The Next.js frontend interacts with the Twilio Verify API through API routes defined in your `pages/api` directory. These routes call helper functions that use the Twilio Node.js helper library to manage factors, challenges, and verification.

What does 'user.authenticated' mean?

The `user.authenticated` flag in your user data indicates whether the user has completed the initial 2FA setup and linked their Authy app. This flag controls the redirection flow after password login.

How to handle Twilio Verify API errors in Next.js?

Implement error handling in your Next.js API routes to catch and map Twilio API errors to user-friendly messages. Provide specific messages for common errors like invalid code format or too many attempts.

Where can I find a starter project for Twilio Verify TOTP in Next.js?

The article references a starter project called 'twilio-authy' on GitHub, though the existence and content should be verified. It is important to ensure it matches the assumptions made in the guide before proceeding.