Blog

For solo engineers, but not only.

Implementing 2FA in Your Next.js App with Google Authenticator

March 23, 2024Lev Gelfenbuim14 min. read

If you've been on a quest to fortify your Next.js app against the dark forces of the internet, you've probably heard the ancient tech wisdom: "In the land of cybersecurity, two factors are better than one". Yes, I'm talking about Two-Factor Authentication (2FA), the legendary shield that adds an extra layer of protection to your digital fortress.

Now, let me guess. You've chosen Google Authenticator as your trusty sidekick in this adventure. Google Authenticator is strong, reliable, and surprisingly easy to team up with. But how do you bring this hero into your Next.js saga?

In this post, we're diving headfirst into the mystic lands of "How to Implement Google Authenticator 2FA in Next.js". Whether you're a seasoned developer or a curious newbie, our journey will equip you with the magic spells (code snippets) and ancient scrolls (best practices) you need to boost your app's security.

So, grab your coffee, because we're about to make your Next.js app a fortress that even the craftiest cyber trolls would think twice before messing with.

Understanding 2FA and Google Authenticator

Before we dive into the nitty-gritty of enchanting our Next.js app with 2FA magic, let's take a moment to understand the mystical artifacts we're dealing with. Think of 2FA as a magical barrier—it's that second layer of defense that keeps the villains out even if they've managed to get their hands on the key to your front gate (aka your password).

The Essence of Two-Factor Authentication (2FA)

Imagine you're a mythical creature guarding a treasure. The first gate, your password, is something you know. But what if a shapeshifter steals this knowledge? Fear not, for there's a second gate, one that requires something you have. This could be a magic token, a special ring, or in our modern tale, a code generated by an app like Google Authenticator. This second factor ensures that even if the password is compromised, the treasure remains safe, guarded by a barrier only the true owner can bypass.

Google Authenticator: Your Digital Griffin

Google Authenticator is akin to a griffin in the realm of 2FA tools—mighty, loyal, and incredibly versatile. It doesn't just sit idly on your phone; it generates a new code every few seconds, like a griffin changing its feathers, making it nearly impossible for the dark forces (hackers) to predict.

Here's where the magic happens: when you log in, you'll enter this code along with your password. Only the correct combination of the known (password) and the possessed (code from Google Authenticator) can unlock the gate, ensuring that you, and only you, can access your digital kingdom.

Why Choose Google Authenticator for Your Next.js App?

Google Authenticator is free, easy to use, and supported by a wide array of services and applications, making it a popular choice among developers and users alike.

Implementing Google Authenticator not only elevates the security of your application but also signals to your users that you take their safety seriously. It’s a trust-building measure, a declaration that their data and privacy are worth protecting with the fiercest digital beasts in your arsenal.

So, now that we’ve laid the foundation of our understanding, let’s prepare for the journey ahead. With Google Authenticator by our side and the power of Next.js at our fingertips, we're ready to weave the protective spells and incantations (also known as code) that will shield our app from the lurking dangers of the digital realm.

An illustration of the concept of Two-Factor Authentication (2FA) process
Image source: https://docs.opnsense.org/manual/how-tos/two_factor.html
Setting Up Your Next.js Environment

Before you can protect your Next.js application with the advanced security of Google Authenticator, you need to lay the groundwork. Let’s set the stage for 2FA by getting your Next.js environment ready for the magic that's about to unfold.

Install Dependencies

To work with Google Authenticator, you'll need a couple of extra packages: speakeasy for the heavy lifting of the 2FA process and qrcode for generating QR codes that users will scan with their mobile device:

1pnpm add speakeasy qrcode

Or, for Yarn users:

1yarn add speakeasy qrcode
Configuring the Essentials

With the necessary tools in your arsenal, it's time to lay the foundation for 2FA within your application’s ecosystem:

  1. Create an Environment for Secrets: You'll be handling sensitive data, and it should be treated as such. Set up an environment variable file, .env.local, at the root of your project to store your secret keys securely.
  2. Database Schema Adjustments: Your users' table will need an extra column or two to store 2FA-related data. Plan out your schema to include fields for the 2FA secret and a boolean to indicate if 2FA is enabled for the user.
Laying the Code Bricks

With preparations out of the way, it's time to roll up your sleeves and start coding:

  1. API Route for Generating the 2FA Secret: Create an API route that uses the speakeasy package to generate a unique 2FA secret for each user. This secret will be the cornerstone of the authentication process.
  2. API Route for Verifying the 2FA Token: You'll need another route that accepts the token from the user's Google Authenticator app and uses speakeasy to verify it against the user's stored 2FA secret.
  3. Secure Storage Practices: Remember, with great power comes great responsibility. Ensure you're handling the storage of the 2FA secrets with care—encrypt them before they touch the database.

