Passkeys in Next.js — Passwordless Login with WebAuthn and SimpleWebAuthn
Passkeys are replacing passwords across every major platform. Here's how to add passkey registration and login to a Next.js App Router app with SimpleWebAuthn — challenges, verification, and the mistakes that break it.

Passwords have been "about to die" for a decade, but 2026 is the year the replacement actually stuck. Apple, Google, and Microsoft now default new accounts to passkeys, password managers sync them across devices, and users increasingly expect a "Sign in with passkey" button instead of yet another password field. The good news: adding passkeys to a Next.js app is a few hundred lines of code, not a security research project.
This guide uses SimpleWebAuthn with the App Router and Server Actions. If you're still choosing an auth stack, see the best Next.js auth libraries compared — several of them ship passkey support out of the box.
Step 1: Understand the Ceremony Before Writing Code
Every passkey flow — registration and login — follows the same three-step shape:
The server never sees a secret. During registration it receives and stores a public key; during login it checks that whoever responded holds the matching private key. That's why passkeys are phishing-resistant — a fake login page on a different domain can't get a valid signature, because the browser binds every signature to the real site's origin.
Step 2: Install the Packages and Define the Data Model
You need one new table: a user can have many passkeys (laptop, phone, password manager).
Then centralize your relying party settings so they're identical everywhere:
The rpID is the registrable domain, with no protocol or port. It's the single most common source of "works locally, fails in production" bugs.
Step 3: Store Challenges Server-Side, Use Them Once
The challenge is what stops replay attacks, so it must come from your server and be consumed exactly once. A short-lived, httpOnly cookie or a server-side session works well:
Never accept a challenge that the client sends back in the request body. If you verify against whatever the browser claims the challenge was, an attacker can replay an old signed response forever.
Step 4: Build the Registration Flow
Registration happens for a user who is already signed in (or mid-sign-up). The server generates options:
excludeCredentials stops a user from registering the same authenticator twice, and residentKey: "preferred" creates a discoverable credential — the kind that lets users sign in without typing a username.
On the client, the browser package does the heavy lifting:
Step 5: Build the Login Flow
Login is the same ceremony in reverse. Because the passkey is discoverable, you can leave allowCredentials empty and let the browser show every passkey the user has for your site:
The client mirrors registration with startAuthentication({ optionsJSON }). For the smoothest experience, also enable conditional UI — passing useBrowserAutofill: true and adding autocomplete="username webauthn" to your email input makes passkeys appear directly in the browser's autofill dropdown.
Step 6: Avoid the Mistakes That Break Passkeys in Production
- Mismatched rpID or origin. Preview deployments on
*.vercel.apphave a different domain from production, so passkeys registered on one will never work on the other. Set both values per environment. - Ignoring the counter. Synced passkeys usually report a counter of
0, which is fine. But if a non-zero counter ever goes backwards, that's a signal of a cloned authenticator — log it. - No recovery path. If passkeys are the only way in and a user loses their device without sync, they're locked out. Keep an email magic link or let users register a second passkey.
- Forcing passkeys too early. Offer "Add a passkey" right after a successful login, when the user is already authenticated. Prompting a brand-new visitor before they've signed up converts badly.
- Treating cancellation as an error.
NotAllowedErrorusually means the user closed the prompt. Don't show a scary red banner for it.
Key Takeaways
Passkeys replace a shared secret with a device-held key pair, which removes phishing, credential stuffing, and password-database leaks in one move. In Next.js, the implementation comes down to a single table, a server-stored one-time challenge, and two Server Actions per ceremony — generate options, then verify the response. Get the rpID and origin right per environment, store and consume challenges on the server, keep a recovery path, and passkeys become the fastest and safest way for your users to sign in.

