Passkeys and WebAuthn in Next.js 16: Production Auth Without Passwords (2026)
Ship passwordless login in Next.js 16 with WebAuthn passkeys: SimpleWebAuthn ceremony, Auth.js credentials, discoverable credentials, device sync, fallback recovery, and production pitfalls that leak accounts.
By Mussawar Hayat
Why Passwords Are the Wrong Default in 2026
Password resets, credential stuffing, and phishing still dominate SaaS support queues. Passkeys (WebAuthn credentials bound to a device or synced vault) remove the shared secret. In a Next.js 16 App Router app you can register and authenticate passkeys with a short ceremony: challenge on the server, credential on the client, verified assertion stored against the user. This guide covers a production path using SimpleWebAuthn, Server Actions or Route Handlers, and session issuance you can drop next to Auth.js or a custom JWT cookie.
What You Will Learn
- Registration vs authentication ceremonies and what must stay on the server
- Challenge storage, origin checks, and RP ID rules that fail in staging
- Discoverable credentials vs username-first flows
- How to store public keys, counter, and transports without leaking PII
- Recovery, device revocation, and a password fallback that does not undo the model
- Production checklist for HTTPS, Apple/Google sync, and bot abuse
1. Mental Model: Two Ceremonies
WebAuthn is not login with a biometric. The biometric (or PIN, or hardware button) unlocks a private key that never leaves the authenticator. Your server only ever sees public keys and signed challenges.
- Registration — create a credential. Server issues a challenge + user handle. Browser calls
navigator.credentials.create(). Server verifies the attestation and storescredentialID, public key, counter, and transports. - Authentication — prove possession. Server issues a challenge (optionally scoped to allowed credential IDs). Browser calls
navigator.credentials.get(). Server verifies the assertion signature and counter, then issues a session.
Never verify assertions in the browser. Never reuse a challenge. Never skip origin / RP ID checks because it works on localhost.
2. Relying Party Config That Survives Staging
Your Relying Party (RP) identity is the most common production break. It must match the domain the user sees:
export const rpName = 'Your Product'
export const rpID = process.env.WEBAUTHN_RP_ID! // e.g. app.example.com
export const origin = process.env.WEBAUTHN_ORIGIN! // e.g. https://app.example.com
rpIDis a registrable domain suffix of the page origin.app.example.comcannot userpID: example.comunless you intentionally share credentials across subdomains and understand the risk.originmust be the exact scheme + host + port. Preview URLs (Vercel, Cloudflare) need their own RP ID or a dedicated auth hostname.- Local dev:
localhostwithhttp://localhost:3000is allowed.127.0.0.1is a different origin.
3. Data Model
Keep passkeys as first-class devices, not a JSON blob on the user row:
model Passkey {
id String @id @default(cuid())
userId String
credentialId Bytes @unique
publicKey Bytes
counter BigInt
deviceType String
backedUp Boolean
transports String[]
createdAt DateTime @default(now())
lastUsedAt DateTime?
nickname String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
Store credentialId and publicKey as bytes. Store the signature counter and reject assertions that go backwards (cloned authenticator signal). Store transports so the next get() can hint USB vs hybrid vs internal.
4. Registration Flow (Server + Client)
Generate options on the server with @simplewebauthn/server:
import { generateRegistrationOptions } from '@simplewebauthn/server'
export async function startRegistration(user: { id: string; email: string; name: string }) {
const existing = await db.passkey.findMany({ where: { userId: user.id } })
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: user.email,
userDisplayName: user.name,
userID: new TextEncoder().encode(user.id),
attestationType: 'none',
excludeCredentials: existing.map((c) => ({
id: c.credentialId,
transports: c.transports as AuthenticatorTransportFuture[],
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
})
await redis.set('webauthn:reg:' + user.id, options.challenge, { ex: 60 })
return options
}
On the client, pass those options to startRegistration from @simplewebauthn/browser, then POST the credential to a verification Server Action. Verify with verifyRegistrationResponse against the stored challenge, origin, and RP ID. Persist only after verification succeeds.
Use attestationType: 'none' unless you have a compliance reason to inspect authenticators. Attestation adds privacy and ops cost most SaaS products do not need.
5. Authentication Flow
Two UX patterns:
- Discoverable (usernameless) — empty
allowCredentials. The authenticator or password manager lists passkeys for this RP. Best for Sign in with passkey on the homepage. - Identifier-first — user types email, you load that user's credential IDs into
allowCredentials. Better when users have many accounts on shared devices.
import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server'
export async function startAuthentication(email?: string) {
const allowCredentials = email
? (await db.passkey.findMany({ where: { user: { email } } })).map((c) => ({
id: c.credentialId,
transports: c.transports as AuthenticatorTransportFuture[],
}))
: undefined
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
allowCredentials,
})
await redis.set('webauthn:auth:' + options.challenge, email ?? '*', { ex: 60 })
return options
}
After verifyAuthenticationResponse:
- Reject if the credential is unknown or belongs to a different user.
- Persist the new counter.
- Update
lastUsedAt. - Issue the same session you would issue after a password login (httpOnly, Secure, SameSite=Lax or Strict, short-lived access + rotating refresh if you use that pattern).
6. Recovery Without Reintroducing Phishing
Passkeys fail when a user gets a new phone and did not sync iCloud Keychain or Google Password Manager. Plan this before launch:
- Allow multiple passkeys per user and show a device list with last-used timestamps.
- Offer add another passkey while already authenticated.
- Recovery codes (hashed, single-use) beat email magic links that can also reset everything if the inbox is the attacker's real target.
- If you keep a password fallback, rate-limit it harder than passkey auth and never let a password reset mint a passkey on a new device without a second factor you already trust.
7. Production Pitfalls
- Challenge in a cookie only — fine for one tab; use server storage keyed by user or challenge so two parallel ceremonies do not clobber each other.
- Base64url vs Buffer mixups — SimpleWebAuthn v13+ uses Uint8Array. Do not double-encode credential IDs.
- Missing Conditional UI —
mediation: 'conditional'plus an input with autocomplete username webauthn enables the browser autofill passkey prompt. Without it, users think passkeys do not work. - Bot farms hitting options endpoints — generate options is cheap until it is not. Rate-limit by IP and by account.
- Ignoring counter — a cloned security key can replay. Monotonic counter checks are the cheap signal.
- Wrong RP ID on www vs apex — pick one canonical host and redirect the other before auth.
8. Ship Checklist
- HTTPS everywhere; exact origin and RP ID in env per environment
- Challenges expire in 60 seconds and are single-use
- Passkeys stored as rows with counter, transports, and last used
- User can list, nickname, and revoke devices
- Discoverable sign-in on the marketing login plus identifier-first as fallback
- Conditional UI enabled on the login form
- Rate limits on options + verify routes
- Session cookies match your existing auth hardness (httpOnly, Secure)
- Recovery path documented in support and in-product
Summary
Passkeys in Next.js 16 are a server-verified WebAuthn ceremony plus ordinary session issuance. The hard parts are RP identity, challenge lifecycle, counter checks, and recovery — not the browser API. Use SimpleWebAuthn for the cryptography, keep credentials as first-class rows, and treat new device as a product problem rather than an email-link shortcut.
Key Takeaway
Verify every assertion on the server against origin, RP ID, challenge, and signature counter. Everything else is session plumbing you already know how to ship.
Need production auth or a Next.js 16 security review?
I design Auth.js / custom session stacks, passkey rollout plans, and hardened Server Actions for SaaS teams. Get in touch or see full-stack engineering services.
Related guides
A practical 2026 guide to evaluating and migrating a Next.js 16 App Router app to Cloudflare vinext. Covers vinext check and init, Vite 8 / Rolldown, Workers bindings, ISR, known Cache Components gaps, security, and when to stay on Next.js or OpenNext instead.
Rate Limit Next.js 16 Route Handlers Before AI Agents Melt Your API (2026)Public Route Handlers and agent tool endpoints fail the same way: one noisy client exhausts Postgres, email, or a paid model. This production guide shows how to add identity-aware rate limits in Next.js 16 with Redis, sliding windows, and cheap in-memory fallbacks.
Next.js 16.3 Instant Navigations: Production Guide for SPA-Like Server ComponentsNext.js 16.3 Instant Navigations make Server Components feel as responsive as SPAs. Enable cacheComponents + partialPrefetching, use Suspense streaming or use cache, inspect shells, and ship instant first-click navigations without giving up the server model.
