Passkeys with WebAuthn: Step-by-Step Implementation
Meta description: I show every step to implement Passkeys with WebAuthn in a custom web app — registration, authentication, real gotchas, and production tips included.
Last updated: June 28, 2026
Introduction: The Password Fatigue That Led Me Here
I used to manage password reset flows like it was a part-time job. Every week: users locked out, support tickets, password spray attacks showing up in the logs. After the third credential stuffing incident in a year, I decided to rip out our entire password system and implement Passkeys from scratch using the WebAuthn API.
It was one of the best decisions I’ve made as an engineer — and also one of the most confusing to implement the first time. The spec is dense, the browser quirks are real, and most tutorials stop at “it works on localhost.” This guide covers everything I learned building it end to end for a production app.
TL;DR
- Passkeys use public-key cryptography — your server stores only a public key, never a secret.
- Implementation requires two flows: registration (creating the credential) and authentication (verifying it).
- The
SimpleWebAuthnlibrary dramatically reduces boilerplate while staying spec-compliant.
Why Passkeys and WebAuthn Matter Right Now
Passkeys are credentials built on the WebAuthn (Web Authentication) standard, backed by FIDO2. Instead of a password, the user’s device generates a public/private key pair. The private key never leaves the device. Your server stores only the public key, which is useless to an attacker by itself.
This eliminates phishing (there’s nothing to steal and reuse), eliminates credential stuffing (stolen hashes are worthless), and removes the UX burden of password managers. Apple, Google, and Microsoft all ship native Passkey support in 2024+, meaning most of your users’ devices support this today.
OWASP now recommends phasing out passwords wherever possible. Passkeys are the practical path forward.
[INTERNAL LINK: related article on authentication security best practices]
Prerequisites
Make sure you have:
- Node.js 20+ and a web framework (I use Express in this guide)
- HTTPS in development — WebAuthn requires a secure context (
localhostcounts) - A modern browser (Chrome 108+, Safari 16+, Firefox 119+)
- A database to store credentials (I use PostgreSQL with Prisma)
- Basic understanding of async/await and REST APIs
Step-by-Step: Implementing Passkeys with WebAuthn
Step 1: Install Dependencies
I use @simplewebauthn/server on the backend and @simplewebauthn/browser on the frontend. These are well-maintained wrappers that handle the low-level spec details without hiding the important parts.
npm install @simplewebauthn/server @simplewebauthn/types
npm install @simplewebauthn/browser # frontend
My versions at time of writing: @simplewebauthn/server@9.0.3, @simplewebauthn/browser@9.0.3.
Security Note: Never implement the raw WebAuthn cryptographic verification yourself. The spec has subtle edge cases around attestation, counter validation, and origin checking. Use a vetted library like SimpleWebAuthn or
fido2-lib.
Step 2: Configure Your Relying Party
A Relying Party (RP) is your web application in WebAuthn terms. You define its ID and name — and these must match exactly across registration and authentication.
// config/webauthn.ts
export const rpConfig = {
rpName: 'My App',
rpID: process.env.RP_ID || 'localhost', // e.g., 'myapp.com' in production
origin: process.env.ORIGIN || 'http://localhost:3000',
expectedOrigin: [process.env.ORIGIN || 'http://localhost:3000'],
}
Gotcha I hit in production: rpID must be the domain only — no protocol, no port. I initially set it to https://myapp.com and got InvalidStateError on every auth attempt. The correct value is myapp.com.
Step 3: Build the Registration Flow
Registration has two round trips: generate options → user verifies with device → server verifies response.
3a: Generate Registration Options (Server)
import { generateRegistrationOptions, isoUint8Array } from '@simplewebauthn/server'
import { rpConfig } from '../config/webauthn'
app.get('/auth/register/options', async (req, res) => {
const user = req.user // assume authenticated session
const existingCredentials = await db.credential.findMany({
where: { userId: user.id },
select: { credentialID: true, transports: true },
})
const options = await generateRegistrationOptions({
rpName: rpConfig.rpName,
rpID: rpConfig.rpID,
userID: isoUint8Array.fromUTF8String(user.id),
userName: user.email,
userDisplayName: user.name,
attestationType: 'none', // 'direct' if you need attestation
excludeCredentials: existingCredentials.map(c => ({
id: c.credentialID,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: 'required', // Required for discoverable credentials (true Passkeys)
userVerification: 'required', // Biometric or PIN required
},
})
// Store challenge in session — critical for verification
req.session.registrationChallenge = options.challenge
res.json(options)
})
3b: Verify Registration Response (Server)
import { verifyRegistrationResponse } from '@simplewebauthn/server'
app.post('/auth/register/verify', async (req, res) => {
const { body } = req
const expectedChallenge = req.session.registrationChallenge
let verification
try {
verification = await verifyRegistrationResponse({
response: body,
expectedChallenge,
expectedOrigin: rpConfig.origin,
expectedRPID: rpConfig.rpID,
})
} catch (error) {
console.error('Registration verification failed:', error.message)
return res.status(400).json({ error: 'Verification failed' })
}
const { verified, registrationInfo } = verification
if (verified && registrationInfo) {
const { credential } = registrationInfo
await db.credential.create({
data: {
userId: req.user.id,
credentialID: credential.id, // base64url string
publicKey: Buffer.from(credential.publicKey), // Store as bytes
counter: credential.counter,
transports: body.response.transports ?? [],
deviceType: registrationInfo.credentialDeviceType,
backedUp: registrationInfo.credentialBackedUp,
},
})
}
req.session.registrationChallenge = undefined
res.json({ verified })
})
3c: Trigger Registration (Browser)
import { startRegistration } from '@simplewebauthn/browser'
async function registerPasskey() {
const optionsRes = await fetch('/auth/register/options')
const options = await optionsRes.json()
let regResponse
try {
regResponse = await startRegistration({ optionsJSON: options })
} catch (err) {
if (err.name === 'InvalidStateError') {
alert('A passkey already exists for this device.')
} else {
console.error('Registration error:', err)
}
return
}
const verifyRes = await fetch('/auth/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(regResponse),
})
const result = await verifyRes.json()
if (result.verified) {
alert('Passkey registered successfully!')
}
}
Step 4: Build the Authentication Flow
Authentication also has two round trips, but no existing session is required — the user proves identity from scratch.
4a: Generate Authentication Options (Server)
import { generateAuthenticationOptions } from '@simplewebauthn/server'
app.get('/auth/login/options', async (req, res) => {
const options = await generateAuthenticationOptions({
rpID: rpConfig.rpID,
userVerification: 'required',
// Leave allowCredentials empty for "discoverable credential" flow
// (the device shows which passkeys are available)
})
req.session.authChallenge = options.challenge
res.json(options)
})
4b: Verify Authentication Response (Server)
import { verifyAuthenticationResponse } from '@simplewebauthn/server'
app.post('/auth/login/verify', async (req, res) => {
const { body } = req
const expectedChallenge = req.session.authChallenge
const credential = await db.credential.findUnique({
where: { credentialID: body.id },
include: { user: true },
})
if (!credential) {
return res.status(400).json({ error: 'Credential not found' })
}
let verification
try {
verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge,
expectedOrigin: rpConfig.origin,
expectedRPID: rpConfig.rpID,
credential: {
id: credential.credentialID,
publicKey: new Uint8Array(credential.publicKey),
counter: credential.counter,
transports: credential.transports,
},
})
} catch (error) {
return res.status(400).json({ error: error.message })
}
const { verified, authenticationInfo } = verification
if (verified) {
// CRITICAL: Update the counter to prevent replay attacks
await db.credential.update({
where: { id: credential.id },
data: { counter: authenticationInfo.newCounter },
})
req.session.userId = credential.userId
req.session.authChallenge = undefined
}
res.json({ verified, userId: verified ? credential.userId : null })
})
4c: Trigger Authentication (Browser)
import { startAuthentication } from '@simplewebauthn/browser'
async function loginWithPasskey() {
const optionsRes = await fetch('/auth/login/options')
const options = await optionsRes.json()
let authResponse
try {
authResponse = await startAuthentication({ optionsJSON: options })
} catch (err) {
console.error('Authentication error:', err)
return
}
const verifyRes = await fetch('/auth/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authResponse),
})
const result = await verifyRes.json()
if (result.verified) {
window.location.href = '/dashboard'
}
}
Step 5: Store Passkey Credentials Correctly in PostgreSQL
Your database schema must store the credential ID as a unique identifier and the public key as bytes. Here’s the Prisma schema I use:
model Credential {
id String @id @default(cuid())
userId String
credentialID String @unique // base64url, indexed
publicKey Bytes // raw COSE-encoded public key
counter Int @default(0)
transports String[]
deviceType String
backedUp Boolean @default(false)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
}
[SOURCE: https://simplewebauthn.dev/docs/packages/server]
Real-World Tips I Use in Production
Always validate counter increment. The authenticator counter should increase with every authentication. If it stays the same or goes backwards, it may indicate a cloned authenticator. I treat this as a security event and lock the credential.
Support multiple passkeys per user. Users have multiple devices. Let them register a passkey on their phone AND their laptop. The excludeCredentials list in registration options prevents exact duplicates from the same device.
Provide a fallback. Not every user has a compatible device yet, especially on older Android versions. Keep an OTP email fallback during the transition period.
Test with Chrome DevTools’ chrome://webauthn-internals. This page shows all registered authenticators and lets you simulate passkey flows without physical hardware.
Common Errors and How I Fixed Them
Error: NotAllowedError: The operation either timed out or was not allowed This fires when the user dismisses the browser prompt or takes too long. It’s not a bug — handle it gracefully in the catch block with a user-friendly message rather than a console error.
Error: TypeError: Cannot read properties of undefined (reading 'publicKey') This happened because I was storing credential.publicKey as a base64 string instead of Buffer. Always store it as raw bytes (Bytes in Prisma, bytea in PostgreSQL) and reconstruct with new Uint8Array(credential.publicKey).
Passkeys not showing up on iOS Safari. I forgot to set residentKey: 'required' during registration. Without this, the credential is not stored in the platform’s keychain as a discoverable Passkey — it’s just a non-discoverable FIDO2 credential, which requires the user to provide their username first.
[SOURCE: https://www.w3.org/TR/webauthn-3/]
FAQ
How does WebAuthn Passkey authentication differ from traditional password-based login?
Passwords are secrets that travel over the network and can be phished or stolen from databases. With Passkeys, only a public key is stored on the server. The private key never leaves the user’s device, and authentication is a local cryptographic challenge-response. There is no secret to steal from your server.
Can I implement Passkeys without HTTPS on my custom web application?
No. The WebAuthn API requires a secure context, which means HTTPS in production. The only exception is localhost, which is treated as secure by browsers for development purposes. In staging and production, your origin must be served over HTTPS or the browser will refuse to invoke the WebAuthn API entirely.
How do I handle Passkey credential storage for users who sign in across multiple devices?
Store each credential separately in your database and associate multiple credentials with one user account. During registration, pass the user’s existing credentialIDs in the excludeCredentials option to prevent duplicate registrations from the same device. Users can name and manage their passkeys in account settings.
What happens if a user loses the device where their Passkey is stored?
If the credential was created with backedUp: true (synced via iCloud Keychain, Google Password Manager, etc.), the user can recover via their platform account. If not synced, they lose access from that device. This is why you should always provide a fallback authentication method and encourage users to register multiple devices.
Is the SimpleWebAuthn library safe to use in a production WebAuthn implementation?
Yes — it is widely used, actively maintained, and adheres strictly to the FIDO2/WebAuthn spec. It handles edge cases like counter verification, CBOR decoding, and attestation parsing that are easy to get wrong in a custom implementation. Always keep it updated and monitor its changelog for security patches.
Conclusion
Implementing Passkeys with WebAuthn was more work than adding passport-local, but the security payoff has been enormous. No more password resets. No more credential stuffing alerts at 3am. Users log in with a fingerprint in under a second. The biggest investment is understanding the two-round-trip flow and storing credentials correctly — once that clicks, the rest is just wiring.
Try this implementation, then come back and share what you ran into. What device quirks did you hit? Drop a comment below, or share this with your team if you’re planning to migrate away from passwords this year.
💡 Gostou desse conteúdo? Acompanhe o SpiritCode e receba novidades direto no seu feed. Basta salvar o endereço spiritcode.blog nos favoritos ou nos seguir nas redes sociais — novas matérias toda semana.
About the Author
I’m a security-focused web engineer with 11 years of experience building authentication systems, OAuth integrations, and zero-trust architectures for SaaS products. My stack centers on TypeScript, Node.js, PostgreSQL, and modern browser APIs — with a particular obsession with making secure systems easy to use. I’ve shipped WebAuthn implementations across three production apps and keep close tabs on FIDO Alliance spec updates as they roll out.
📋 SEO Metadata Block
| Campo | Valor |
|---|---|
| Keyword principal | implement Passkeys WebAuthn web app |
| H1 sugerido | Passkeys with WebAuthn: Step-by-Step Implementation |
| Meta description | I walk through every step to implement Passkeys with WebAuthn in a custom web app — registration, authentication, real gotchas, and production tips. (152 chars) |
| URL slug | /implement-passkeys-webauthn-web-app |
| Alt text sugerido (imagem hero) | “WebAuthn Passkey registration flow diagram — browser, authenticator, and server exchange illustrated step by step” |
Sugestões de links internos:
- “JWT vs Session Tokens: which should you use in 2025?” — complementa o tema de autenticação e tem alta intenção de busca na mesma audiência
- “How to secure your Node.js API with rate limiting and OWASP best practices” — reforça o contexto de segurança do mesmo stack
Sugestão de link externo:
- FIDO Alliance — Passkeys Overview — fonte primária de máxima autoridade sobre o padrão Passkey/FIDO2
