# How to use Passkeys in Next.js using WebAuthn

In this tutorial, we will learn how to use [SimpleWebAuth](https://simplewebauthn.dev/)n to allow users to register and login with passkeys in a [Next.js](https://nextjs.org/) app. It assumes you have basic knowledge of Typescript and Next.js. We’ll be building [this (live demo)](https://webauthn.mcgee.cat/).

If you’d rather dive head-first into the code, you can find the repo [here](https://github.com/catmcgee/webauthn-nextjs/tree/main).

# But first, what are passkeys?

Even if you’ve never heard the word `passkey` before, chances are you’ve probably used them. They are becoming the preferred way to handle authentication, with a smoother UX and better security than the traditional username and password method.

Ever seen something like this?

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1754504793013/2a66851b-1e21-4ef3-af95-6275c40ac53f.png align="center")

This is a passkey. It is a key-pair credential that lives in the user’s operating system or browser credential manager. In the case of the screenshot above, my credential is saved in my iCloud account’s Keychain. The private key never leaves my device, and the app only stores only the public key. In order to register or login to the app, I simply login to my Apple account.

This comes with security benefits, especially for app developers, as we no longer need to worry about handling authentication data for all our users. We can instead benefit from the strong authenticators already built into user’s devices. It also means that users can often use biometric logins such as TouchID or FaceID without first having to register their biometric data in your app.

# Introducing WebAuthn

WebAuthn (aka Web Authentication API) is a specification written by [W3](https://www.w3.org/) and [FIDO](https://fidoalliance.org/) and introduces a standardized way of using passkeys in websites. It works like this:

**Registration (creating an account)**

1. Browser → Authenticator: “Generate a key-pair and sign this challenge”
    
2. Authenticator: generates key-pair, stores the private key, hands back public key + signature to challenge
    
3. Browser → Server: sends that public key and challenge (attestation) for the server to store
    

**Login**

1. Server → Browser: sends a random challenge
    
2. Browser → Authenticator: asks it to sign the challenge (after Face ID / Touch ID / PIN)
    
3. Authenticator → Browser: returns challenge + signature
    
4. Browser → Server: forwards them (assertion)
    
5. Server: verifies the signature with the stored public key and logs in the user
    

A “challenge” is a cryptographically random byte string that ensures the user can’t send the same signature over and over again, helping stop relay attacks.

## SimpleWebAuthn

SimpleWebAuthn is a collection of TypeScript libraries for easily plugging the WebAuthn standard into your app. In this tutorial, we’ll be using [@simplewebauthn/server](https://simplewebauthn.dev/docs/packages/server) to build an API that allows users to register and login with WebAuthn, and a simple frontend to interact with it.

# The code

We’re going to be working from an existing repo and building on top of it, so we can focus on SimpleWebAuthn and not get tied up in configs or navigation.

## Prerequisites

Make sure you have installed:

* Node v20+
    
* Your preferred package manager (this tutorial uses [bun](https://bun.sh/package-manager), my personal favourite)
    

## Setup

Open your terminal and run:

```bash
git clone --branch starter https://github.com/catmcgee/webauthn-nextjs.git webauthn-example
cd webauthn-example
cp .env.example .env.local
bun install # or npm install / pnpm install / yarn install
```

This clones the repo, sets up our `.env` file and installs dependencies.

This is the structure of our app:

```bash
├── README.md
├── bun.lock
├── eslint.config.mjs
├── next-env.d.ts
├── next.config.ts
├── package.json
├── postcss.config.mjs
├── public
│   └── homescreen.png
├── src # Our code lives here
│   ├── app
│   │   ├── api # API routes 
│   │   │   ├── authenticate # Called when a user attempts a login (server)
│   │   │   └── register # Called when a user attemps to register (server)
│   │   ├── favicon.ico
│   │   ├── globals.css
│   │   ├── layout.tsx
│   │   └── page.tsx # The homepage with register/login buttons and logged in state
│   └── lib
│       ├── passkeys.ts # A temporary data store for our passkeys
│       └── webauthn.ts # Helper functions containing logic for the API routes
└── tsconfig.json
```

We will only be working in the `src` directory, mainly within `webauthn.ts` where our SimpleWebAuthn flows will live.

Go ahead and run it:

```bash
bun dev # or npm dev / pnpm dev / yarn dev
```

Open your browser to [http://localhost:3000/](http://localhost:3000/) and you’ll see something like this:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1754584771742/de86de85-5529-42af-b41c-47fd54a4afab.png align="center")

PS if you port to another port other than 3000, you will need to update `NEXT_PUBLIC_ORIGIN` in your `.env.local`.

The buttons won’t work yet - that’s what we’re working on now!

## Register flow

Open `src/page.tsx` and you’ll see a `register()` function that is called with the Register button is clicked:

```typescript
 /**
   * Initiates WebAuthn registration
   * On success, automatically treats registration as a login
   */
  // TODO function to register
  const register = async () => {};
```

This function needs to:

* Sign a registration challenge and receive `options`
    
* Use these options to sign an `attestation`
    
* Verify the `attestation`
    
* Login the user
    

We already have an API route set up for options inside `src/app/api/register/options/route.ts`. If you open it you’ll see this:

```typescript
import { NextResponse } from "next/server";
import { regOptions } from "@/lib/webauthn";

// Generates WebAuthn registration options (challenge) for the client
export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const user = searchParams.get("u") ?? "Alice";
  return NextResponse.json(await regOptions(user));
}
```

This is telling us two things: we need to pass `u` for username, and the logic for this route sits at a function called `regOptions()` in `lib/webauthn.ts`.

Inside the `register()` function paste this:

```typescript
 const options = await fetch(
        `/api/register/options?u=${encodeURIComponent(username)}`
      ).then((res) => res.json());
```

We’re now calling the API. Let’s make it do something. Inside `src/lib/webauthn` you’ll see a function called `regOptions()`:

```typescript
/**
 * Generate WebAuthn registration options for the user
 *
 * @param username - A username, also used as user handle
 * @returns Options that should be passed to `navigator.credentials.create`
 */
// TODO function to register options
export async function regOptions(username: string) {}
```

Inside here we are going to call `generateRegistrationOptions()` that we imported from `@simplewebauthn/server`. This takes these params:

```typescript
{
rpName, // app name 
rpID, // app identifier (domain)
username, // username of user 
attestationType, // whether the authenticator sends a certificate proving its manufacturer (used in hardware)
excludeCredentials, // don't register if user is previously registered 
supportedAlgorithms // Array of COSE algorithm IDs
}
```

We have `rpName` and `rpID` in our `.env.local`:

```bash
NEXT_PUBLIC_RP_NAME="Passkeys in WebAuthn"
NEXT_PUBLIC_RP_ID="localhost"
```

We pass `username` from the frontend. We don’t need `attestationType` as we are not working with hardware. We will `excludeCredentials` by looking in our temporary data store. We will use `[-7]` for `supportedAlgorithms` which is `ES256` (the most commonly used algorithm for passkeys).

Inside `regOptions()` paste this:

```typescript
 const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userName: username,
    attestationType: "none",
    excludeCredentials: (db.store.get(username) || []).map((p) => ({
      id: p.id,
    })),
    supportedAlgorithmIDs: [-7],
  });
return options
```

`excludeCredentials` excludes anyone with this `username` inside of `db`. We can see at the top of the file that `db` is imported from `./passkeys` which looks like this:

```typescript
/**
 * In-memory storage utilities for passkey registration and authentication.
 * A restart wipes everything; don't use in prod
 */

export type Passkey = {
  id: string; // credential identifier
  publicKey: Uint8Array; // raw public key returned by the authenticator
  counter: number; // signature counter
};

/**
 * Map of user → registered passkeys
 */
const store = new Map<string, Passkey[]>();
const registerChallenge = new Map<string, string>(); // registration challenge per user
const authChallenge = new Map<string, string>(); // auth challenge per user

export const db = { store, registerChallenge, authChallenge };
```

This is a temporary datastore so we can track challenges and users across a session. If you refresh your browser, this is cleared.

We will also want to register the challenge that is returned from `generateRegistrationOptions()` so that we can attest to this challenge in the next step:

```typescript
  db.registerChallenge.set(username, options.challenge);
```

Your full `regOptions()` function should look like this:

```typescript
export async function regOptions(username: string) {
  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userName: username,
    attestationType: "none",
    excludeCredentials: (db.store.get(username) || []).map((p) => ({
      id: p.id,
    })),
    supportedAlgorithmIDs: [-7],
  });
  db.registerChallenge.set(username, options.challenge);
  return options;
}
```

Awesome - now if you run the frontend and click Register… nothing will happen. We still need to sign an attestation to these options and verify the attestation.

Inside `register()` in `page.tsx` under where we fetched our options, paste this:

```typescript
const attestation = await startRegistration(options);
```

`startRegistration()` is a helper function we imported from `simplewebauthn/browser`. Now we have our attestation we can verify our registration:

```typescript
const res = await fetch("/api/register/verify", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ username, credential: attestation }),
      });
```

This is similar to how we got our options, but instead we are passing `username` and `attestation` in the body of our request.

Under this still inside `register()`, paste this:

```typescript
 const json = await res.json();
      if (json.verified) {
        await fetchJoke();
        setLoggedIn(true);
      } else {
        alert("Registration failed");
      }
    } catch (err) {
      console.error(err);
      alert("Registration failed");
    }
```

This will set us as logged in and catch errors if our registration failed.

Your full `register()` should look like this:

```typescript
const register = async () => {
    if (!username) {
      alert("Please enter a username");
      return;
    }
    try {
      const options = await fetch(
        `/api/register/options?u=${encodeURIComponent(username)}`
      ).then((res) => res.json());
      const attestation = await startRegistration(options);
      const res = await fetch("/api/register/verify", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ username, credential: attestation }),
      });
      const json = await res.json();
      if (json.verified) {
        await fetchJoke();
        setLoggedIn(true);
      } else {
        alert("Registration failed");
      }
    } catch (err) {
      console.error(err);
      alert("Registration failed");
    }
  };
```

If you go to `/api/register/verify/route.ts` you’ll see that our logic sits inside `regVerify()` in `webauth.ts`. Open `webautn.ts` and it looks like this:

```typescript
/**
 * Verify the client's response to a registration ceremony
 *
 * @param username - Username associated with the registration attempt
 * @param body - The response JSON returned by WebAuthn client
 * @returns `true` if the response is valid and stored in memory, if not `false`
 */
// TODO function to verify registration
export async function regVerify(
  username: string,
  body: RegistrationResponseJSON
) {}
```

Inside here we will call `verifyRegistrationResponse()` from `simplewebauthn` which takes these params:

```typescript
{
response // the body returned from startRegistration which contains the credential
expectedChallenge, // challenge signed by options step
expectedOrigin, // app domain
expectedRPID // app id
}
```

We pass `response` as `body`, will fetch `expected challenge` from the `db` and use env vars for `expectedOrigin` and `expectedRPID`.

```typescript
 const expectedChallenge = db.registerChallenge.get(username) ?? "";
  const verification = await verifyRegistrationResponse({
    response: body,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
  });
```

Now we need to add the user to our `db` so that we can remember them when they try to login. `verification` returns `registrationInfo` which contains some useful fields:

```typescript
verification
├─ verified                // boolean
├─ registrationInfo        
│   ├─ credential
│   │   ├─ id              // credential ID 
│   │   ├─ publicKey       // COSE public key
│   │   ├─ counter         // number  → initial signature counter (usually 0)
│   │   ├─ credentialBackedUp      // boolean 
│   │   └─ credentialDeviceType    // 'singleDevice' | 'multiDevice'
│   ├─ fmt                 // attestation format
│   ├─ aaguid              // authenticator GUID
│   ├─ attestationObject   // raw attestation data
│   ├─ clientDataJSON      // raw client data
│   └─ warnings           
├─ error                   // only when verified === false
└─ warning
```

We’re going to store some of the credential information and return `verified` to the frontend.

```typescript
if (verification.verified && verification.registrationInfo) {
    const { credential } = verification.registrationInfo;
    const { id, publicKey: credentialPublicKey, counter } = credential;

    const entry = {
      id,
      publicKey: credentialPublicKey,
      counter,
    };
    db.store.set(username, [...(db.store.get(username) || []), entry]);
  return verification.verified;
```

Your full `regVerify()` should look like this:

```typescript
export async function regVerify(
  username: string,
  body: RegistrationResponseJSON
) {
  const expectedChallenge = db.registerChallenge.get(username) ?? "";
  const verification = await verifyRegistrationResponse({
    response: body,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
  });

  if (verification.verified && verification.registrationInfo) {
    const { credential } = verification.registrationInfo;
    const { id, publicKey: credentialPublicKey, counter } = credential;

    const entry = {
      id,
      publicKey: credentialPublicKey,
      counter,
    };
    db.store.set(username, [...(db.store.get(username) || []), entry]);
  }

  return verification.verified;
}
```

Now open your browser to [http://localhost:3000/](http://localhost:3000/), type in a username, and click Register. Depending on your operating system, you should see something like this:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1754589334797/e3c1dcc6-2e10-43fd-b62d-96da0939e884.png align="center")

This might be a simple login to your computer. After completing verification, you should be logged in and see a new page!

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1754589010089/de7e0af4-194a-46e4-a8be-ffd9d820d498.png align="center")

(The joke will be different every time you log in; it’s sourced from `https://official-joke-api.appspot.com/random_joke`.

Congrats, you’ve just set up WebAuthn in your app and allowed people to register!

## Login flow

This flow is very similar:

* Sign a login challenge and receive `options`
    
* Use these options to sign an `assertion`
    
* Verify the `assertion`
    
* Login the user
    

Go into `page.tsx` and find:

```typescript
  /**
   * Initiates WebAuthn authentication (login)
   */
  // TODO function to login
  const login = async () => {};
```

Let’s fill all of it in now. You’ll see a familiar pattern:

```typescript
const login = async () => {
    if (!username) {
      alert("Please enter a username");
      return;
    }
    try {
      const options = await fetch(
        `/api/authenticate/options?u=${encodeURIComponent(username)}`
      ).then((res) => res.json());
      const assertion = await startAuthentication(options);
      const res = await fetch("/api/authenticate/verify", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ username, credential: assertion }),
      });
      const json = await res.json();
      if (json.verified) {
        await fetchJoke();
        setLoggedIn(true);
      } else {
        alert("Login failed – did you register first?");
      }
    } catch (err) {
      console.error(err);
      alert("Login failed – did you register first?");
    }
  };
```

We are generating options, calling `startAuthentication()` with these options, then verifying the assertion received from `startAuthentication()`.

The API routes here also call functions from `webauthn.ts` so let’s start there. Open `webauthn.ts` and find `authOptions()`:

```typescript
/**
 * Generate WebAuthn registration options for the user
 *
 * @param username - A username, also used as user handle
 * @returns Options that should be passed to `navigator.credentials.create`
 */
// TODO function to register options
export async function regOptions(username: string) {}
```

The first thing we need to do is ensure that users can only login with credentials tied to their account. to do this, we set a `allowCredentials` variable and grab the IDs associated with the `username` from our `db`.

```typescript
 const allowCredentials = (db.store.get(username) || []).map((p) => ({
    id: p.id,
  }));
```

Then we call `generateAuthenticationOptions()` from simplewebauthn with `allowCredentials` and the `rpID` we already defined:

```typescript
const options = await generateAuthenticationOptions({
    rpID,
    allowCredentials,
  });
```

Now, like we did with the registration options, we can save this challenge in our `db` to verify it in the next step. The entire function should look like this:

```typescript
export async function authOptions(username: string) {
  const allowCredentials = (db.store.get(username) || []).map((p) => ({
    id: p.id,
  }));

  const options = await generateAuthenticationOptions({
    rpID,
    allowCredentials,
  });

  db.authChallenge.set(username, options.challenge);
  return options;
}
```

These options are returned to our frontend which then calls `startAuthentication()` which gets our `assertion` in the body. We’ll use this inside `authVerify()` the same way we did with `regVerify()`. Find `authVerify()` in `webauthn.ts`:

```typescript
/**
 * Verify the client's response to a registration ceremony
 *
 * @param username - Username associated with the registration attempt
 * @param body - The response JSON returned by WebAuthn client
 * @returns `true` if the response is valid and stored in memory, if not `false`
 */
// TODO function to verify registration
export async function regVerify(
  username: string,
  body: RegistrationResponseJSON
) {}
```

We need to get the challenge from our `db` that we created in `authOptions()`. Paste this into the `regVerify()` function:

```typescript
  const expectedChallenge = db.authChallenge.get(username) ?? "";
```

Now we can call `verifyAuthenticationResponse()` from `simplewebauthn/server` which takes these params:

```typescript
{ 
response // assertion
expectedChallenge
expectedOrigin
expectedRPID
credential {
id
publicKey
counter
transports // optional
}
```

We derive the values for for `response`, `expectedChallenge`, `expectedOrigin` and `expectedRPID` in the same way we derived them for `verifyReg()`. We can get the `credential` data from our `db`:

```typescript
 const creds = (db.store.get(username) || []).find((c) => c.id === body.id);
  if (!creds) return false;
```

This returns the `id`, `publicKey`, and `counter` of our passkey credential. Now we can call `verifyAuthenticationResponse()` and return `verification`:

```typescript
 const verification = await verifyAuthenticationResponse({
    response: body,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: {
      publicKey: creds.publicKey,
      counter: creds.counter,
      id: creds.id,
    },
return verification
  });
```

We will also need to increase the `counter` of the `credential` as we have logged in another time. If you don’t increase this, the login attempt may be flagged as a relay attack.

```typescript
 if (verification.verified && verification.authenticationInfo) {
    creds.counter = verification.authenticationInfo.newCounter;
  }
```

The whole `authVerify()` function should look like this:

```typescript
export async function authVerify(
  username: string,
  body: AuthenticationResponseJSON
) {
  const expectedChallenge = db.authChallenge.get(username) ?? "";

  const creds = (db.store.get(username) || []).find((c) => c.id === body.id);
  if (!creds) return false;

  const verification = await verifyAuthenticationResponse({
    response: body,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: {
      publicKey: creds.publicKey,
      counter: creds.counter,
      id: creds.id,
    },
  });

  if (verification.verified && verification.authenticationInfo) {
    creds.counter = verification.authenticationInfo.newCounter;
  }
  return verification.verified;
}
```

Now when you go to [`http://localhost:3000/`](http://localhost:3000/) you’ll have both Register and Login buttons working! As it’s a temporary database, you will need to register a new user every time you refresh the page. Try registering, logging out, and then logging in with the user you just registered.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1754592668514/02930d44-6c87-46f6-ac9a-de71b04371bd.png align="center")

Congratulations! you have just implemented a simple register and login flow with WebAuthn!

# Considerations

* When using WebAuthn in production, be sure to use a real datastore
    
* Do not store challenges in a datastore like this example - challenges should expire after verification
    
* Users can (and probably will) have multiple credentials for one account, eg their laptop and their phone. Be sure to handle this properly in your datastore
    
* You will likely still want to implement fallback username/password solutions or social logins. WebAuthn may not work on all browsers, eg older Android devices
    
* This example doesn’t handle errors very smoothly. There are a few edge cases when relying on external auth, eg users might delete credentials from their device, so you’ll need to handle this smoothly in your app
    

### SimpleWebAuthn gotchas

* If you're deploying behind a reverse proxy or custom domain, `rpID` might not be something you expect; you may need to detect it dynamically or enforce it
    
* `publicKey` is a raw `Uint8Array` which can be confusing when using `simplewebauthn` with other services; you may have to convert this into `PEM` or `JWK` sometimes
    
* Sometimes errors are not helpful at all - you may often get `DOMException: The operation either timed out or was not allowed.`; if you can’t find anything that could be the issue a trick is to set `attestationType: "none"`, as often this error comes from Safari or or other Apple products trying to preserve privacy
    

### Cool things to know

* `simplewebauthn` allows you to specify `transports` to indicate how the authenticator communicates with the client device, such as `"internal"` (built-in like Face ID), `"usb"`, or `"nfc"`
    
* You can set `attestationType: "none"` to avoid storing any identifying data, eg browser or location, to keep user privacy
    

# Next steps

Now you’ve got passkeys working on a Next.js app, you can level it up by using passkeys to create an login to a blockchain wallet with [Privy](https://www.privy.io/). Take it a step further and make functions gasless so users barely even know they’re interacting with the blockchain. You can find an example of an app that looks identical to this but with a wallet included [here](http://privy.mcgee.cat/) and the code [here](https://github.com/catmcgee/webauthn-nextjs/tree/privy).

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1754606443483/e549e18d-c700-468b-bb28-58eb1496820f.png align="center")
