How to use Passkeys in Next.js using WebAuthn
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:
Node v20+
Your preferred package manager (this tutorial uses bun, my personal favourite)
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 # 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:
├── 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:
bun dev # or npm dev / pnpm dev / yarn 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:
/**
* 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
optionsUse these options to sign an
attestationVerify the
attestationLogin 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:
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:
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():
/**
* 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:
{
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:
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:
/**
* 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:
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:
/**
* 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:
{
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.
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 // 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.
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:
Sign a login challenge and receive
optionsUse these options to sign an
assertionVerify the
assertionLogin the user
Go into page.tsx and find:
/**
* 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:
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():
/**
* 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.
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:
/**
* 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:
const expectedChallenge = db.authChallenge.get(username) ?? "";
Now we can call verifyAuthenticationResponse() from simplewebauthn/server which takes these params:
{
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:
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,
rpIDmight not be something you expect; you may need to detect it dynamically or enforce itpublicKeyis a rawUint8Arraywhich can be confusing when usingsimplewebauthnwith other services; you may have to convert this intoPEMorJWKsometimesSometimes 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 setattestationType: "none", as often this error comes from Safari or or other Apple products trying to preserve privacy
Cool things to know
simplewebauthnallows you to specifytransportsto 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.
