Understanding WebAuthn and Passkeys
WebAuthn is a W3C standard that allows browsers to create and use public-key credentials for authentication. Passkeys are the user-friendly implementation of this standard, backed by the FIDO Alliance. Instead of passwords, the private key stays on the user's device while the public key lives on your server.
The protocol uses two main ceremonies: registration (navigator.credentials.create()) and authentication (navigator.credentials.get()). These operations are performed by platform authenticators such as Touch ID, Face ID, Windows Hello, or roaming authenticators like YubiKey.
Key properties that make Passkeys valuable for Next.js applications:
- Phishing resistance because credentials are bound to the origin (your domain).
- No shared secrets that can be stolen in a breach.
- Automatic sync across devices when using iCloud Keychain or Google Password Manager.
- Support for discoverable credentials (resident keys) that eliminate the need to enter a username.
In a typical Next.js 15 app running on the App Router, we combine server actions, route handlers, and React components to orchestrate these browser APIs with our backend.
Project Setup with Next.js 15 and Required Dependencies
Start with the latest Next.js 15 template. Run the following commands:
npx create-next-app@15.0.1 my-passkey-app --typescript --tailwind --eslint --app --yes
cd my-passkey-app
npm install next-auth@5.0.0-beta.25 @auth/prisma-adapter prisma @prisma/client
npm install --save-dev @types/node
npx prisma init
The versions above were confirmed stable at the time of writing: Next.js 15.0.1, Auth.js beta.25, Prisma 5.15.0. Using the beta of Auth.js v5 is necessary because WebAuthn support matured in this release.
Update your next.config.mjs to enable the required headers and experimental features:
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: '2mb',
},
},
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
],
},
];
},
};
export default nextConfig;
Prisma Schema for Passkey Storage
Auth.js v5 requires a specific schema for WebAuthn. Replace your prisma/schema.prisma with the following:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
authenticators Authenticator[]
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Authenticator {
credentialID String @id
userId String
providerAccountId String
credentialPublicKey String
counter Int
credentialDeviceType String
credentialBackedUp Boolean
transports String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, credentialID])
}
Run npx prisma db push after setting your DATABASE_URL to a PostgreSQL instance (Supabase, Neon, or local Postgres 16 work well). This schema stores the public key, counter for replay protection, and device metadata.
Trade-off: Storing the credentialPublicKey as a base64 string increases database size slightly compared to password hashes, but removes the need for any password-related fields entirely.
Configuring Auth.js v5 with the WebAuthn Provider
Create app/api/auth/[...nextauth]/route.ts:
import NextAuth from "next-auth"
import { PrismaAdapter } from "@auth/prisma-adapter"
import prisma from "@/lib/prisma"
import Credentials from "next-auth/providers/credentials"
import { WebAuthn } from "@auth/core/providers/webauthn"
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
WebAuthn({
name: "Passkey",
// Only allow discoverable credentials for true passwordless experience
enableConditionalUI: true,
}),
],
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
}
return token
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string
}
return session
},
},
pages: {
signIn: "/login",
},
})
Note the use of enableConditionalUI: true. This tells the browser to show the passkey autofill UI when a username field with autocomplete="username webauthn" is focused.
The PrismaAdapter automatically handles creation of the Authenticator records during registration.
Building the Registration Flow
Create a server action for registration at app/actions.ts:
"use server"
import { auth } from "./api/auth/[...nextauth]/route"
import { generateRegistrationOptions, verifyRegistrationResponse } from "@simplewebauthn/server"
import prisma from "@/lib/prisma"
export async function registerPasskey(email: string) {
const user = await prisma.user.findUnique({ where: { email } })
if (!user) {
// Create user first if they don't exist
await prisma.user.create({
data: { email, name: email.split("@")[0] }
})
}
const options = await generateRegistrationOptions({
rpName: "Ajendra Demo",
rpID: process.env.NEXT_PUBLIC_WEBAUTHN_RP_ID || "localhost",
userID: Buffer.from(email),
userName: email,
attestationType: "none", // "direct" for high security with hardware keys
authenticatorSelection: {
residentKey: "required",
userVerification: "preferred",
},
})
return options
}
export async function verifyRegistration(data: any, email: string) {
const verification = await verifyRegistrationResponse({
response: data,
expectedChallenge: data.challenge,
expectedOrigin: process.env.NEXT_PUBLIC_WEBAUTHN_ORIGIN || "http://localhost:3000",
expectedRPID: process.env.NEXT_PUBLIC_WEBAUTHN_RP_ID || "localhost",
})
if (verification.verified) {
await prisma.authenticator.create({
data: {
credentialID: verification.registrationInfo!.credentialID,
userId: (await prisma.user.findUnique({ where: { email } }))!.id,
providerAccountId: verification.registrationInfo!.credentialID,
credentialPublicKey: Buffer.from(verification.registrationInfo!.credentialPublicKey).toString("base64"),
counter: verification.registrationInfo!.counter,
credentialDeviceType: verification.registrationInfo!.credentialDeviceType,
credentialBackedUp: verification.registrationInfo!.credentialBackedUp,
}
})
return { success: true }
}
return { success: false }
}
The frontend registration page uses these server actions. Here is the React component using server actions with React Hook Form (version 7.51):
"use client"
import { useState } from "react"
import { registerPasskey, verifyRegistration } from "./actions"
export default function RegisterForm() {
const [email, setEmail] = useState("")
const [step, setStep] = useState<"email" | "passkey">("email")
const handleRegister = async () => {
const options = await registerPasskey(email)
const credential = await navigator.credentials.create({
publicKey: {
...options,
challenge: new Uint8Array(options.challenge),
user: {
...options.user,
id: new Uint8Array(options.user.id),
},
}
}) as PublicKeyCredential
const result = await verifyRegistration(credential, email)
if (result.success) {
window.location.href = "/dashboard"
}
}
return (
<div className="max-w-md mx-auto">
{step === "email" ? (
<input
type="email"
value={email}
=> setEmail(e.target.value)}
placeholder="you@example.com"
className="w-full p-3 border rounded-lg"
autoComplete="username webauthn"
/>
) : (
<button
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium"
>
Create Passkey
</button>
)}
</div>
)
}
Implementing the Login Flow with Conditional UI
Login is simpler because we can use conditional mediation. Create a similar server action for generating authentication options using generateAuthenticationOptions from @simplewebauthn/server.
The client code for login looks like this:
async function loginWithPasskey() {
const options = await getAuthenticationOptions() // your server action
const credential = await navigator.credentials.get({
publicKey: {
...options,
challenge: new Uint8Array(options.challenge),
allowCredentials: options.allowCredentials.map((cred: any) => ({
...cred,
id: new Uint8Array(cred.id),
})),
},
mediation: "conditional" // enables autofill UI
}) as PublicKeyCredential
const verification = await verifyAuthentication(credential)
if (verification.success) {
// Auth.js session is created automatically via the provider
window.location.href = "/dashboard"
}
}
Auth.js v5 automatically calls your adapter methods when the verification succeeds. The counter is incremented on every successful authentication to prevent replay attacks. This is handled inside the WebAuthn provider.
Production Configuration and Environment Variables
For production you must set these variables in your Vercel dashboard or .env:
NEXT_PUBLIC_WEBAUTHN_RP_ID=yourdomain.com
NEXT_PUBLIC_WEBAUTHN_ORIGIN=https://yourdomain.com
AUTH_SECRET=32-character-random-string-generated-with-openssl-rand-hex-32
DATABASE_URL=postgresql://...
Use a strong AUTH_SECRET. On Vercel you can generate it with the CLI: openssl rand -hex 32.
Important production trade-off: When using Cloudflare or Vercel Edge, some WebAuthn verification libraries have compatibility issues because they rely on Node.js crypto. For this reason we run authentication routes on the Node.js runtime explicitly by adding export const runtime = 'nodejs' to the route handler.
Browser Support, Device Testing and Fallbacks
Current support (as of December 2024):
- Chrome/Edge 67+
- Safari 16+ (macOS Ventura, iOS 16)
- Firefox 119+ with
security.webauthn.ctap2enabled
Test on physical devices. The iOS simulator in Xcode 16 and Android Emulator with Google Play Services both support Passkeys. For automated testing, Playwright 1.42 added experimental WebAuthn support with context.grantPermissions(['clipboard-write']) and virtual authenticators.
Implement a fallback to email magic links for older browsers. Detect support with:
function supportsPasskeys() {
return window.PublicKeyCredential &&
typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === "function"
}
When the function returns false, show the traditional login form. This graceful degradation is essential for broad adoption.
Security Analysis and Hardening Steps
Passkeys eliminate many traditional attack vectors:
- No password database to be breached.
- Origin binding prevents phishing even if user is tricked into visiting evil.com.
- Attestation (when set to "direct") allows you to restrict which authenticators are allowed (e.g. only YubiKeys from a specific vendor).
- The signature counter prevents cloned authenticators from being used.
Additional hardening you should implement:
- Rate limit the authentication routes using Upstash Ratelimit (limit of 10 attempts per IP per minute).
- Store the
userVerificationrequirement as "required" for high-security applications like fintech. - Periodically rotate RP ID if you change domains (requires re-registration of all users).
- Log authentication events to a separate audit table for compliance.
One trade-off is that resident keys (discoverable credentials) consume more space on security keys with limited slots (YubiKey 5 has roughly 100). For consumer apps, using non-resident keys with username + passkey is often the pragmatic choice.
Advanced Topics: Multi-Device Support and Account Recovery
Modern password managers sync passkeys across devices. When a user registers on their MacBook, the same passkey becomes available on their iPhone via iCloud. Google Password Manager does the same for Android/Chrome users.
For account recovery, one recommended pattern is to allow users to register multiple passkeys (one for laptop, one for phone, one hardware key). Modify the registration flow to accept an optional label like "MacBook Pro" so users can manage their credentials later.
Create a credentials management page that lists all Authenticator records for the current user using a simple query:
const authenticators = await prisma.authenticator.findMany({
where: { userId: session.user.id },
select: { credentialDeviceType: true, createdAt: true }
})
Users should be able to delete lost credentials. Deleting the last credential should be blocked unless you have implemented a secondary recovery method (backup codes or admin intervention).
Observability and Monitoring in Production
Add logging around the WebAuthn flows using the built-in Auth.js events:
events: {
async createUser({ user }) {
console.log(`New user registered with passkey: ${user.email}`)
},
async signIn({ user, account }) {
if (account?.provider === "webauthn") {
// Record successful authentication with device type
}
}
}
Monitor for spikes in registration failures which often indicate RP ID or origin misconfiguration. Tools like Vercel Analytics or a simple PostgreSQL query counting failed verification attempts per hour provide early warning.
Performance characteristic: The entire registration ceremony usually completes in under 800ms on modern devices. The cryptographic operations are handled by the platform, so your server mainly validates the signature which takes ~15ms on a Vercel Hobby instance.
Comparison with Alternative Authentication Approaches
Compared to magic links (which Auth.js also supports), Passkeys require zero user input after the initial registration. Magic links still depend on email deliverability and are vulnerable to SIM swapping.
Compared to TOTP apps, Passkeys remove the need to switch contexts between your app and an authenticator app. They are also immune to time-based attacks.
Compared to social login (Google, GitHub), Passkeys keep users within your domain and avoid sharing identity data with third parties. However, social providers often have better conversion rates for new users because they reduce friction to a single click.
Many production apps combine approaches: offer Passkeys for returning users and social/email for new sign-ups. Auth.js v5 makes this easy by registering multiple providers.
Deployment Checklist for Vercel
Before going live:
- Set all environment variables in both Preview and Production environments.
- Update your Content Security Policy to allow
selffor WebAuthn. - Test registration and login on iOS 17, macOS Sonoma, Windows 11, and Android 14.
- Verify that your RP ID matches the exact domain (no www vs non-www mismatch).
- Configure session cookie settings with
sameSite: "lax"andsecure: true. - Add a privacy policy link explaining biometric data is never sent to your servers.
After deployment, the first 100 users will reveal edge cases. Common issues include corporate laptops with MDM policies that disable platform authenticators and Safari users who have not yet enabled iCloud Keychain syncing.
The complete implementation shown above gives you a production-grade passwordless system that scales with Next.js 15 streaming capabilities and the App Router. The combination of strong cryptography, excellent UX, and straightforward server-side verification makes Passkeys one of the highest-ROI security improvements available to modern web applications today.
