Implementing WebAuthn in Next.js: A Hands-On Guide

A hands-on guide to implementing WebAuthn in Next.js with live code examples.
Sijie
SijieDeveloper
November 15, 20239 min read
Implementing WebAuthn in Next.js: A Hands-On Guide

Welcome back to our WebAuthn series. In our previous articles, we've covered the basics of WebAuthn, and a 101 guide. If you're just joining us, feel free to check out these foundational pieces to get up to speed.

Today, we're rolling up our sleeves to put theory into practice. We'll be harnessing the power of Next.js with the new feature “Server Actions”. Our goal? To implement WebAuthn in a Next.js application, and get ourselves ready for WebAuthn.

Before we dive into the coding sea, here's a glimpse of what awaits you at the journey's end - a fully functional demo website. Explore it to see WebAuthn in action and to get a taste of what you'll be building. In this demo website, you can register new users and login with the passkeys just registered.

Preview

And for those who prefer a map in hand, we've got you covered! All the code we'll be discussing is available in a public GitHub repository . This repository is your companion guide, offering the full source code of our implementation.

Ready to embark on this exciting adventure? Let's get started!

Prerequisites

Before we begin, let's make sure we have everything we need:

  1. A Next.js Project: If you haven't set up a Next.js project yet, here's a quick guide to get you started.
  2. Simple WebAuthn Library: Several packages to help reduce the amount of work needed to incorporate WebAuthn into a website. Use your favorite package manager to install @simplewebauthn/browser, @simplewebauthn/server and @simplewebauthn/typescript-types
  3. Session Storage: We'll be using session storage to manage WebAuthn challenges. We’ll use vercel’s KV to achieve this.
  4. A User Database: A place to store our users' registered passkeys. To keep it simple, we’ll also use vercel’s KV to demonstrate.

Now, with our tools and materials at hand, we're ready to start building.

Implementing session storage with Vercel’s KV

Setting up KV storage

It is easy to initialize a KV storage both in production and local development, follow this guide to connect a KV store to your project and pull the environment values: https://vercel.com/docs/storage/vercel-kv/quickstart

Implement session management functions

import { kv } from '@vercel/kv';
import { cookies } from 'next/headers';

const sessionPrefix = 'nextjs-webauthn-example-session-';

type SessionData = {
  currentChallenge?: string;
  email?: string;
};

const getSession = async (id: string) => {
  return kv.get<SessionData>(`${sessionPrefix}${id}`);
};

const createSession = async (id: string, data: SessionData) => {
  return kv.set(`${sessionPrefix}${id}`, JSON.stringify(data));
};

export const getCurrentSession = async (): Promise<{
  sessionId: string;
  data: SessionData;
}> => {
  const cookieStore = cookies();
  const sessionId = cookieStore.get('session-id');

  if (sessionId?.value) {
    const session = await getSession(sessionId.value);

    if (session) {
      return { sessionId: sessionId.value, data: session };
    }
  }

  const newSessionId = Math.random().toString(36).slice(2);
  const newSession = { currentChallenge: undefined };
  cookieStore.set('session-id', newSessionId);

  await createSession(newSessionId, newSession);

  return { sessionId: newSessionId, data: newSession };
};

export const updateCurrentSession = async (data: SessionData): Promise<void> => {
  const { sessionId, data: oldData } = await getCurrentSession();

  await createSession(sessionId, { ...oldData, ...data });
};

We exported 2 functions:

  • getCurrentSession: Use Next.js cookie helper to create a session for current request, and return the value.
  • updateCurrentSession: Save data to current session.

Implementing user database with Vercel’s KV

Similarly to our session implementation, let's implement a simple user database.

import { AuthenticatorDevice } from '@simplewebauthn/typescript-types';
import { kv } from '@vercel/kv';
import { cookies } from 'next/headers';

const userPrefix = 'nextjs-webauthn-example-user-';

// The original types are "Buffer" which is not supported by KV
export type UserDevice = Omit<AuthenticatorDevice, 'credentialPublicKey' | 'credentialID'> & {
  credentialID: string;
  credentialPublicKey: string;
};

type User = {
  email: string;
  devices: UserDevice[];
};

export const findUser = async (email: string): Promise<User | null> => {
  const user = await kv.get<User>(`${userPrefix}${email}`);

  return user;
};

export const createUser = async (email: string, devices: UserDevice[]): Promise<User> => {
  const user = await findUser(email);

  if (user) {
    throw new Error('User already exists');
  }

  await kv.set(`${userPrefix}${email}`, { email, devices });
  return { email, devices };
};

