Passwords Are Losing: Add Passkeys to Your Next.js App
7 billion passkeys are now in use. Add passkey sign-in to Next.js with SimpleWebAuthn, autofill and Prisma, and avoid the XSS backdoor.
CarnielOn this page9 sections
This week the FIDO Alliance published its 2026 Passkey Index. The headline number: an estimated 7 billion passkeys are now in use, and more than a third of successful sign-ins at the services they measured used one (FIDO Alliance).
The numbers behind it are better than the headline. Across 15 consumer services, passkey sign-ins succeeded 93% of the time, compared with 78% for traditional MFA, and they were nearly three times faster, saving an average of 27 seconds per sign-in (Passkey Index 2026). Every service that tracked phishing saw fewer successful attacks after adding passkeys.
If you build anything with a login, especially in fintech, that's hard to ignore. This post shows how to add passkeys to a Next.js app with SimpleWebAuthn, server actions and Prisma, and the one security mistake that turns a passkey into a backdoor.
What a passkey actually is
A passkey is a key pair. When a user creates one, their device (phone, laptop or password manager) generates a private key that never leaves it, and sends your server the matching public key. To sign in, your server sends a random challenge, the device signs it after a fingerprint, face scan or PIN, and your server checks the signature with the public key.
That design is why passkeys beat passwords:
Nothing to steal from your database. A public key is useless to an attacker.
Nothing to phish. The browser only lets a passkey sign for the site it was created on. A fake login page on a look-alike domain gets nothing.
Nothing to remember. The user unlocks their device the way they already do 50 times a day.
Setup
You need two packages: one for the server, one for the browser. At the time of writing, both are on version 14.
npm install @simplewebauthn/server @simplewebauthn/browserAdd a Passkey model next to your existing User. A user can have several passkeys, one per device or password manager:
model User {
id String @id @default(cuid())
email String @unique
passkeys Passkey[]
}
model Passkey {
id String @id // credential ID from the authenticator
publicKey Bytes
counter Int
transports String[]
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
}Then three values that tell the browser which site the passkey belongs to:
// lib/passkeys.ts
export const rpName = 'My App'
export const rpID = process.env.NODE_ENV === 'production' ? 'example.com' : 'localhost'
export const origin =
process.env.NODE_ENV === 'production' ? 'https://example.com' : 'http://localhost:3000'rpID is your domain without the protocol or port. Passkeys created for example.com only work on example.com and its subdomains, which is exactly what makes them phishing-proof. Get this wrong and nothing works, so check it first when debugging.
Storing the challenge
Every registration and sign-in starts with a random challenge from the server, and the server must check that the response signs that exact challenge. The simplest place to keep it between the two requests is a short-lived, httpOnly cookie:
// app/passkeys/actions.ts
'use server'
import { cookies } from 'next/headers'
const CHALLENGE_COOKIE = 'pk-challenge'
async function saveChallenge(challenge: string) {
const jar = await cookies()
jar.set(CHALLENGE_COOKIE, challenge, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 5,
})
}
async function takeChallenge() {
const jar = await cookies()
const challenge = jar.get(CHALLENGE_COOKIE)?.value
jar.delete(CHALLENGE_COOKIE)
if (!challenge) throw new Error('Challenge expired. Please try again.')
return challenge
}The challenge is deleted as soon as it's read, so each one can only be used once.
Step 1: let a signed-in user add a passkey
Passkeys are easiest to roll out as an upgrade. Users sign in the way they do today, then add a passkey from their security settings. Two server actions handle it:
import {
generateRegistrationOptions,
verifyRegistrationResponse,
type RegistrationResponseJSON,
} from '@simplewebauthn/server'
import { isoUint8Array } from '@simplewebauthn/server/helpers'
import { db, requireUser } from '@/lib/auth'
import { rpName, rpID, origin } from '@/lib/passkeys'
export async function getRegistrationOptions() {
const user = await requireUser()
const existing = await db.passkey.findMany({ where: { userId: user.id } })
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: user.email,
userID: isoUint8Array.fromUTF8String(user.id),
attestationType: 'none',
excludeCredentials: existing.map((p) => ({ id: p.id, transports: p.transports })),
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred',
},
})
await saveChallenge(options.challenge)
return options
}
export async function verifyRegistration(response: RegistrationResponseJSON) {
const user = await requireUser()
const { verified, registrationInfo } = await verifyRegistrationResponse({
response,
expectedChallenge: await takeChallenge(),
expectedOrigin: origin,
expectedRPID: rpID,
})
if (!verified) return { ok: false }
const { credential } = registrationInfo
await db.passkey.create({
data: {
id: credential.id,
publicKey: new Uint8Array(credential.publicKey),
counter: credential.counter,
transports: credential.transports ?? [],
userId: user.id,
},
})
return { ok: true }
}A few options are doing important work here:
residentKey: 'required'creates a discoverable passkey. The user won't need to type their email to sign in later, because the passkey itself knows which account it belongs to.excludeCredentialsstops the same device registering twice. In my test, a second attempt failed with anInvalidStateErrorsaying the authenticator was already registered, which you can show as a friendly message.userIDis a stable ID for the user, not their email. The browser hands it back at sign-in, so it must never change.
On the client, a button calls both actions with the browser helper in between:
'use client'
import { useState } from 'react'
import { startRegistration } from '@simplewebauthn/browser'
import { getRegistrationOptions, verifyRegistration } from '@/app/passkeys/actions'
export function AddPasskeyButton() {
const [status, setStatus] = useState('')
async function addPasskey() {
try {
const optionsJSON = await getRegistrationOptions()
const response = await startRegistration({ optionsJSON })
const result = await verifyRegistration(response)
setStatus(result.ok ? 'Passkey added.' : 'Could not verify that passkey.')
} catch (err) {
// The user cancelled, or this device already has a passkey for the account
setStatus(err instanceof Error ? err.message : 'Something went wrong.')
}
}
return (
<div>
<button onClick={addPasskey}>Add a passkey</button>
{status && <p role="status">{status}</p>}
</div>
)
}Step 2: sign in with a passkey
Sign-in is the same pattern in reverse. The server sends a challenge, the device signs it, the server checks the signature against the stored public key:
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
type AuthenticationResponseJSON,
} from '@simplewebauthn/server'
import { createSession } from '@/lib/auth'
export async function getLoginOptions() {
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
})
await saveChallenge(options.challenge)
return options
}
export async function verifyLogin(response: AuthenticationResponseJSON) {
const passkey = await db.passkey.findUnique({ where: { id: response.id } })
if (!passkey) return { ok: false }
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
response,
expectedChallenge: await takeChallenge(),
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: passkey.id,
publicKey: new Uint8Array(passkey.publicKey),
counter: passkey.counter,
transports: passkey.transports,
},
})
if (!verified) return { ok: false }
await db.passkey.update({
where: { id: passkey.id },
data: { counter: authenticationInfo.newCounter },
})
await createSession(passkey.userId)
return { ok: true }
}Notice that generateAuthenticationOptions doesn't get a list of allowed credentials. Leaving it empty tells the browser to offer any passkey the user has for your site, which is what makes username-less sign-in work. createSession is whatever your app already uses to sign someone in, whether that's Auth.js, Better Auth or your own session cookie.
The nicest part: autofill
You don't need a separate "Sign in with passkey" button. Add webauthn to the email field's autocomplete, start a passkey request in the background, and the browser will offer saved passkeys right in the autofill dropdown. Users who don't have one just type their email and password as usual.
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { startAuthentication, browserSupportsWebAuthnAutofill } from '@simplewebauthn/browser'
import { getLoginOptions, verifyLogin } from '@/app/passkeys/actions'
export function LoginForm() {
const router = useRouter()
useEffect(() => {
async function listenForPasskey() {
if (!(await browserSupportsWebAuthnAutofill())) return
const optionsJSON = await getLoginOptions()
const response = await startAuthentication({ optionsJSON, useBrowserAutofill: true })
const result = await verifyLogin(response)
if (result.ok) router.push('/dashboard')
}
listenForPasskey().catch(() => {
// Autofill was dismissed or aborted. The normal form still works.
})
}, [router])
return (
<form action="/api/login/password" method="post">
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="username webauthn" />
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" autoComplete="current-password" />
<button type="submit">Sign in</button>
</form>
)
}This is the gentlest rollout you can do. Nothing changes for people without passkeys, and people with one get a one-tap sign-in.
How I tested it
Testing passkeys usually means grabbing your phone every time. Chromium has a virtual authenticator you can switch on from Playwright, which makes the whole flow scriptable:
const cdp = await context.newCDPSession(page)
await cdp.send('WebAuthn.enable')
await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
})With that in place I ran the server code above against a real Chromium: registration verified, a sign-in with no email typed verified and returned the right user, and a second registration from the same authenticator was rejected. You can also add the same virtual authenticator by hand in Chrome DevTools, under More tools, then WebAuthn.
The mistake that turns a passkey into a backdoor
Passkeys stop phishing. They don't stop malicious JavaScript running on your own site. Scott Helme points out that a single XSS bug lets an attacker's script register their own passkey on the victim's account, with no prompt the victim would notice. From then on, the attacker can sign in whenever they like (XSS is deadly for passkeys).
Most apps, including the code above, use attestationType: 'none', because synced passkeys from iCloud Keychain, Google Password Manager and 1Password can't prove what device made them. That's the right default. It just means your server can't tell a real passkey from one an attacker's script made up. So protect the registration step itself:
Ask again before adding a passkey. Require the current password, an existing passkey or an email code inside
getRegistrationOptions. A stolen session alone shouldn't be enough.Tell the user. Send an email every time a passkey is added, with a link to remove it.
Show every passkey in settings. Store the date it was created and let users delete any they don't recognise.
Lock down scripts. A strict Content Security Policy and Subresource Integrity on third-party scripts make the XSS itself much harder.
If you work on payments, treat "add a passkey" with the same care as "change phone number". It's a change to how someone gets into their money.
A rollout plan that works
Ship the "Add a passkey" button in security settings, with a re-check and an email alert.
Turn on autofill on the login page.
After a user signs in with a password, offer to create a passkey right then, while they're already verified.
Watch your sign-in success rate. If the Passkey Index numbers hold for your users, you'll see it move.
Only once most active users have a passkey, consider making passwords optional for new accounts.
The Passkey Index found that two of the services it measured still show a password or code screen before the passkey, even to users who have one. Don't make that mistake. If a user has a passkey, offer it first.
Passwords won't disappear this year. But 7 billion passkeys means your users probably already have one set up somewhere, and they'll notice which apps let them use it.