Web Development

Building Production‑Ready Two‑Factor Authentication with TOTP in Next.js

Learn how TOTP-based 2FA works from the ground up using otplib and qrcode

KUZUE
July 2, 2026
10 min read
24 views

Building Two-Factor Authentication (2FA) with TOTP in Next.js

Security is one of the most important aspects of building modern applications. Even if a user's password is compromised, we can still protect their account by requiring an additional form of verification before granting access.

This extra layer of protection is known as Two-Factor Authentication (2FA).

In this article, I'll explain, from the barest minimum knowledge, how we can add Two-Factor Authentication to our applications using TOTP, otplib, and qrcode.

Rather than jumping straight into the implementation, we'll first understand the concepts behind how everything works. Once the concepts are clear, implementing the code becomes much easier.


Table of Contents

  • Basic Concepts
  • What is TOTP?
  • What is otplib?
  • What is qrcode?
  • How the Authentication Flow Works
  • Generating the QR Code
  • Verifying the First Authentication Code
  • Future Login Flow
  • Disabling Two-Factor Authentication
  • Complete Implementation

Basic Concepts

Before writing any code, let's understand the technologies involved.


What is TOTP?

TOTP (Time-Based One-Time Password) is an algorithm that generates a short-lived numerical code using:

  • A shared secret
  • The current time as a moving factor

It is the technology behind authenticator applications like:

  • Google Authenticator
  • Microsoft Authenticator
  • Authy

The generated code usually changes every 30 seconds.

Unlike email OTPs, the server does not generate and send these codes. Instead, both the server and the authenticator application independently calculate the exact same code using the shared secret and the current time.


What is otplib?

otplib (One-Time Password Library) is a JavaScript library that implements the TOTP algorithm.

It is responsible for:

  • Generating a secret
  • Creating the otpauth:// URI
  • Verifying authentication codes
  • Generating TOTP codes

Authenticator applications do not understand the raw secret alone. They expect an otpauth:// URI, which is why otplib provides the keyuri() function.


What is qrcode?

qrcode is a general-purpose JavaScript library that converts text into a QR code image.

ts
QRCode.toDataURL("text");

For Two-Factor Authentication, we pass the generated otpauth:// URI into QRCode.toDataURL().

Without this QR code, users would have to manually type a very long authentication URI into their authenticator application, resulting in a poor user experience.


Authentication Flow

2FA Authentication Flow

To support Two-Factor Authentication, we add two additional columns to our User model.

| Field | Purpose | |--------|---------| | twoFactorSecret | Stores the user's shared secret | | twoFactorEnabled | Indicates whether Two-Factor Authentication has been enabled |


Setting Up Two-Factor Authentication

The user must already be authenticated before they can enable Two-Factor Authentication.

When the user clicks Enable 2FA, our server performs the following steps:

  1. Generate a unique secret.
  2. Create an otpauth:// URI.
  3. Convert the URI into a QR code.
  4. Store the secret.
  5. Return the QR code to the frontend.

Step 1 — Generate the Secret

ts
const secret = authenticator.generateSecret();

Every user receives their own unique secret.


Step 2 — Generate the Authentication URI

ts
const serviceName = "Chukwunonso App";

const otpAuthUrl = authenticator.keyuri(
  user.email,
  serviceName,
  secret
);

Understanding keyuri()

ts
authenticator.keyuri(
  accountName,
  issuer,
  secret
);
text
 `accountName` | Usually the user's email address 
`issuer`  The name of your application 
 `secret`  The shared secret used to generate TOTP codes 
---

## Step 3 — Generate the QR Code