With the setup phase complete, you're now ready to weave the intricate web of 2FA into your Next.js application.

Integrating Google Authenticator into Your Next.js App

With your Next.js environment all prepped and ready, it's time to integrate Google Authenticator for that sweet 2FA security. Here’s how you can turn the dial up on your app's defense system.

I've prepared a Git repository that acts as a beacon, guiding you through the implementation in Next.js. Check out the repository here: Next.js 2FA with Google Authenticator Example.

Backend Configuration: API Endpoints to Empower 2FA

Let's start by configuring the backend to support Google Authenticator:

  1. The Secret and QR Code Generation Endpoint: Craft an API route /app/api/2fa/qrcode/route.ts that invokes speakeasy.generateSecret(). This endpoint should create a unique secret for each user, which will be used to generate one-time tokens in the Authenticator app. Once the secret is generated, you'll need to translate it into a scannable QR code using qrcode.toDataURL().
1import QRCode from "qrcode";
2import speakeasy from "speakeasy";
3
4export async function GET(): Promise<Response> {
5  const secret = speakeasy.generateSecret({
6    name: "Next.js + Google authenticator",
7  });
8  const data = await QRCode.toDataURL(secret.otpauth_url as string);
9  return Response.json({
10    data,
11    secret: secret.base32,
12  });
13}
14
  1. Token Verification Endpoint: Set up a verification route /app/api/2fa/verify/route.ts that takes the user-inputted token and verifies it against the stored secret using speakeasy.totp.verify().
1import { type NextRequest } from "next/server";
2import speakeasy from "speakeasy";
3
4export async function GET(request: NextRequest): Promise<Response> {
5  const searchParams = request.nextUrl.searchParams;
6
7  const verified = speakeasy.totp.verify({
8    secret: searchParams.get("secret") as string,
9    encoding: "base32",
10    token: searchParams.get("token") as string,
11  });
12
13  return Response.json({
14    verified,
15  });
16}
Frontend Integration

The user interface plays a crucial role in the adoption of 2FA. Let’s make it straightforward:

Activating 2FA

Design a clear and concise UI in your user settings where users can enable 2FA. When a user opts to enable it, display the QR code generated by the backend for them to scan with their Google Authenticator app.

1export default function Home() {
2  const [_2faStatus, set2FAStatus] = useState<
3    "enabled" | "disabled" | "initializing"
4  >("disabled");
5  const [qrData, setQRData] = useState<string>();
6  const [qrSecret, setQRSecret] = useState<string>();
7  return (
8    {/* ... */}
9    <Button
10      onClick={async () => {
11        set2FAStatus("initializing");
12        const response = await fetch("/api/2fa/qrcode");
13        const data = await response.json();
14        setQRData(data.data);
15        setQRSecret(data.secret);
16      }}
17    >
18      Enable 2FA
19    </Button>
20    {/* ... */}
21    <img src={qrData} alt="2FA QR Code" />
22    {/* ... */}
23  );
24}

In the above example:

  • We have a state qrSecret to store the secret generated by the server and another state qrData to store the QR code data URI generated by the server.
  • The button click handler makes a request to /api/2fa/qrcode to get the secret, and the QR code data URI.

6-digit token verification

After the QR secret is successfully generated, make sure to verify that the user has successfully set up Google Authenticator by verifying the 6-digit code that the application has generated for our website:

1export default function Home() {
2  const [_2faStatus, set2FAStatus] = useState<
3    "enabled" | "disabled" | "initializing"
4  >("disabled");
5  const [qrSecret, setQRSecret] = useState<string>();
6  const [userToken, setUserToken] = useState<string>();
7  const [errorText, setErrorText] = useState<string>();
8  return (
9    {/* ... */}
10    <input
11      type="text"
12      className="rounded-md text-black p-2 border border-solid text-center"
13      maxLength={6}
14      onChange={(e) => setUserToken(e.target.value)}
15      value={userToken}
16    />
17    {/* ... */}
18    <Button
19      onClick={async () => {
20        const response = await fetch(
21          `/api/2fa/verify?secret=${qrSecret}&token=${userToken}`
22        );
23        const data = await response.json();
24        if (data.verified) {
25          set2FAStatus("enabled");
26          setErrorText("");
27        } else {
28          setUserToken("");
29          setErrorText(
30            "Failed. Please scan the QR code and repeat verification."
31          );
32        }
33      }}
34    >
35      Verify
36    </Button>
37    {/* ... */}
38  );
39}

