Decorative background gradient
Back to Blog
PasskeysWebauthnNextjs AuthenticationPasswordless Login

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.

Passkeys in Next.js — Passwordless Login with WebAuthn and SimpleWebAuthn

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:

text

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

bash

You need one new table: a user can have many passkeys (laptop, phone, password manager).

prisma

Then centralize your relying party settings so they're identical everywhere:

ts

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:

ts

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:

ts

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:

tsx

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:

ts

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.app have 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. NotAllowedError usually 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.

Frequently Asked Questions

What is the difference between a passkey and a password?

A password is a shared secret — both the user and the server know it, so it can be phished, reused, or leaked in a database breach. A passkey is a key pair: the server stores only the public key, and the private key stays on the user's device or synced password manager. Login works by the device signing a one-time challenge, so there is nothing reusable for an attacker to capture.

Do passkeys work across devices?

Yes. Passkeys created in iCloud Keychain, Google Password Manager, or a third-party manager like 1Password sync across the user's devices automatically. On a device without the passkey, users can scan a QR code with their phone to sign in, which uses the phone's passkey over a proximity-checked connection.

Should I use a library like Better Auth instead of SimpleWebAuthn directly?

If you already use an auth library that ships a passkey plugin — Better Auth, Auth.js, or a hosted provider like Clerk — use that, since it handles sessions, challenge storage, and account linking for you. Using SimpleWebAuthn directly makes sense when you have a custom auth system, or when you want to understand exactly what happens during a passkey ceremony before trusting an abstraction with it.

Why does my passkey work on localhost but fail in production?

Passkeys are bound to the relying party ID, which is your domain. A credential registered with rpID "localhost" can never be used on "example.com", and the expected origin must match the exact protocol, host, and port the browser reports. Set rpID and the expected origin from environment variables, and remember that users will need to register a new passkey in each environment.

Working on something similar? Take a look at my services and case studies, or book a free call to talk about your idea.

Related Articles

Trending Topics