Step-by-Step: Implementing Passkeys (WebAuthn) in Your Next.js App
A comprehensive step-by-step developer guide to implementing secure, passwordless authentication using Passkeys (WebAuthn) in Next.js with SimpleWebAuthn.

Passwords are one of the weakest links in modern web security. They are vulnerable to phishing, credential stuffing, and brute-force attacks. Passkeys, backed by the FIDO Alliance and the World Wide Web Consortium (W3C), offer a phishing-resistant, passwordless alternative. They allow users to log into websites using biometrics (such as Face ID, Touch ID), PINs, or physical security keys.
In this tutorial, we will walk through implementing Passkeys (WebAuthn) in a Next.js application. We will use the industry-standard @simplewebauthn library, which abstracts the complex cryptographic validations into simple, developer-friendly helpers.
The WebAuthn Cryptographic Handshake
The WebAuthn flow relies on public-key cryptography. Instead of sending a shared secret (a password) to the server, the user's device creates a public/private key pair. The server stores only the public key, while the private key remains locked on the user's secure hardware.
sequenceDiagram
autonumber
actor User as User Device
participant NextClient as Next.js Client
participant NextServer as Next.js API
database DB as Database
Note over User, DB: Registration Flow
NextClient->>NextServer: Request registration options (username)
NextServer->>NextServer: Generate challenge & credential requirements
NextServer->>DB: Save challenge temporarily in session
NextServer-->>NextClient: Return registration options JSON
NextClient->>User: Call navigator.credentials.create() (Biometrics prompt)
User-->>NextClient: User approves; returns credential signature
NextClient->>NextServer: Send credential payload
NextServer->>NextServer: Verify signature against saved challenge
NextServer->>DB: Save User ID, Public Key, & Credential ID
NextServer-->>NextClient: Registration Success!
Prerequisites & Dependencies
We will use a standard Next.js (App Router) project. Install the @simplewebauthn packages for both the server-side verification and browser-side API interactions:
npm install @simplewebauthn/server @simplewebauthn/browser
@simplewebauthn/server: Handles challenge generation and cryptographic verification.@simplewebauthn/browser: Invokes the browser's native WebAuthn prompts (navigator.credentials.createandnavigator.credentials.get).
For session management in this tutorial, we will simulate a lightweight in-memory storage. In production, you must use a database (e.g., PostgreSQL, MongoDB, Redis) to persist active sessions and user public keys.
Step 1: User Registration (Creating a Passkey)
The registration flow consists of two backend API routes and one client action:
- Generate Options: Server generates options containing a cryptographic challenge and returns them to the browser.
- Execute Registration: Browser prompts the user for biometrics and returns the signed credential.
- Verify Registration: Server validates the signature and stores the credential.
1.1 Backend: Generate Registration Options
Create app/api/auth/register/options/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { generateRegistrationOptions } from "@simplewebauthn/server";
// In-memory store simulating database for this walkthrough.
// Key: username | Value: Temporary session challenge & registered credentials
export const mockDB: Record<string, { currentChallenge?: string; credentials: any[] }> = {};
export const RP_NAME = "My NextJS App";
export const RP_ID = "localhost"; // Must match your domain in production
export const ORIGIN = "http://localhost:3000";
export async function POST(req: NextRequest) {
try {
const { username } = await req.json();
if (!username) {
return NextResponse.json({ error: "Username is required" }, { status: 400 });
}
if (!mockDB[username]) {
mockDB[username] = { credentials: [] };
}
// Generate options required by browser WebAuthn API
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: Buffer.from(username), // Must be unique per user
userName: username,
userDisplayName: username,
// Only request cross-platform authenticator if you don't allow biometrics
authenticatorSelection: {
residentKey: "required",
userVerification: "preferred",
},
});
// Save the challenge in user's temporary state to verify in the next step
mockDB[username].currentChallenge = options.challenge;
return NextResponse.json(options);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
1.2 Backend: Verify Registration Signature
Create app/api/auth/register/verify/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { mockDB, RP_ID, ORIGIN } from "../options/route";
export async function POST(req: NextRequest) {
try {
const { username, credentialPayload } = await req.json();
const user = mockDB[username];
if (!user || !user.currentChallenge) {
return NextResponse.json({ error: "Challenge not found for user" }, { status: 400 });
}
// Cryptographic validation of the public key signature
const verification = await verifyRegistrationResponse({
response: credentialPayload,
expectedChallenge: user.currentChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
});
const { verified, registrationInfo } = verification;
if (verified && registrationInfo) {
const { credentialID, credentialPublicKey, counter } = registrationInfo;
// Save public key details in your user profile database
user.credentials.push({
credentialID: Buffer.from(credentialID).toString("base64url"),
// Convert Uint8Array public key to Base64url representation
credentialPublicKey: Buffer.from(credentialPublicKey).toString("base64url"),
counter,
});
// Clear the used challenge
user.currentChallenge = undefined;
return NextResponse.json({ verified: true });
}
return NextResponse.json({ verified: false }, { status: 400 });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
Step 2: User Login (Authenticating with a Passkey)
The authentication flow mimics the registration flow but validates an existing key pair.
2.1 Backend: Generate Authentication Options
Create app/api/auth/login/options/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { mockDB, RP_ID } from "../register/options/route";
export async function POST(req: NextRequest) {
try {
const { username } = await req.json();
const user = mockDB[username];
if (!user || user.credentials.length === 0) {
return NextResponse.json({ error: "User has no credentials registered" }, { status: 400 });
}
const options = await generateAuthenticationOptions({
rpID: RP_ID,
allowCredentials: user.credentials.map((cred) => ({
id: Buffer.from(cred.credentialID, "base64url"),
type: "public-key",
})),
userVerification: "preferred",
});
user.currentChallenge = options.challenge;
return NextResponse.json(options);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
2.2 Backend: Verify Authentication
Create app/api/auth/login/verify/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { mockDB, RP_ID, ORIGIN } from "../register/options/route";
export async function POST(req: NextRequest) {
try {
const { username, credentialPayload } = await req.json();
const user = mockDB[username];
if (!user || !user.currentChallenge) {
return NextResponse.json({ error: "Challenge not found" }, { status: 400 });
}
// Find the saved credential matching the incoming ID
const savedCredential = user.credentials.find(
(cred) => cred.credentialID === credentialPayload.id
);
if (!savedCredential) {
return NextResponse.json({ error: "Credential not registered with this account" }, { status: 400 });
}
const verification = await verifyAuthenticationResponse({
response: credentialPayload,
expectedChallenge: user.currentChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
authenticator: {
credentialID: Buffer.from(savedCredential.credentialID, "base64url"),
credentialPublicKey: Buffer.from(savedCredential.credentialPublicKey, "base64url"),
counter: savedCredential.counter,
},
});
const { verified, authenticationInfo } = verification;
if (verified && authenticationInfo) {
// Update counter in database to prevent replay attacks
savedCredential.counter = authenticationInfo.newCounter;
user.currentChallenge = undefined;
// In production, sign a JWT or assign cookie session here
return NextResponse.json({ verified: true, message: "Welcome back!" });
}
return NextResponse.json({ verified: false }, { status: 400 });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
Step 3: Frontend Client Integration
Now, let's build the interactive user interface that handles registration and login clicks.
Update your main file (e.g., app/page.tsx or a custom client component app/components/AuthContainer.tsx):
"use client";
import { useState } from "react";
import { startRegistration, startAuthentication } from "@simplewebauthn/browser";
import { ShieldCheck, Fingerprint, LogIn, Sparkles } from "lucide-react";
export default function PasskeyAuth() {
const [username, setUsername] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleRegister = async () => {
if (!username) return setStatus("Please enter a username.");
setIsLoading(true);
setStatus("Generating registration options...");
try {
// 1. Fetch options from server
const optionsRes = await fetch("/api/auth/register/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
const options = await optionsRes.json();
if (options.error) throw new Error(options.error);
// 2. Trigger biometric registration prompt via browser
setStatus("Awaiting fingerprint / face authentication...");
const credentialPayload = await startRegistration({ optionsJSON: options });
// 3. Verify response payload on server
setStatus("Verifying credentials on server...");
const verifyRes = await fetch("/api/auth/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, credentialPayload }),
});
const result = await verifyRes.json();
if (result.verified) {
setStatus("Passkey registered successfully! You can now log in.");
} else {
setStatus("Verification failed.");
}
} catch (err: any) {
console.error(err);
setStatus(`Error: ${err.message}`);
} finally {
setIsLoading(false);
}
};
const handleLogin = async () => {
if (!username) return setStatus("Please enter a username.");
setIsLoading(true);
setStatus("Generating login options...");
try {
// 1. Fetch authentication options
const optionsRes = await fetch("/api/auth/login/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
const options = await optionsRes.json();
if (options.error) throw new Error(options.error);
// 2. Open biometric authentication dialog
setStatus("Awaiting passkey verification...");
const credentialPayload = await startAuthentication({ optionsJSON: options });
// 3. Verify signed signature on the server
setStatus("Verifying credential signature...");
const verifyRes = await fetch("/api/auth/login/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, credentialPayload }),
});
const result = await verifyRes.json();
if (result.verified) {
setStatus("Logged in successfully! Welcome to the future of passwordless security.");
} else {
setStatus("Login signature verification failed.");
}
} catch (err: any) {
console.error(err);
setStatus(`Error: ${err.message}`);
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-slate-950 text-slate-100 p-6">
<div className="max-w-md w-full bg-slate-900 border border-slate-800 p-8 rounded-2xl space-y-6 shadow-2xl shadow-teal-950/10">
<div className="flex flex-col items-center text-center space-y-2">
<div className="p-3.5 bg-teal-950/50 rounded-2xl border border-teal-800/30 text-teal-400">
<ShieldCheck className="w-8 h-8" />
</div>
<h2 className="text-xl font-bold tracking-tight">Passkey Authentication</h2>
<p className="text-slate-400 text-xs leading-relaxed">
Register or authenticate using biometric capabilities local to your device.
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">
Username
</label>
<input
type="text"
placeholder="e.g. adit_jangid"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoading}
className="w-full px-4 py-3 bg-slate-950 border border-slate-800 rounded-xl text-slate-200 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<button
onClick={handleRegister}
disabled={isLoading}
className="py-3 px-4 bg-slate-800 hover:bg-slate-700 disabled:opacity-50 text-white font-semibold rounded-xl text-xs flex items-center justify-center gap-2 transition-all cursor-pointer border border-slate-700/50"
>
<Sparkles className="w-3.5 h-3.5 text-teal-400" />
Register Key
</button>
<button
onClick={handleLogin}
disabled={isLoading}
className="py-3 px-4 bg-teal-500 hover:bg-teal-400 disabled:opacity-50 text-slate-950 font-bold rounded-xl text-xs flex items-center justify-center gap-2 transition-all cursor-pointer shadow-lg shadow-teal-500/10"
>
<Fingerprint className="w-3.5 h-3.5" />
Authenticate
</button>
</div>
</div>
{status && (
<div className="p-3 bg-slate-950 border border-slate-800/80 rounded-xl text-center">
<p className="text-[11px] font-mono text-slate-400">{status}</p>
</div>
)}
</div>
</div>
);
}
Key Security Guidelines
When launching WebAuthn/Passkeys in production, keep the following security protocols in mind:
- Verify the Origin: WebAuthn enforces that browser challenges match the origin exactly (e.g.
https://example.com). If you verify requests from subdomains or different environments, ensure theexpectedOriginparameter is dynamic or supports localhost for development. - Prevent Replay Attacks (Authenticator Counter): During verification, SimpleWebAuthn checks the
counterreturned by the authenticator. Authenticators increment this value with each login. If the counter sent by the device is lower than or equal to the counter stored in the database, it suggests credentials might have been cloned or replayed. Raise a security alert if this condition is met. - Graceful Fallbacks: Biometrics can fail (sensor damage, user has biometrics disabled). Always support multi-factor fallbacks (e.g., standard session cookies, magical link emails, or authenticator TOTP codes).
Conclusion
You have successfully integrated biometric Passkey authentication into Next.js! Passkeys fundamentally change authentication by removing the necessity for users to maintain passwords. By validating public-key signatures directly on our backend utilizing @simplewebauthn/server and invoking browser prompts with @simplewebauthn/browser, you have built a seamless, secure, and modern sign-in flow.