```ts
const qrCodeDataUrl = await QRCode.toDataURL(otpAuthUrl);

This QR code is returned to the frontend and displayed for the user to scan.


Step 4 — Store the Secret

After generating the QR code, store the secret inside your database.

Important

Do not enable Two-Factor Authentication immediately.

A user might close the browser before scanning the QR code.

Instead, store the secret and wait until they successfully verify their first authentication code before setting:

ts
twoFactorEnabled = true;

Finally, return the generated QR code to the frontend.


What Happens After Scanning?

Once the user scans the QR code, the authenticator application extracts everything it needs from the otpauth:// URI.

That includes:

  • The application name (Issuer)
  • The account name
  • The shared secret

The authenticator application securely stores the secret on the user's device.


How TOTP Actually Works

This is the most important concept in the entire authentication process.

Every 30 seconds, both the server and the authenticator application calculate a six-digit authentication code using:

  • The shared secret
  • The current time
text
Shared Secret
      +
Current Time
      │
      ▼
TOTP Algorithm
      │
      ▼
  483921

The important thing to understand is that the server and the authenticator application never communicate directly.

The server never asks Google Authenticator for the correct code.

Likewise, Google Authenticator never asks your server what the current code should be.

Instead, both independently perform the exact same standardized TOTP calculation using:

  • The same shared secret
  • The same current time
  • The same TOTP algorithm

Since both sides use identical inputs, they both arrive at the exact same authentication code.

The authentication code itself is never stored inside the database.


Verifying the First Authentication Code

After scanning the QR code, the user enters the generated authentication code.

The server first checks that the user has already started the setup process.

ts
if (!user.twoFactorSecret) {
  return NextResponse.json(
    {
      success: false,
      message: "2FA authentication has not been initiated",
    },
    { status: 400 }
  );
}

Next, we verify the submitted code.

ts
const isValid = authenticator.verify({
  token: code,
  secret: user.twoFactorSecret,
});

The verify() function accepts two parameters:

  • token — The authentication code entered by the user.
  • secret — The shared secret stored in the database.

If the verification fails:

ts
if (!isValid) {
  return NextResponse.json(
    {
      success: false,
      message: "2FA code invalid. Scan the QR code and try again.",
    },
    { status: 400 }
  );
}

If the verification succeeds, we can finally enable Two-Factor Authentication.

ts
await prisma.user.update({
  where: {
    id: session.user.id,
  },
  data: {
    twoFactorEnabled: true,
  },
});

return NextResponse.json(
  {
    success: true,
    message: "2FA authentication has now been enabled",
  },
  { status: 200 }
);

At this point, Two-Factor Authentication has been successfully configured.


Future Login Flow

The next time the user logs in, the authentication flow becomes:

  1. Verify the user's email.
  2. Verify the user's password.
  3. Check whether twoFactorEnabled is true.
  4. Prompt the user for the authentication code.
  5. Verify the submitted code using the stored secret.
  6. Complete authentication.

Disabling Two-Factor Authentication

Disabling Two-Factor Authentication is much simpler.

We simply:

  • Set twoFactorEnabled to false.
  • Remove the stored secret.
ts
await prisma.user.update({
  where: {
    id: user.id,
  },
  data: {
    twoFactorEnabled: false,
    twoFactorSecret: null,
  },
});

return NextResponse.json(
  {
    success: true,
    message: "2FA has been disabled successfully",
  },
  { status: 200 }
);

By removing the secret, the server no longer has the information required to verify future authentication codes.


Complete Implementation

Our implementation consists of three API routes.

text
app
└── api
    ├── setup
    │   └── route.ts
    ├── enable
    │   └── route.ts
    └── disable
        └── route.ts

Each route follows the Single Responsibility Principle, meaning it focuses on one task only.


1. Setup Route

Endpoint

http
GET /api/setup

Responsibility

This route:

  • Verifies the user's session.
  • Generates a unique secret.
  • Creates the otpauth:// URI.
  • Generates the QR code.
  • Stores the secret.
  • Returns the QR code to the frontend.
ts
import prisma from "@/app/lib/prisma";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { authenticator} from "otplib"
import qrcode from 'qrcode'

export async function GET() {
   try {
    const cookiesStore = await cookies()
    const sessionToken = cookiesStore.get("sessionToken")
    console.log(sessionToken)

    if(!sessionToken || !sessionToken.value){
        return NextResponse.json({
            success:false, message: "Not authorized"
        }, {status: 401})
    }

    const session = await prisma.session.findUnique({
        where: {sessionToken: sessionToken.value},
        include: {user: true}
    })

    if (!session || session.expiresAt < new Date()){
        return NextResponse.json({
            success:false, message: "unauthorized: invalid session or expired session"
        }, {status: 401})
    }

    const userId = session.user.id
    const user = await prisma.user.findUnique({
        where: {id: userId}
    })

    if (!user){
        return NextResponse.json({
            success:false, message: "Not found: user is not found"
        }, {status: 404})
    }

    const secret = authenticator.generateSecret()
    const serviceName = "chukwunonso app"
    const otpAuthUrl = authenticator.keyuri(user.email, serviceName, secret)

    //converting to qrcode
    const qrcodeDataUrl = await qrcode.toDataURL(otpAuthUrl)

    await prisma.user.update({
        where: {id: user.id},
        data: {
            twoFactorEnabled: false,
            twoFactorSecret: secret
        }
    })
    console.log("success activated 2FA mode")
    return NextResponse.json({
        success: true, message: "successfully activated 2FA mode", qrcode: qrcodeDataUrl, secret: secret
    },{status: 200})
   } catch (TwoFAerr) {
    console.log("server error:unable to setup 2FA",TwoFAerr )
    return NextResponse.json({
        success:false, TwoFAerr: "internal server error"
    }, {status: 500})
   }

}

2. Enable Route

Endpoint

http
POST /api/enable

Responsibility

This route:

  • Verifies the user's session.
  • Ensures setup has already started.
  • Verifies the submitted authentication code.
  • Enables Two-Factor Authentication.
ts
import prisma from "@/app/lib/prisma"
import { cookies } from "next/headers"
import { NextResponse } from "next/server"
import { authenticator } from "otplib"

export async function POST(req: Request){
    try {
        const { code } = await req.json()
    if(!code){
        return NextResponse.json({
            success:true, message: "bad request: code is required!"
        }, {status: 400})
    }

    const cookiesStore = await cookies()
    const sessionObject = cookiesStore.get("sessionToken")

    if (!sessionObject || !sessionObject?.value){
        return NextResponse.json({
            success:false, message: "unauthorized: session is missing"
        }, {status: 401})
    }

    const session = await prisma.session.findUnique({
        where: {sessionToken: sessionObject.value},
        include: {user: true}
    })

    if (!session || session.expiresAt < new Date()){

        return NextResponse.json({
            success: false, message: "unauthorized: invalid or expired session"
        }, {status: 401})
    
    }

    const user = await prisma.user.findUnique({
        where: {id: session.user.id}
    })

    if(!user){
        return NextResponse.json({
            success: true, message: "Not found"
        }, {status: 404})
    }

    if (!user.twoFactorSecret) {
        return NextResponse.json({
            success: false, message: "2FA authentication has not been initiated"
        }, {status: 400})
    }

    const isValid = authenticator.verify({token: code, secret: user.twoFactorSecret})

    if (!isValid){
        return NextResponse.json({
            success: false, message: "2FA code invalid, scan the qrcode and try again"
        }, {status: 400})
    }

    await prisma.user.update({
        where: {id: session.user.id},
        data: {
            twoFactorEnabled: true
        }
    })

    return NextResponse.json({
        success: true, message: "2FA authentication has now been enabled"
    }, {status: 200})
    } catch (error) {
        console.log("activating 2FA failed", error)
        return NextResponse.json({
            success: false, message: "internal server Error", error
        }, {status: 500})
    }
}

3. Disable Route

Endpoint

http
GET /api/disable

Responsibility

This route:

  • Verifies the user's session.
  • Disables Two-Factor Authentication.
  • Removes the stored secret.
ts
import prisma from "@/app/lib/prisma";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";

export async function GET(){
  try {
        const cookiesStore = await cookies()
        const sessionObject = cookiesStore.get("sessionToken")
        
        if (!sessionObject || !sessionObject?.value){
            return NextResponse.json({
                success:false, message: "unauthorized: session token  is missing"
            }, {status: 401})
        }
    
        const session = await prisma.session.findUnique({
            where: {sessionToken: sessionObject.value},
            include: {user: true}
        })
    

    if(!session || session.expiresAt < new Date() ){
        return NextResponse.json({
            success: false, message: "unauthorized: invalid or expired token"
        }, {status: 401})
    }

    const user = await prisma.user.findUnique({
        where: {id: session.user.id}
    })

    if (!user){
        return NextResponse.json({
            success: false, message: "Not Found"
        }, {status: 404})
    }

    await prisma.user.update({
        where: {id: user.id},
        data: {
            twoFactorEnabled: false,
            twoFactorSecret: null
        }
    })

    return NextResponse.json({
        success: true, message: "2FA has been disabled successfully"
    }, {status: 200})

  } catch (error) {
    console.log("deactivated 2FA successfully")
    return NextResponse.json({
        success:false, message: "internal server error"
    }, {status: 500})
  }
}

Final Thoughts

The most important concept to understand is this:

The server and the authenticator application never communicate directly.

Instead:

  • The server stores a shared secret.
  • The authenticator application stores the same shared secret.
  • Both independently use the TOTP algorithm and the current time to generate the same six-digit authentication code.
  • The server simply compares the submitted code with the one it calculates itself.

Once this mental model clicks, implementing TOTP-based Two-Factor Authentication becomes much easier.

I hope this article helped you understand not just how to implement 2FA, but why it works.

Comments (0)

No comments yet. Be the first to share your thoughts.