In the above example:

  • The button click handler sends the user-inputted token (6-digit code from Google Authenticator) and the secret to /api/2fa/verify for verification.
  • If the server returns { verified: true }, we can be confident that the user has successfully set up their Google Authenticator application for our website.
Storing 2FA Secrets Securely

When it comes to two-factor authentication, the secret keys are the linchpin of security. Mishandle them, and it's akin to leaving the keys to the castle under the welcome mat. Let's make sure that doesn't happen.

Best Practices for the Safekeeping of 2FA Secrets
  1. Encryption at Rest: Before these secrets make their home in your database, swaddle them in a layer of encryption. This ensures that even if data breaches occur, the encrypted secrets remain gibberish without the decryption key. Use robust encryption standards like AES (Advanced Encryption Standard) to turn your secrets into an unreadable format.
  2. Environmental Shielding: The decryption keys should be kept safe in your environment variables, away from the prying eyes of your code repository. Consider using a secrets manager to handle these crucial keys, especially if you're deploying across multiple servers or environments.
  3. Database Security: Ensure that your database, where these secrets will reside, is fortified. Use network isolation, firewalls, and regular security audits to keep the data store secure. Regularly update your database and its management tools to patch any vulnerabilities.
Storing Secrets in Next.js

In the world of Next.js, here's how you can implement these security measures:

  1. Utilizing Environmental Variables: Store sensitive keys in a .env.local file for local development and use secrets management for production environments. In your next.config.js, you can refer to these variables without exposing them to the frontend:
1module.exports = {
2  env: {
3    ENCRYPTION_SECRET: process.env.ENCRYPTION_SECRET,
4  },
5};
  1. Encryption Before Storage: When saving a new secret key generated by the speakeasy package, encrypt it using your preferred encryption library before inserting it into the database. Here's a pseudocode example:
1import { encrypt } from 'some-encryption-library';
2
3const saveSecretKey = async (userId, secret) => {
4  const encryptedSecret = encrypt(secret, process.env.ENCRYPTION_SECRET);
5  // Save the encryptedSecret to the database linked with the userId
6};
  1. Access Control: Limit who can read the 2FA secret keys in your database. Implement role-based access controls and ensure that only the essential parts of your application have the ability to decrypt and use these secrets.
Preparing for the Unthinkable

Prepare for disaster recovery. Regularly back up your encrypted secrets, and have a protocol in place for what to do in case of a security breach. It’s not just about defense; it’s also about having a plan when the defenses fall.

User Experience Considerations

Security is essential, but if it's a hassle, users will balk. Striking the perfect balance between stringent security measures and a smooth user journey is key. Let's ensure your 2FA integration doesn't become a user's labyrinth of frustration.

Crafting a Seamless 2FA Activation Process
  1. Intuitive Setup Instructions: Guide your users through the 2FA setup process with clear, concise instructions. Consider interactive tutorials or tooltips that appear at each step, so users don't feel lost in a sea of technical jargon.
  2. Friendly UI for QR Code Scanning: When it's time for users to scan the QR code, make sure the interface is as clean and uncluttered as possible. Provide a large, easily scannable QR code, and maybe even an animation to show the scanning process—every bit helps in making the experience less intimidating.
2FA setup screen on GitHub
Image source: https://github.blog/changelog/2022-11-21-updates-to-the-two-factor-authentication-setup-flow/
  1. Authenticating with a Fallback Number: Offer users the option to set up a fallback number. If they can't access their Authenticator app, a text message with a backup code can save the day. This approach keeps the process dynamic and user-friendly, without compromising security.
Smoothing Out the Login Experience
  1. Speedy Verification Field: For returning users, the 2FA input field on the login page should be fast, responsive, and automatically focused after they've entered their password—because no one likes to click more than necessary.
2FA input screen on GitHub
  1. Clear Error Messaging: If a user enters an incorrect 2FA code, provide a clear error message. Bonus points if you can gently nudge them towards success with tips, like checking if their Google Authenticator app is synced correctly.
2FA authentication failure on GitHub
  1. Recovery Options: Have a process in place for users who have lost access to their 2FA device. This might include customer support verification steps, secondary email verification, or those previously mentioned backup codes.
Keeping the User Informed
  1. Feedback on Successful Setup: Once a user has successfully set up 2FA, give them a pat on the back with a congratulatory message. Let them know they've taken a big step in securing their account.
  2. Educational Resources: Offer resources that explain why 2FA is essential. Sometimes, understanding the 'why' behind an extra step can turn a user's sigh into a nod of appreciation.

Remember, the goal is to make security feel like a comforting embrace, not a straitjacket. By considering the user's journey, you create not just a secure environment, but a user-friendly one that encourages adoption and fosters trust.

Article last update: March 25, 2024

React
Authentication
Next.js
2FA