Skip to main content
Full-Stack·11 min read

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 stores credentialID, 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
  • rpID is a registrable domain suffix of the page origin. app.example.com cannot use rpID: example.com unless you intentionally share credentials across subdomains and understand the risk.
  • origin must be the exact scheme + host + port. Preview URLs (Vercel, Cloudflare) need their own RP ID or a dedicated auth hostname.
  • Local dev: localhost with http://localhost:3000 is allowed. 127.0.0.1 is 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.