Passkeys and WebAuthn with Next.js 15: Build Passwordless Authentication in 2026

The password era is ending. Passkeys — built on the WebAuthn standard — let users authenticate with biometrics, device PINs, or hardware keys instead of passwords. In this tutorial, you will build a complete passwordless authentication system with Next.js 15, using the SimpleWebAuthn library to handle the WebAuthn ceremony on both client and server.
What You Will Learn
By the end of this tutorial, you will:
- Understand how Passkeys and the WebAuthn API work under the hood
- Set up a Next.js 15 project with TypeScript and Prisma
- Implement passkey registration (credential creation)
- Build passwordless login using biometrics or device PIN
- Handle the complete WebAuthn ceremony (challenge, attestation, assertion)
- Store and verify credentials in a PostgreSQL database
- Add fallback authentication for devices without passkey support
- Deploy a production-ready passwordless auth system
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed (
node --version) - TypeScript experience (types, generics, async/await)
- Next.js 15 familiarity (App Router, Server Components, Route Handlers)
- PostgreSQL running locally or a cloud database (Neon, Supabase, or similar)
- A modern browser that supports WebAuthn (Chrome, Safari, Firefox, Edge)
- A device with biometric capability (Touch ID, Face ID, Windows Hello) or a hardware security key
Why Passkeys?
Traditional passwords are the weakest link in web security. Users reuse them, forget them, and fall for phishing attacks that steal them. Passkeys solve all three problems:
| Feature | Passwords | Passkeys |
|---|---|---|
| Phishing resistant | No | Yes — bound to origin |
| Nothing to remember | No | Yes — biometric or device PIN |
| Reuse across sites | Common | Impossible — unique per site |
| Server breach risk | High — hashed passwords leak | None — only public keys stored |
| User experience | Friction-heavy | One tap or glance |
Major platforms now support passkeys natively: Apple iCloud Keychain syncs them across devices, Google Password Manager does the same on Android and Chrome, and Windows Hello handles them on Microsoft devices. By mid-2026, over 75% of consumer devices support passkeys out of the box.
How WebAuthn Works
WebAuthn is the W3C standard that powers passkeys. The flow involves three parties:
- Relying Party (RP) — your server
- Client — the browser
- Authenticator — the device biometric sensor or hardware key
Registration Flow
- Server generates a challenge (random bytes)
- Browser calls
navigator.credentials.create()with the challenge - Authenticator creates a public/private key pair, stores the private key securely
- Browser sends the public key and attestation back to the server
- Server verifies the attestation and stores the public key
Authentication Flow
- Server generates a new challenge
- Browser calls
navigator.credentials.get()with the challenge - Authenticator signs the challenge with the private key
- Browser sends the signed assertion to the server
- Server verifies the signature against the stored public key
The private key never leaves the device. Even if your database is breached, attackers get only public keys — which are useless without the corresponding private keys locked inside user devices.
Step 1: Project Setup
Create a new Next.js 15 project with TypeScript:
npx create-next-app@latest passkeys-demo --typescript --tailwind --app --src-dir --use-npm
cd passkeys-demoInstall the dependencies:
npm install @simplewebauthn/server @simplewebauthn/browser
npm install @prisma/client
npm install -D prisma @simplewebauthn/typesHere is what each package does:
- @simplewebauthn/server — Server-side WebAuthn verification (attestation and assertion)
- @simplewebauthn/browser — Client-side helpers for
navigator.credentialscalls - @prisma/client — Type-safe database ORM
- @simplewebauthn/types — Shared TypeScript types
Step 2: Database Schema with Prisma
Initialize Prisma:
npx prisma init --datasource-provider postgresqlUpdate your prisma/schema.prisma to define users and their passkey credentials:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
credentials Credential[]
challenges Challenge[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Credential {
id String @id @default(cuid())
credentialId String @unique
credentialPublicKey Bytes
counter BigInt @default(0)
credentialDeviceType String
credentialBackedUp Boolean @default(false)
transports String[] @default([])
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
}
model Challenge {
id String @id @default(cuid())
challenge String
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
createdAt DateTime @default(now())
}Key design decisions:
- credentialPublicKey is stored as
Bytes— the raw COSE public key - counter tracks the signature count to detect cloned authenticators
- credentialDeviceType distinguishes single-device vs multi-device (synced) credentials
- credentialBackedUp indicates if the credential is synced via cloud (e.g., iCloud Keychain)
- transports stores how the authenticator communicates (USB, BLE, NFC, internal)
- Challenge has an expiration to prevent replay attacks
Run the migration:
npx prisma migrate dev --name initCreate a Prisma client singleton at src/lib/prisma.ts:
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}Step 3: WebAuthn Configuration
Create src/lib/webauthn.ts to centralize your relying party configuration:
import type {
GenerateRegistrationOptionsOpts,
GenerateAuthenticationOptionsOpts,
} from "@simplewebauthn/server";
// Relying Party configuration
export const rpName = "Passkeys Demo";
export const rpID = process.env.WEBAUTHN_RP_ID || "localhost";
export const origin =
process.env.WEBAUTHN_ORIGIN || `http://localhost:3000`;
// Helper to get expected origins (supports multiple)
export function getExpectedOrigins(): string[] {
const origins = [origin];
// Add additional origins for production (e.g., www subdomain)
if (process.env.WEBAUTHN_ADDITIONAL_ORIGINS) {
origins.push(
...process.env.WEBAUTHN_ADDITIONAL_ORIGINS.split(",")
);
}
return origins;
}Add environment variables to .env:
DATABASE_URL="postgresql://user:password@localhost:5432/passkeys_demo"
WEBAUTHN_RP_ID="localhost"
WEBAUTHN_ORIGIN="http://localhost:3000"Important: The rpID must match your domain exactly. For localhost development it should be "localhost". In production, use your domain without the protocol — for example "example.com". Passkeys are bound to this origin and will not work if the rpID changes.
Step 4: Registration API Routes
Create the registration flow with two endpoints: one to generate options and one to verify the response.
Generate Registration Options
Create src/app/api/auth/register/options/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { prisma } from "@/lib/prisma";
import { rpName, rpID } from "@/lib/webauthn";
export async function POST(request: NextRequest) {
try {
const { email, name } = await request.json();
if (!email) {
return NextResponse.json(
{ error: "Email is required" },
{ status: 400 }
);
}
// Find or create user
let user = await prisma.user.findUnique({
where: { email },
include: { credentials: true },
});
if (!user) {
user = await prisma.user.create({
data: { email, name: name || email.split("@")[0] },
include: { credentials: true },
});
}
// Get existing credentials to exclude
const excludeCredentials = user.credentials.map((cred) => ({
id: cred.credentialId,
type: "public-key" as const,
transports: cred.transports as AuthenticatorTransport[],
}));
// Generate registration options
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: user.email,
userDisplayName: user.name || user.email,
attestationType: "none",
excludeCredentials,
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
authenticatorAttachment: "platform",
},
supportedAlgorithmIDs: [-7, -257], // ES256, RS256
});
// Store the challenge with expiration
await prisma.challenge.create({
data: {
challenge: options.challenge,
userId: user.id,
expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
},
});
return NextResponse.json({
options,
userId: user.id,
});
} catch (error) {
console.error("Registration options error:", error);
return NextResponse.json(
{ error: "Failed to generate registration options" },
{ status: 500 }
);
}
}Key parameters explained:
- attestationType: "none" — We do not need the authenticator to prove its identity. Most apps do not require attestation.
- residentKey: "preferred" — Allows discoverable credentials (passkeys that appear in autofill)
- userVerification: "preferred" — Requests biometric/PIN verification but does not fail without it
- authenticatorAttachment: "platform" — Prefers built-in authenticators (Touch ID, Face ID, Windows Hello)
- supportedAlgorithmIDs — ES256 (ECDSA) and RS256 (RSA) cover nearly all authenticators
Verify Registration
Create src/app/api/auth/register/verify/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { prisma } from "@/lib/prisma";
import { rpID, getExpectedOrigins } from "@/lib/webauthn";
export async function POST(request: NextRequest) {
try {
const { credential, userId } = await request.json();
// Find the stored challenge
const storedChallenge = await prisma.challenge.findFirst({
where: {
userId,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: "desc" },
});
if (!storedChallenge) {
return NextResponse.json(
{ error: "Challenge expired or not found" },
{ status: 400 }
);
}
// Verify the registration response
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: storedChallenge.challenge,
expectedOrigin: getExpectedOrigins(),
expectedRPID: rpID,
requireUserVerification: false,
});
if (!verification.verified || !verification.registrationInfo) {
return NextResponse.json(
{ error: "Verification failed" },
{ status: 400 }
);
}
const {
credential: registrationCredential,
credentialDeviceType,
credentialBackedUp,
} = verification.registrationInfo;
// Store the credential
await prisma.credential.create({
data: {
credentialId: registrationCredential.id,
credentialPublicKey: Buffer.from(
registrationCredential.publicKey
),
counter: registrationCredential.counter,
credentialDeviceType,
credentialBackedUp,
transports: credential.response.transports || [],
userId,
},
});
// Clean up used challenge
await prisma.challenge.delete({
where: { id: storedChallenge.id },
});
return NextResponse.json({
verified: true,
credentialDeviceType,
credentialBackedUp,
});
} catch (error) {
console.error("Registration verification error:", error);
return NextResponse.json(
{ error: "Verification failed" },
{ status: 500 }
);
}
}Step 5: Authentication API Routes
Generate Authentication Options
Create src/app/api/auth/login/options/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { prisma } from "@/lib/prisma";
import { rpID } from "@/lib/webauthn";
export async function POST(request: NextRequest) {
try {
const { email } = await request.json();
let allowCredentials: {
id: string;
type: "public-key";
transports?: AuthenticatorTransport[];
}[] = [];
if (email) {
// If email provided, narrow to that user's credentials
const user = await prisma.user.findUnique({
where: { email },
include: { credentials: true },
});
if (user) {
allowCredentials = user.credentials.map((cred) => ({
id: cred.credentialId,
type: "public-key" as const,
transports: cred.transports as AuthenticatorTransport[],
}));
}
}
// Generate authentication options
const options = await generateAuthenticationOptions({
rpID,
userVerification: "preferred",
allowCredentials:
allowCredentials.length > 0 ? allowCredentials : undefined,
});
// Store the challenge
const user = email
? await prisma.user.findUnique({ where: { email } })
: null;
await prisma.challenge.create({
data: {
challenge: options.challenge,
userId: user?.id || null,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
},
});
return NextResponse.json({ options });
} catch (error) {
console.error("Authentication options error:", error);
return NextResponse.json(
{ error: "Failed to generate authentication options" },
{ status: 500 }
);
}
}When allowCredentials is empty (no email provided), the browser will show all available passkeys for the domain — this enables the discoverable credential flow where users just tap "Sign in with passkey" without entering an email first.
Verify Authentication
Create src/app/api/auth/login/verify/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { prisma } from "@/lib/prisma";
import { rpID, getExpectedOrigins } from "@/lib/webauthn";
import { cookies } from "next/headers";
export async function POST(request: NextRequest) {
try {
const { credential } = await request.json();
// Find the credential in the database
const storedCredential = await prisma.credential.findUnique({
where: { credentialId: credential.id },
include: { user: true },
});
if (!storedCredential) {
return NextResponse.json(
{ error: "Credential not found" },
{ status: 400 }
);
}
// Find the stored challenge
const storedChallenge = await prisma.challenge.findFirst({
where: {
expiresAt: { gt: new Date() },
OR: [
{ userId: storedCredential.userId },
{ userId: null },
],
},
orderBy: { createdAt: "desc" },
});
if (!storedChallenge) {
return NextResponse.json(
{ error: "Challenge expired or not found" },
{ status: 400 }
);
}
// Verify the authentication response
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: storedChallenge.challenge,
expectedOrigin: getExpectedOrigins(),
expectedRPID: rpID,
credential: {
id: storedCredential.credentialId,
publicKey: new Uint8Array(storedCredential.credentialPublicKey),
counter: Number(storedCredential.counter),
transports:
storedCredential.transports as AuthenticatorTransport[],
},
requireUserVerification: false,
});
if (!verification.verified) {
return NextResponse.json(
{ error: "Authentication failed" },
{ status: 400 }
);
}
// Update the counter (important for security)
await prisma.credential.update({
where: { id: storedCredential.id },
data: {
counter: verification.authenticationInfo.newCounter,
},
});
// Clean up the challenge
await prisma.challenge.delete({
where: { id: storedChallenge.id },
});
// Create a session (simplified — use a proper session library in production)
const cookieStore = await cookies();
cookieStore.set("session", storedCredential.userId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
maxAge: 60 * 60 * 24 * 7, // 7 days
path: "/",
});
return NextResponse.json({
verified: true,
user: {
id: storedCredential.user.id,
email: storedCredential.user.email,
name: storedCredential.user.name,
},
});
} catch (error) {
console.error("Authentication verification error:", error);
return NextResponse.json(
{ error: "Authentication failed" },
{ status: 500 }
);
}
}Counter verification is critical. The counter increments each time the authenticator is used. If the server sees a counter value lower than what it stored, it means the credential may have been cloned. SimpleWebAuthn handles this check automatically and will throw an error if it detects a cloned authenticator.
Step 6: Client-Side Hooks
Create a custom hook to manage the WebAuthn flow. Create src/hooks/use-passkey.ts:
"use client";
import {
startRegistration,
startAuthentication,
browserSupportsWebAuthn,
} from "@simplewebauthn/browser";
import { useState, useCallback } from "react";
interface UsePasskeyReturn {
isSupported: boolean;
isLoading: boolean;
error: string | null;
register: (email: string, name?: string) => Promise<boolean>;
login: (email?: string) => Promise<boolean>;
}
export function usePasskey(): UsePasskeyReturn {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isSupported = browserSupportsWebAuthn();
const register = useCallback(
async (email: string, name?: string): Promise<boolean> => {
setIsLoading(true);
setError(null);
try {
// Step 1: Get registration options from server
const optionsRes = await fetch(
"/api/auth/register/options",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, name }),
}
);
if (!optionsRes.ok) {
throw new Error("Failed to get registration options");
}
const { options, userId } = await optionsRes.json();
// Step 2: Create credential via browser API
const credential = await startRegistration({
optionsJSON: options,
});
// Step 3: Verify with server
const verifyRes = await fetch(
"/api/auth/register/verify",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ credential, userId }),
}
);
if (!verifyRes.ok) {
throw new Error("Registration verification failed");
}
const result = await verifyRes.json();
return result.verified;
} catch (err) {
const message =
err instanceof Error ? err.message : "Registration failed";
setError(message);
return false;
} finally {
setIsLoading(false);
}
},
[]
);
const login = useCallback(
async (email?: string): Promise<boolean> => {
setIsLoading(true);
setError(null);
try {
// Step 1: Get authentication options
const optionsRes = await fetch("/api/auth/login/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!optionsRes.ok) {
throw new Error("Failed to get authentication options");
}
const { options } = await optionsRes.json();
// Step 2: Authenticate via browser API
const credential = await startAuthentication({
optionsJSON: options,
});
// Step 3: Verify with server
const verifyRes = await fetch("/api/auth/login/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ credential }),
});
if (!verifyRes.ok) {
throw new Error("Authentication failed");
}
const result = await verifyRes.json();
return result.verified;
} catch (err) {
const message =
err instanceof Error ? err.message : "Login failed";
setError(message);
return false;
} finally {
setIsLoading(false);
}
},
[]
);
return { isSupported, isLoading, error, register, login };
}Step 7: Registration UI Component
Create src/components/passkey-register.tsx:
"use client";
import { useState } from "react";
import { usePasskey } from "@/hooks/use-passkey";
export function PasskeyRegister() {
const { isSupported, isLoading, error, register } = usePasskey();
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [success, setSuccess] = useState(false);
if (!isSupported) {
return (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<p className="text-yellow-800">
Your browser does not support passkeys. Please use a modern
browser like Chrome, Safari, or Firefox.
</p>
</div>
);
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const result = await register(email, name);
if (result) {
setSuccess(true);
}
};
if (success) {
return (
<div className="rounded-lg border border-green-200 bg-green-50 p-6 text-center">
<h3 className="text-lg font-semibold text-green-800">
Passkey Created!
</h3>
<p className="mt-2 text-green-600">
Your passkey has been registered. You can now sign in
using your fingerprint, face, or device PIN.
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
>
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500"
placeholder="you@example.com"
/>
</div>
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-700"
>
Display Name (optional)
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500"
placeholder="Jane Doe"
/>
</div>
{error && (
<div className="rounded-md bg-red-50 p-3">
<p className="text-sm text-red-700">{error}</p>
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full rounded-md bg-indigo-600 px-4 py-2 text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
>
{isLoading ? "Creating Passkey..." : "Create Passkey"}
</button>
</form>
);
}Step 8: Login UI Component
Create src/components/passkey-login.tsx:
"use client";
import { useState } from "react";
import { usePasskey } from "@/hooks/use-passkey";
import { useRouter } from "next/navigation";
export function PasskeyLogin() {
const { isSupported, isLoading, error, login } = usePasskey();
const [email, setEmail] = useState("");
const router = useRouter();
if (!isSupported) {
return (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<p className="text-yellow-800">
Your browser does not support passkeys.
</p>
</div>
);
}
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault();
const result = await login(email);
if (result) {
router.push("/dashboard");
}
};
const handleQuickLogin = async () => {
const result = await login();
if (result) {
router.push("/dashboard");
}
};
return (
<div className="space-y-6">
{/* Quick login — discoverable credentials */}
<button
onClick={handleQuickLogin}
disabled={isLoading}
className="w-full rounded-md bg-indigo-600 px-4 py-3 text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
>
{isLoading ? (
"Verifying..."
) : (
<span className="flex items-center justify-center gap-2">
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4"
/>
</svg>
Sign in with Passkey
</span>
)}
</button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500">
Or enter your email
</span>
</div>
</div>
{/* Email-based login */}
<form onSubmit={handleEmailLogin} className="space-y-4">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500"
placeholder="you@example.com"
/>
<button
type="submit"
disabled={isLoading || !email}
className="w-full rounded-md border border-indigo-600 px-4 py-2 text-indigo-600 hover:bg-indigo-50 disabled:opacity-50"
>
Continue with Email
</button>
</form>
{error && (
<div className="rounded-md bg-red-50 p-3">
<p className="text-sm text-red-700">{error}</p>
</div>
)}
</div>
);
}The login component offers two paths:
- Quick login — calls
login()without an email, triggering the browser to show all available passkeys for the domain - Email-based login — narrows the credential list to a specific user, useful when multiple people share a device
Step 9: Auth Pages
Create the main authentication page at src/app/auth/page.tsx:
"use client";
import { useState } from "react";
import { PasskeyRegister } from "@/components/passkey-register";
import { PasskeyLogin } from "@/components/passkey-login";
export default function AuthPage() {
const [mode, setMode] = useState<"login" | "register">("login");
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md space-y-8 rounded-xl bg-white p-8 shadow-lg">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900">
{mode === "login" ? "Welcome Back" : "Create Account"}
</h1>
<p className="mt-2 text-gray-600">
{mode === "login"
? "Sign in with your passkey"
: "Register a new passkey"}
</p>
</div>
{mode === "login" ? <PasskeyLogin /> : <PasskeyRegister />}
<div className="text-center">
<button
onClick={() =>
setMode(mode === "login" ? "register" : "login")
}
className="text-sm text-indigo-600 hover:text-indigo-500"
>
{mode === "login"
? "Need an account? Register"
: "Already have a passkey? Sign in"}
</button>
</div>
</div>
</div>
);
}Step 10: Session Management and Protected Routes
Create a session helper at src/lib/session.ts:
import { cookies } from "next/headers";
import { prisma } from "./prisma";
export async function getSession() {
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("session");
if (!sessionCookie?.value) {
return null;
}
const user = await prisma.user.findUnique({
where: { id: sessionCookie.value },
select: {
id: true,
email: true,
name: true,
credentials: {
select: {
id: true,
credentialDeviceType: true,
credentialBackedUp: true,
createdAt: true,
},
},
},
});
return user;
}Create middleware to protect routes. Update src/middleware.ts:
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
const session = request.cookies.get("session");
// Protect dashboard routes
if (request.nextUrl.pathname.startsWith("/dashboard")) {
if (!session?.value) {
return NextResponse.redirect(new URL("/auth", request.url));
}
}
// Redirect authenticated users away from auth page
if (request.nextUrl.pathname === "/auth") {
if (session?.value) {
return NextResponse.redirect(
new URL("/dashboard", request.url)
);
}
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/auth"],
};Create a protected dashboard at src/app/dashboard/page.tsx:
import { getSession } from "@/lib/session";
import { redirect } from "next/navigation";
import { PasskeyManager } from "@/components/passkey-manager";
export default async function DashboardPage() {
const user = await getSession();
if (!user) {
redirect("/auth");
}
return (
<div className="mx-auto max-w-4xl p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-gray-600">
Welcome, {user.name || user.email}
</p>
</div>
<div className="rounded-lg border bg-white p-6">
<h2 className="mb-4 text-lg font-semibold">Your Passkeys</h2>
<div className="space-y-3">
{user.credentials.map((cred) => (
<div
key={cred.id}
className="flex items-center justify-between rounded-md border p-3"
>
<div>
<p className="font-medium">
{cred.credentialDeviceType === "multiDevice"
? "Synced Passkey"
: "Device-bound Passkey"}
</p>
<p className="text-sm text-gray-500">
Created{" "}
{new Date(cred.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2">
{cred.credentialBackedUp && (
<span className="rounded-full bg-green-100 px-2 py-1 text-xs text-green-700">
Backed up
</span>
)}
</div>
</div>
))}
</div>
<PasskeyManager />
</div>
</div>
);
}Step 11: Passkey Management
Let users add additional passkeys and sign out. Create src/components/passkey-manager.tsx:
"use client";
import { usePasskey } from "@/hooks/use-passkey";
import { useRouter } from "next/navigation";
export function PasskeyManager() {
const { isLoading, error, register } = usePasskey();
const router = useRouter();
const handleAddPasskey = async () => {
// Fetch current user email from session
const res = await fetch("/api/auth/me");
if (!res.ok) return;
const { email } = await res.json();
await register(email);
router.refresh();
};
const handleSignOut = async () => {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/auth");
};
return (
<div className="mt-6 flex gap-3">
<button
onClick={handleAddPasskey}
disabled={isLoading}
className="rounded-md border border-indigo-600 px-4 py-2 text-indigo-600 hover:bg-indigo-50 disabled:opacity-50"
>
{isLoading ? "Adding..." : "Add Another Passkey"}
</button>
<button
onClick={handleSignOut}
className="rounded-md border border-red-300 px-4 py-2 text-red-600 hover:bg-red-50"
>
Sign Out
</button>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
);
}Add the supporting API routes:
src/app/api/auth/me/route.ts:
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
export async function GET() {
const user = await getSession();
if (!user) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
return NextResponse.json({
id: user.id,
email: user.email,
name: user.name,
});
}src/app/api/auth/logout/route.ts:
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST() {
const cookieStore = await cookies();
cookieStore.delete("session");
return NextResponse.json({ success: true });
}Step 12: Conditional UI for WebAuthn Support
Not all browsers and devices support passkeys equally. Create a utility component to handle graceful degradation at src/components/webauthn-check.tsx:
"use client";
import {
browserSupportsWebAuthn,
platformAuthenticatorIsAvailable,
} from "@simplewebauthn/browser";
import { useEffect, useState } from "react";
interface WebAuthnStatus {
webauthnSupported: boolean;
platformSupported: boolean;
checked: boolean;
}
export function useWebAuthnStatus(): WebAuthnStatus {
const [status, setStatus] = useState<WebAuthnStatus>({
webauthnSupported: false,
platformSupported: false,
checked: false,
});
useEffect(() => {
async function check() {
const webauthnSupported = browserSupportsWebAuthn();
const platformSupported = webauthnSupported
? await platformAuthenticatorIsAvailable()
: false;
setStatus({
webauthnSupported,
platformSupported,
checked: true,
});
}
check();
}, []);
return status;
}Use this to show appropriate UI:
const { webauthnSupported, platformSupported, checked } =
useWebAuthnStatus();
if (!checked) return <LoadingSpinner />;
if (!webauthnSupported) {
return <PasswordFallbackForm />;
}
if (!platformSupported) {
return <SecurityKeyPrompt />;
}
return <PasskeyLogin />;Testing Your Implementation
Local Testing
Start the development server:
npx prisma migrate dev
npm run devOpen http://localhost:3000/auth and test:
- Register — Enter your email, click "Create Passkey", and authenticate with Touch ID / Windows Hello
- Login — Click "Sign in with Passkey" and verify with biometrics
- Dashboard — Verify you see your registered passkeys
- Add passkey — Add a second passkey from a different device or browser
Testing on Mobile
To test on a mobile device during development:
# Use a tunnel service to expose localhost with HTTPS
npx localtunnel --port 3000WebAuthn requires a secure context. It only works on https:// origins or localhost. When testing on mobile, you need HTTPS — use a tunnel service or deploy to a staging environment.
Cross-Device Authentication
Passkeys support cross-device authentication via QR codes:
- On your desktop browser, start the login flow
- Choose "Use a phone or tablet"
- Scan the QR code with your mobile device
- Authenticate with biometrics on your phone
- The desktop browser completes authentication
This works because FIDO2 CTAP (Client to Authenticator Protocol) uses Bluetooth Low Energy to verify that both devices are physically close to each other, preventing remote phishing.
Troubleshooting
"The operation either timed out or was not allowed"
This usually means:
- The user cancelled the biometric prompt
- The authenticator timed out (default is 60 seconds)
- The rpID does not match the current origin
"NotAllowedError: The request is not allowed by the user agent"
Check these common causes:
- WebAuthn requires a secure context (HTTPS or localhost)
- The page must be focused — background tabs cannot trigger WebAuthn
- On Safari, the call must originate from a user gesture (click handler)
Counter mismatch errors
A counter mismatch suggests a cloned authenticator. In development, this can happen if you reset your database without clearing the browser credentials. Clear the passkey from your browser or device settings and re-register.
Credential not found during login
Ensure the credentialId stored in the database matches what the authenticator returns. If you see Base64URL encoding differences, verify that both sides use the same encoding.
Production Deployment Checklist
Before going to production, verify:
- Set
WEBAUTHN_RP_IDto your production domain (e.g.,"example.com") - Set
WEBAUTHN_ORIGINto your production URL (e.g.,"https://example.com") - Enable HTTPS — WebAuthn will not work without it
- Replace the simplified cookie session with a proper session library (iron-session, jose, or similar)
- Add rate limiting to the authentication endpoints
- Set up challenge cleanup (cron job to delete expired challenges)
- Consider adding attestation verification if you need to restrict authenticator types
- Add password fallback for users on older devices
- Test across browsers: Chrome, Safari, Firefox, and Edge
- Test cross-device flows with QR codes
Next Steps
Now that you have passwordless authentication working, consider these enhancements:
- Conditional UI — Use
PublicKeyCredential.isConditionalMediationAvailable()to show passkeys in the browser autofill dropdown, just like saved passwords - Password + Passkey hybrid — Allow users to register a password first, then add a passkey later as an upgrade path
- Account recovery — Implement recovery codes or email-based account recovery for users who lose all their devices
- Audit logging — Track authentication events (successful logins, failed attempts, new credential registrations)
- Multi-factor — Combine passkeys with a second factor for high-security operations
Conclusion
You have built a complete passwordless authentication system using Passkeys and WebAuthn with Next.js 15. Your implementation handles:
- Credential registration with biometric or device PIN verification
- Passwordless login supporting both discoverable and email-based flows
- Session management with protected routes and middleware
- Passkey management allowing users to add multiple credentials
- Graceful degradation for browsers without WebAuthn support
Passkeys represent the most significant improvement to web authentication in decades. They eliminate passwords entirely, resist phishing by design, and provide a better user experience than any password-based system. As adoption continues to grow through 2026 and beyond, implementing passkeys now positions your application at the forefront of web security.
The complete source code for this tutorial is available as a reference implementation. Start with the basic flow shown here, then layer on the production hardening steps as your application grows.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Better Auth with Next.js 15: The Complete Authentication Guide for 2026
Learn how to implement full-featured authentication in Next.js 15 using Better Auth. This tutorial covers email/password, OAuth, sessions, middleware protection, and role-based access control.

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.

Build a SaaS Starter Kit with Next.js 15, Stripe Subscriptions, and Auth.js v5
Learn how to build a production-ready SaaS application with Next.js 15, Stripe for subscription billing, and Auth.js v5 for authentication. This step-by-step tutorial covers project setup, OAuth login, pricing plans, webhook handling, and protected routes.