Passkeys and WebAuthn in 2026: Replacing Passwords in Your Next.js App
Passwords are dying. After more than two decades of being the de facto standard for authentication, the era of "Password123!" is finally coming to an end. I've been building authentication systems for clients since 2020, and in 2026, I can confidently say that passkeys powered by the WebAuthn API are no longer experimental — they are the new baseline for any serious web application.
In this hands-on guide, I'll walk you through everything you need to know to implement passkey-based authentication in a Next.js application. We'll cover the full flow, from registration to login, cross-device sync, fallback strategies for legacy users, and the production-grade best practices I've learned from deploying these systems at scale.
Why Passkeys Matter More Than Ever in 2026
Let me start with the uncomfortable truth: passwords are fundamentally broken. Every week, I see clients dealing with credential stuffing attacks, phishing campaigns, and database leaks that trace back to weak or reused passwords. The numbers from 2025 were staggering — over 4.2 billion credentials were exposed in breaches, and phishing remains the number one attack vector worldwide.
Passkeys, built on the WebAuthn standard (now an official W3C recommendation), solve the core problems that make passwords vulnerable:
- Phishing-resistant by design: Passkeys are bound to the specific domain where they were created. A passkey registered for
yourapp.comsimply will not work onyourapp-evil.com. - Nothing to steal: The private key never leaves the user's device. Even if your database is breached, attackers get nothing useful.
- No more password fatigue: Users authenticate with biometrics, device PINs, or hardware security keys.
- Cross-device sync: Apple's iCloud Keychain, Google Password Manager, and 1Password now sync passkeys seamlessly across devices.
According to the FIDO Alliance, over 75% of global users now have access to a passkey-capable device, and major platforms like Google, Microsoft, GitHub, and Stripe have made passkeys their default authentication method. If your Next.js app doesn't support passkeys in 2026, you're falling behind.
Understanding the WebAuthn Fundamentals
Before we write any code, let's quickly cover the mental model. WebAuthn is built on public-key cryptography. Here's the simplified flow:
- The server generates a random challenge and sends it to the browser.
- The browser asks an authenticator (Touch ID, Windows Hello, a YubiKey, etc.) to create a key pair.
- The private key stays on the device. The public key, along with a credential ID, is sent back to the server.
- The server stores the public key and credential ID tied to the user account.
- On subsequent logins, the server sends a new challenge, the authenticator signs it with the private key, and the server verifies the signature using the stored public key.
The beauty of this model is that the server only ever holds public keys. Even a complete database breach yields nothing an attacker can use to impersonate users.
Project Setup: Building the Foundation
Let's start with a fresh Next.js 15 project. I'll be using the App Router, TypeScript, and a few carefully chosen libraries that I've battle-tested in production.
npx create-next-app@latest passkey-auth-demo \
--typescript --app --tailwind --eslint
cd passkey-auth-demo
npm install @simplewebauthn/server @simplewebauthn/browser prisma @prisma/client
The @simplewebauthn library is hands-down the best WebAuthn implementation I've used in production. It abstracts away the painful parts of the spec and works beautifully with both server and client code. If you're doing Next.js authentication in 2026, this is the library you want.
Next, set up your Prisma schema to store credentials:
model User {
id String @id @default(cuid())
email String @unique
name String?
createdAt DateTime @default(now())
credentials WebAuthnCredential[]
}
model WebAuthnCredential {
id String @id @default(cuid())
userId String
credentialID String @unique
publicKey Bytes
counter BigInt
deviceType String
backedUp Boolean
transports String?
createdAt DateTime @default(now())
lastUsedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
Notice that we're storing the public key, credential ID, and a counter. The counter is critical — it prevents replay attacks by ensuring each authentication uses a fresh signature.
Implementing the Registration Flow
The registration flow has two phases: a server endpoint that generates the registration options, and a client component that handles the WebAuthn ceremony.
Here's the API route for generating registration options:
// app/api/auth/register/options/route.ts
import { generateRegistrationOptions } from '@simplewebauthn/server';
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function POST(req: Request) {
const { email, name } = await req.json();
const user = await prisma.user.upsert({
where: { email },
update: { name },
create: { email, name },
});
const existingCredentials = await prisma.webAuthnCredential.findMany({
where: { userId: user.id },
});
const options = await generateRegistrationOptions({
rpName: 'Rasel Hossain Demo',
rpID: process.env.NEXT_PUBLIC_RP_ID || 'localhost',
userID: user.id,
userName: user.email,
userDisplayName: user.name || user.email,
attestationType: 'none',
excludeCredentials: existingCredentials.map(cred => ({
id: cred.credentialID,
transports: cred.transports?.split(',') as AuthenticatorTransport[],
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
supportedAlgorithmIDs: [-7, -257],
});
return NextResponse.json(options);
}
And the verification endpoint:
// app/api/auth/register/verify/route.ts
import { verifyRegistrationResponse } from '@simplewebauthn/server';
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function POST(req: Request) {
const { email, attestationResponse } = await req.json();
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const verification = await verifyRegistrationResponse({
response: attestationResponse,
expectedChallenge: attestationResponse.challenge,
expectedOrigin: process.env.NEXT_PUBLIC_ORIGIN!,
expectedRPID: process.env.NEXT_PUBLIC_RP_ID!,
});
if (!verification.verified || !verification.registrationInfo) {
return NextResponse.json({ error: 'Verification failed' }, { status: 400 });
}
await prisma.webAuthnCredential.create({
data: {
userId: user.id,
credentialID: verification.registrationInfo.credential.id,
publicKey: verification.registrationInfo.credential.publicKey,
counter: verification.registrationInfo.credential.counter,
deviceType: verification.registrationInfo.credentialDeviceType,
backedUp: verification.registrationInfo.credentialBackedUp,
transports: verification.registrationInfo.credential.transports?.join(','),
},
});
return NextResponse.json({ verified: true });
}
On the client side, here's a React component that handles the registration ceremony:
'use client';
import { startRegistration } from '@simplewebauthn/browser';
export function PasskeyRegistration({ email, name }: { email: string; name: string }) {
const handleRegister = async () => {
try {
const optionsResponse = await fetch('/api/auth/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, name }),
});
const options = await optionsResponse.json();
const attestationResponse = await startRegistration({ optionsJSON: options });
const verificationResponse = await fetch('/api/auth/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, attestationResponse }),
});
const verification = await verificationResponse.json();
if (verification.verified) {
alert('Passkey registered successfully!');
}
} catch (error) {
console.error('Registration failed:', error);
}
};
return <button onClick={handleRegister}>Create Passkey</button>;
}
Building the Login Flow
The login flow follows a similar pattern. I prefer the conditional UI approach — the modern UX where users see a passkey prompt immediately on the login page if they have one available, but can still type their email as a fallback.
// app/api/auth/login/options/route.ts
import { generateAuthenticationOptions } from '@simplewebauthn/server';
export async function POST(req: Request) {
const { email } = await req.json();
const user = await prisma.user.findUnique({
where: { email },
include: { credentials: true },
});
if (!user || user.credentials.length === 0) {
return NextResponse.json({ error: 'No passkeys found' }, { status: 404 });
}
const options = await generateAuthenticationOptions({
rpID: process.env.NEXT_PUBLIC_RP_ID!,
allowCredentials: user.credentials.map(cred => ({
id: cred.credentialID,
transports: cred.transports?.split(',') as AuthenticatorTransport[],
})),
userVerification: 'preferred',
});
return NextResponse.json(options);
}
For the verification, you must remember to increment the counter on every successful authentication:
const verification = await verifyAuthenticationResponse({
response: authenticationResponse,
expectedChallenge: authenticationResponse.challenge,
expectedOrigin: process.env.NEXT_PUBLIC_ORIGIN!,
expectedRPID: process.env.NEXT_PUBLIC_RP_ID!,
credential: {
id: credential.credentialID,
publicKey: credential.publicKey,
counter: Number(credential.counter),
transports: credential.transports?.split(',') as AuthenticatorTransport[],
},
});
if (verification.verified) {
await prisma.webAuthnCredential.update({
where: { id: credential.id },
data: {
counter: verification.authenticationInfo.newCounter,
lastUsedAt: new Date(),
},
});
}
Handling Cross-Device Sync
One of the questions I get asked most often is about cross-device passkey sync. In 2026, this is mostly handled by the platform:
- Apple devices: Passkeys sync via iCloud Keychain with end-to-end encryption. As long as the user is signed into the same Apple ID, their passkeys are available everywhere.
- Android and Chrome: Google Password Manager syncs passkeys across Android devices and Chrome browsers. Chrome 132+ has full passkey sync on desktop too.
- Password managers: 1Password, Bitwarden, and Dashlane all support passkey sync across platforms.
What you need to do on the server side is straightforward: set residentKey: 'preferred' and userVerification: 'preferred' in your registration options. This enables discoverable credentials, which allow the browser to surface passkeys without the user typing their email first.
The one thing I always recommend to clients is to support multiple passkeys per user. Let users register passkeys from their phone, their laptop, and a hardware key. It's a small database change that dramatically improves the user experience.
Fallback Flows for Legacy Users
I would be doing you a disservice if I claimed passkeys are a complete replacement for passwords today. There are still edge cases:
- Users on older browsers without WebAuthn support
- Users on devices without biometrics or hardware security keys
- Enterprise users behind restrictive IT policies
- Users who lose access to all their synced devices
The pragmatic approach I recommend is a hybrid model: support both passkeys and a traditional password fallback. Here's a strategy that works well in production:
- New users: Encourage passkey creation during onboarding. Make it the default, but offer "Use a password instead" as a secondary option.
- Existing users: Add a "Add a passkey" option in account settings. When users log in with a password, surface a prompt to upgrade to a passkey.
- Recovery: Implement account recovery via email magic links. This is more secure than security questions and works well as a backup.
- Enterprise: Offer TOTP as a secondary fallback for organizations that need to enforce specific compliance requirements.
Production Best Practices From Real Deployments
After deploying passkey authentication for dozens of clients, here are the hard-won lessons I can share:
Always set the correct RP ID. This is the most common production mistake I see. The RP ID must be a valid domain or a registrable suffix of the origin. If your app runs on app.example.com, your RP ID should be example.com, not app.example.com. Get this wrong and your passkeys won't work on subdomains.
Validate the origin carefully. The origin in your verification must exactly match the origin of your app in production. I've debugged countless "works in dev, broken in prod" issues that all traced back to origin mismatches.
Store the AAGUID and attestation data. In 2026, regulators in financial services and healthcare require you to know what type of authenticator was used. Store the AAGUID and attestation metadata for audit purposes.
Implement rate limiting on your challenge endpoints. Even though passkeys are inherently secure, you still need to rate limit your API endpoints to prevent DoS attacks against your challenge generation.
Log authentication events for security monitoring. When a user logs in from a new device, when a new passkey is added, when an account is recovered — log everything. Your SOC team will thank you.
Test cross-browser compatibility. Safari, Chrome, Firefox, and Edge all have slightly different WebAuthn behaviors. Test every flow on every major browser before shipping.
Looking Ahead: The Future of Authentication
Passkeys are not the end of the authentication story, but they are a massive leap forward. In 2026, I'm starting to see early experiments with verifiable credentials, decentralized identifiers, and zero-knowledge proofs. But for the next 3-5 years, passkeys will be the foundation of secure web authentication.
If you're building a new Next.js app in 2026, there is no good reason to start with passwords. If you're modernizing an existing app, you can roll out passkeys gradually without disrupting your users. Either way, the time to act is now.
Frequently Asked Questions
What happens if a user loses their device? With cross-device sync enabled, users can recover their passkeys on a new device by signing into their Apple ID, Google account, or password manager. For users without sync, you should provide account recovery via email or backup authentication methods.
Are passkeys more secure than passwords? Yes, significantly. Passkeys are phishing-resistant, cannot be reused across sites, and the private key never leaves the user's device. Even a complete database breach on your server reveals nothing an attacker can use.
Do all browsers support WebAuthn now? As of 2026, all major browsers (Chrome, Safari, Firefox, Edge) support WebAuthn and passkeys on both desktop and mobile. Coverage is over 95% of global users.
Can I use passkeys alongside existing password authentication? Absolutely. In fact, I recommend this hybrid approach during the transition period. Most users will gradually migrate to passkeys while you retain password support for edge cases.
Should I use a third-party service like Clerk or Auth0 instead? For larger teams that need to ship fast, yes. For teams that want full control and lower long-term costs, building it yourself with libraries like SimpleWebAuthn is very achievable in a sprint or two.