We created functions to find user by email, and update user data by email. Remember, this is for demonstration only, in the real product, the user data is kept in database usually.

Preparing WebAuthn functions

Before we proceed, let’s see the diagram of registration and authentication flow:

API ServerWebPageUserAPI ServerWebPageUserRequest register a new passkeyGet registration options (generateWebAuthnRegistrationOptions)optionsCall WebAuthn APIPrompt to authenticatePerform authSend WebAuthn API responseVerify and save (verifyWebAuthnRegistration)Return result

As you can see, we need to prepare 2 functions:

  1. generateWebAuthnRegistrationOptions
  2. verifyWebAuthnRegistration

API ServerWebPageUserAPI ServerWebPageUserRequest login to an emailGet authentication options (generateWebAuthnLoginOptions)optionsCall WebAuthn APIPrompt to authenticatePerform authSend WebAuthn API responseVerify (verifyWebAuthnLogin)Return result

Similar to registration, the login requires 2 functions:

  1. generateWebAuthnLoginOptions
  2. verifyWebAuthnLogin

Here’s the code:

'use server';

import {
  GenerateAuthenticationOptionsOpts,
  GenerateRegistrationOptionsOpts,
  VerifyAuthenticationResponseOpts,
  VerifyRegistrationResponseOpts,
  generateAuthenticationOptions,
  generateRegistrationOptions,
  verifyAuthenticationResponse,
  verifyRegistrationResponse,
} from '@simplewebauthn/server';
import { UserDevice, createUser, findUser, getCurrentSession, updateCurrentSession } from './user';
import { origin, rpId } from './constants';
import {
  AuthenticationResponseJSON,
  RegistrationResponseJSON,
} from '@simplewebauthn/typescript-types';
import { isoBase64URL } from '@simplewebauthn/server/helpers';

export const generateWebAuthnRegistrationOptions = async (email: string) => {
  const user = await findUser(email);

  if (user) {
    return {
      success: false,
      message: 'User already exists',
    };
  }

  const opts: GenerateRegistrationOptionsOpts = {
    rpName: 'SimpleWebAuthn Example',
    rpID: rpId,
    userID: email,
    userName: email,
    timeout: 60000,
    attestationType: 'none',
    excludeCredentials: [],
    authenticatorSelection: {
      residentKey: 'discouraged',
    },
    /**
     * Support the two most common algorithms: ES256, and RS256
     */
    supportedAlgorithmIDs: [-7, -257],
  };

  const options = await generateRegistrationOptions(opts);

  await updateCurrentSession({ currentChallenge: options.challenge, email });

  return {
    success: true,
    data: options,
  };
};

export const verifyWebAuthnRegistration = async (data: RegistrationResponseJSON) => {
  const {
    data: { email, currentChallenge },
  } = await getCurrentSession();

  if (!email || !currentChallenge) {
    return {
      success: false,
      message: 'Session expired',
    };
  }

  const expectedChallenge = currentChallenge;

  const opts: VerifyRegistrationResponseOpts = {
    response: data,
    expectedChallenge: `${expectedChallenge}`,
    expectedOrigin: origin,
    expectedRPID: rpId,
    requireUserVerification: false,
  };
  const verification = await verifyRegistrationResponse(opts);

  const { verified, registrationInfo } = verification;

  if (!verified || !registrationInfo) {
    return {
      success: false,
      message: 'Registration failed',
    };
  }

  const { credentialPublicKey, credentialID, counter } = registrationInfo;

  /**
   * Add the returned device to the user's list of devices
   */
  const newDevice: UserDevice = {
    credentialPublicKey: isoBase64URL.fromBuffer(credentialPublicKey),
    credentialID: isoBase64URL.fromBuffer(credentialID),
    counter,
    transports: data.response.transports,
  };

  await updateCurrentSession({});

  try {
    await createUser(email, [newDevice]);
  } catch {
    return {
      success: false,
      message: 'User already exists',
    };
  }

  return {
    success: true,
  };
};

export const generateWebAuthnLoginOptions = async (email: string) => {
  const user = await findUser(email);

  if (!user) {
    return {
      success: false,
      message: 'User does not exist',
    };
  }

  const opts: GenerateAuthenticationOptionsOpts = {
    timeout: 60000,
    allowCredentials: user.devices.map((dev) => ({
      id: isoBase64URL.toBuffer(dev.credentialID),
      type: 'public-key',
      transports: dev.transports,
    })),
    userVerification: 'required',
    rpID: rpId,
  };
  const options = await generateAuthenticationOptions(opts);

  await updateCurrentSession({ currentChallenge: options.challenge, email });

  return {
    success: true,
    data: options,
  };
};

