In this tutorial, we will learn how to use SimpleWebAuthn to allow users to register and login with passkeys in a Next.js app. It assumes you have basic knowledge of Typescript and Next.js. We’ll be building this (live demo).
If you’d rather dive head-first into the code, you can find the repo here.
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?

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 and FIDO and introduces a standardized way of using passkeys in websites. It works like this:
Registration (creating an account)
Browser → Authenticator: “Generate a key-pair and sign this challenge”
Authenticator: generates key-pair, stores the private key, hands back public key + signature to challenge
Browser → Server: sends that public key and challenge (attestation) for the server to store
Login
Server → Browser: sends a random challenge
Browser → Authenticator: asks it to sign the challenge (after Face ID / Touch ID / PIN)
Authenticator → Browser: returns challenge + signature
Browser → Server: forwards them (assertion)
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 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:
Setup
Open your terminal and run:
git clone --branch starter https://github.com/catmcgee/webauthn-nextjs.git webauthn-example
cd webauthn-example
cp .env.example .env.local
bun install
This clones the repo, sets up our .env file and installs dependencies.
This is the structure of our app:
├── README.md
├── bun.lock
├── eslint.config.mjs
├── next-env.d.ts
├── next.config.ts
├── package.json
├── postcss.config.mjs
├── public
│ └── homescreen.png
├── src
│ ├── app
│ │ ├── api
│ │ │ ├── authenticate
│ │ │ └── register
│ │ ├── favicon.ico
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ └── page.tsx
│ └── lib
│ ├── passkeys.ts
│ └── webauthn.ts
└── 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:
bun dev
Open your browser to http://localhost:3000/ and you’ll see something like this:

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:
const register = async () => {};
This function needs to:
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:
import { NextResponse } from "next/server";
import { regOptions } from "@/lib/webauthn";
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:
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():
export async function regOptions(username: string) {}
Inside here we are going to call generateRegistrationOptions() that we imported from @simplewebauthn/server. This takes these params:
{
rpName,
rpID,
username,
attestationType,
excludeCredentials,
supportedAlgorithms
}
We have rpName and rpID in our .env.local:
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:
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:
export type Passkey = {
id: string;
publicKey: Uint8Array;
counter: number;
};
const store = new Map<string, Passkey[]>();
const registerChallenge = new Map<string, string>();
const authChallenge = new Map<string, string>();
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:
db.registerChallenge.set(username, options.challenge);
Your full regOptions() function should look like this:
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:
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:
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:
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:
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:
export async function regVerify(
username: string,
body: RegistrationResponseJSON
) {}
Inside here we will call verifyRegistrationResponse() from simplewebauthn which takes these params:
{
response
expectedChallenge,
expectedOrigin,
expectedRPID
}
We pass response as body, will fetch expected challenge from the db and use env vars for expectedOrigin and expectedRPID.
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:
verification
├─ verified
├─ registrationInfo
│ ├─ credential
│ │ ├─ id
│ │ ├─ publicKey
│ │ ├─ counter
│ │ ├─ credentialBackedUp
│ │ └─ credentialDeviceType
│ ├─ fmt
│ ├─ aaguid
│ ├─ attestationObject
│ ├─ clientDataJSON
│ └─ warnings
├─ error
└─ warning
We’re going to store some of the credential information and return verified to the frontend.
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:
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/, type in a username, and click Register. Depending on your operating system, you should see something like this:

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

(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:
Go into page.tsx and find:
const login = async () => {};
Let’s fill all of it in now. You’ll see a familiar pattern:
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():
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.
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:
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:
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:
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:
const expectedChallenge = db.authChallenge.get(username) ?? "";
Now we can call verifyAuthenticationResponse() from simplewebauthn/server which takes these params:
{
response
expectedChallenge
expectedOrigin
expectedRPID
credential {
id
publicKey
counter
transports
}
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:
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:
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.
if (verification.verified && verification.authenticationInfo) {
creds.counter = verification.authenticationInfo.newCounter;
}
The whole authVerify() function should look like this:
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/ 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.

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. 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 and the code here.