export const verifyWebAuthnLogin = async (data: AuthenticationResponseJSON) => {
  const {
    data: { email, currentChallenge },
  } = await getCurrentSession();

  if (!email || !currentChallenge) {
    return {
      success: false,
      message: 'Session expired',
    };
  }

  const user = await findUser(email);

  if (!user) {
    return {
      success: false,
      message: 'User does not exist',
    };
  }

  const dbAuthenticator = user.devices.find((dev) => dev.credentialID === data.rawId);

  if (!dbAuthenticator) {
    return {
      success: false,
      message: 'Authenticator is not registered with this site',
    };
  }

  const opts: VerifyAuthenticationResponseOpts = {
    response: data,
    expectedChallenge: `${currentChallenge}`,
    expectedOrigin: origin,
    expectedRPID: rpId,
    authenticator: {
      ...dbAuthenticator,
      credentialID: isoBase64URL.toBuffer(dbAuthenticator.credentialID),
      credentialPublicKey: isoBase64URL.toBuffer(dbAuthenticator.credentialPublicKey),
    },
    requireUserVerification: true,
  };
  const verification = await verifyAuthenticationResponse(opts);

  await updateCurrentSession({});

  return {
    success: verification.verified,
  };
};

Build the web page

We’ve completed the preparation, let’s build the page:

'use client';

import { startAuthentication, startRegistration } from '@simplewebauthn/browser';
import {
  generateWebAuthnLoginOptions,
  generateWebAuthnRegistrationOptions,
  verifyWebAuthnLogin,
  verifyWebAuthnRegistration,
} from '@/libraries/webauthn';
import styles from './page.module.css';

export default function Home() {
  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    const formData = new FormData(event.currentTarget);
    const email = formData.get('email')?.toString();
    const type = formData.get('type')?.toString();

    if (!email) {
      return;
    }

    if (type === 'register') {
      const response = await generateWebAuthnRegistrationOptions(email);

      if (!response.success || !response.data) {
        alert(response.message ?? 'Something went wrong!');
        return;
      }

      const localResponse = await startRegistration(response.data);
      const verifyResponse = await verifyWebAuthnRegistration(localResponse);

      if (!verifyResponse.success) {
        alert(verifyResponse.message ?? 'Something went wrong!');
        return;
      }

      alert('Registration successful!');
    } else {
      const response = await generateWebAuthnLoginOptions(email);

      if (!response.success || !response.data) {
        alert(response.message ?? 'Something went wrong!');
        return;
      }

      const localResponse = await startAuthentication(response.data);
      const verifyResponse = await verifyWebAuthnLogin(localResponse);

      if (!verifyResponse.success) {
        alert(verifyResponse.message ?? 'Something went wrong!');
        return;
      }

      alert('Login successful!');
    }
  };

  return (
    <main className={styles.main}>
      <form onSubmit={handleSubmit} className={styles.formContainer}>
        <input type="email" name="email" placeholder="Email" className={styles.inputField} />

        <div className={styles.radioGroup}>
          <label className={styles.label}>
            <input
              type="radio"
              name="type"
              value="register"
              defaultChecked
              className={styles.radioInput}
            />
            Register
          </label>

          <label className={styles.label}>
            <input type="radio" name="type" value="login" className={styles.radioInput} />
            Login
          </label>
        </div>

        <input type="submit" value="Submit" className={styles.submitButton} />
      </form>
    </main>
  );
}

Conclusion

Congratulations on navigating through the intricacies of implementing WebAuthn in a Next.js application. As we wrap up, it's important to address some crucial considerations for deploying it in a production environment.

Key Considerations for Production Deployment

  1. User Identifier Adjustment: In this tutorial, we used an email address as the user identifier. However, in a production scenario, you might need to use a different identifier, such as a userId or username.
  2. Database Integration: While we utilized Vercel’s KV as a simple demonstration of session and user data management, a real-world application should integrate a more robust database system (like PostgreSQL, MongoDB, etc.)
  3. Customizing WebAuthn Options: The WebAuthn options we explored are a starting point. Depending on your application's requirements and security policies, you may need to adjust these settings. Consult the WebAuthn documentation and the Simple WebAuthn library's documentation for guidance on customizing these options to suit your specific needs.

Thank you for joining us on this educational adventure. Even in this minimal example, integrating WebAuthn is not a simple task, there is another option, try WebAuthn in Logto’s MFA: