Passkeys and WebAuthn explained
By Flavio Copes
Understand passkeys and WebAuthn, then build registration and login with server challenges, stored public keys, SimpleWebAuthn, recovery, and tests.
Passkeys let a user sign in with a public-key credential instead of typing a password.
The browser talks to an authenticator through WebAuthn. The authenticator can be the device, a password manager, a phone, or a hardware security key.
Your server stores a public key. The private key stays under the authenticator’s control.
That changes the trust model:
password login -> user sends a shared secret
passkey login -> authenticator signs a fresh challenge
No password equivalent is sent to your server during login.
The free Web Authentication course covers passkeys together with sessions, passwords, OAuth, recovery, and authorization.
Passkeys, WebAuthn, and authenticators
These words describe different parts of the system.
WebAuthn is the browser API and protocol used to create and use public-key credentials.
A passkey is a discoverable WebAuthn credential designed for user-friendly sign-in.
An authenticator protects the credential and performs the cryptographic operation. It might ask the user to unlock with a fingerprint, face, device PIN, or security-key touch.
The biometric is not sent to your site. The device uses it locally to verify the user before allowing the private key operation.
Why passkeys resist phishing
A password can be typed into a convincing fake site.
A WebAuthn credential is scoped to a relying party ID, or RP ID. The browser and authenticator enforce that scope.
A credential created for shop.example.com cannot be used by shop-example.com.
Your server must still verify the exact expected origin and RP ID. Browser enforcement is one layer, not permission to skip server checks.
Even correctly hashed passwords remain shared secrets that users can reveal to a phishing page. Passkeys remove that action from the login flow.
Synced and device-bound passkeys
Some passkeys sync through a passkey provider to the user’s other devices. Others stay on one authenticator.
Examples of passkey providers include Apple Passwords, Google Password Manager, and third-party password managers.
A hardware security key usually holds a device-bound passkey. Losing that key can mean losing the credential.
Do not design recovery around the assumption that every passkey syncs. Let users register more than one passkey and give them a safe recovery path.
The two ceremonies
WebAuthn has two main flows.
Registration creates a credential and stores its public information on your server.
Authentication proves the user controls that credential.
Both flows start on the server with a random challenge.
Registration
server creates challenge and options
browser calls navigator.credentials.create()
authenticator creates key pair
browser returns credential response
server verifies and stores public key
Authentication
server creates challenge and options
browser calls navigator.credentials.get()
authenticator signs challenge
browser returns assertion
server verifies signature with stored public key
server creates normal application session
The challenge makes an old response useless. A captured assertion cannot be replayed for a new challenge.
What the server must verify
During registration and authentication, verify at least:
- the response contains the challenge you issued
- the challenge is unexpired and has not been used
- the response type matches the ceremony
- the origin is one you expect
- the RP ID matches your application
- user presence is set
- user verification is set when you require it
- the cryptographic signature or attestation is valid
During authentication, also verify the credential belongs to the user flow you are completing.
This is not a good place for hand-written cryptography or ad-hoc CBOR parsing. Use a maintained WebAuthn library or an authentication provider.
Use HTTPS
WebAuthn is available in secure contexts. Production applications use HTTPS.
Browsers make a localhost exception for development, so you can test locally without creating a public domain first.
Choose the production RP ID carefully. Credentials are scoped to it for their lifetime. A migration from one unrelated domain to another needs a product plan, not only a DNS change.
Install SimpleWebAuthn
We will use SimpleWebAuthn to make the server checks visible without implementing the binary protocol ourselves.
Install its server and browser packages:
npm install @simplewebauthn/server @simplewebauthn/browser
The examples use these application values:
const rpName = 'Books'
const rpID = 'books.example.com'
const expectedOrigin = 'https://books.example.com'
For local development, use the localhost values documented by the library.
Generate registration options
Only an authenticated user should add a passkey to an existing account.
Create an endpoint that loads the current user and their credentials:
import { generateRegistrationOptions } from '@simplewebauthn/server'
app.get('/api/passkeys/register/options', requireUser, async (
request,
response
) => {
const passkeys = await getPasskeysForUser(request.user.id)
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: request.user.email,
attestationType: 'none',
excludeCredentials: passkeys.map(passkey => ({
id: passkey.credentialId,
transports: passkey.transports
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'required'
}
})
await savePasskeyChallenge({
userId: request.user.id,
kind: 'registration',
challenge: options.challenge,
expiresAt: new Date(Date.now() + 5 * 60_000)
})
response.json(options)
})
excludeCredentials helps prevent registering the same credential twice.
attestationType: 'none' avoids collecting identifying authenticator information when the application does not need hardware attestation.
The challenge is stored server-side, bound to the user and ceremony, with a short expiry.
Start registration in the browser
The browser package handles base64url conversion and the call to navigator.credentials.create():
import { startRegistration } from '@simplewebauthn/browser'
async function registerPasskey() {
const optionsResponse = await fetch(
'/api/passkeys/register/options'
)
if (!optionsResponse.ok) {
throw new Error('Could not start passkey registration')
}
const optionsJSON = await optionsResponse.json()
const registration = await startRegistration({ optionsJSON })
const verifyResponse = await fetch(
'/api/passkeys/register/verify',
{
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify(registration)
}
)
if (!verifyResponse.ok) {
throw new Error('Passkey registration failed')
}
}
The browser displays its own authenticator interface. Your page should explain why the prompt appears and what account is being changed.
Verify registration on the server
The verification endpoint consumes the stored challenge:
import { verifyRegistrationResponse } from '@simplewebauthn/server'
app.post('/api/passkeys/register/verify', requireUser, async (
request,
response
) => {
const challenge = await consumePasskeyChallenge({
userId: request.user.id,
kind: 'registration'
})
if (!challenge || challenge.expiresAt < new Date()) {
return response.status(400).json({
error: 'Registration challenge expired'
})
}
const verification = await verifyRegistrationResponse({
response: request.body,
expectedChallenge: challenge.challenge,
expectedOrigin,
expectedRPID: rpID,
requireUserVerification: true
})
if (!verification.verified || !verification.registrationInfo) {
return response.status(400).json({
error: 'Registration could not be verified'
})
}
const { credential, credentialDeviceType, credentialBackedUp } =
verification.registrationInfo
await savePasskey({
userId: request.user.id,
credentialId: credential.id,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp
})
response.json({ verified: true })
})
consumePasskeyChallenge() must make the challenge single-use. A database transaction or atomic delete-and-return operation is a good fit.
Store the credential ID, public key, counter, transports, user relationship, and useful backup metadata. Let the library’s types guide the exact binary storage format.
Never store a private key. Your server never receives it.
Generate authentication options
For a username-first login, find the user and list their credentials:
import { generateAuthenticationOptions } from '@simplewebauthn/server'
app.post('/api/passkeys/login/options', async (request, response) => {
const user = await findUserByEmail(request.body.email)
if (!user) {
return response.status(400).json({
error: 'Could not start sign in'
})
}
const passkeys = await getPasskeysForUser(user.id)
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: passkeys.map(passkey => ({
id: passkey.credentialId,
transports: passkey.transports
})),
userVerification: 'required'
})
const flowId = crypto.randomUUID()
await savePasskeyChallenge({
flowId,
userId: user.id,
kind: 'authentication',
challenge: options.challenge,
expiresAt: new Date(Date.now() + 5 * 60_000)
})
response.json({ flowId, options })
})
The error does not reveal whether the email exists. Account-enumeration defenses also need consistent timing and rate limits.
The random flowId lets one browser start two login attempts without the newer challenge silently overwriting the older one.
Start authentication in the browser
Call startAuthentication() with the options:
import { startAuthentication } from '@simplewebauthn/browser'
async function signInWithPasskey(email) {
const optionsResponse = await fetch(
'/api/passkeys/login/options',
{
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({ email })
}
)
const { flowId, options } = await optionsResponse.json()
const authentication = await startAuthentication({
optionsJSON: options
})
const verifyResponse = await fetch(
'/api/passkeys/login/verify',
{
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
flowId,
authentication
})
}
)
if (!verifyResponse.ok) {
throw new Error('Passkey sign in failed')
}
}
After verification, the server creates the same kind of secure session it would create after another login method. Passkeys authenticate the user; they are not a replacement for application sessions.
Verify authentication
Load the challenge, user, and exact credential:
import { verifyAuthenticationResponse } from '@simplewebauthn/server'
app.post('/api/passkeys/login/verify', async (request, response) => {
const challenge = await consumePasskeyChallenge({
flowId: request.body.flowId,
kind: 'authentication'
})
if (!challenge || challenge.expiresAt < new Date()) {
return response.status(400).json({
error: 'Authentication challenge expired'
})
}
const passkey = await getPasskey({
userId: challenge.userId,
credentialId: request.body.authentication.id
})
if (!passkey) {
return response.status(400).json({
error: 'Authentication could not be verified'
})
}
const verification = await verifyAuthenticationResponse({
response: request.body.authentication,
expectedChallenge: challenge.challenge,
expectedOrigin,
expectedRPID: rpID,
credential: {
id: passkey.credentialId,
publicKey: passkey.publicKey,
counter: passkey.counter,
transports: passkey.transports
},
requireUserVerification: true
})
if (!verification.verified) {
return response.status(400).json({
error: 'Authentication could not be verified'
})
}
await updatePasskeyCounter(
passkey.id,
verification.authenticationInfo.newCounter
)
await createSession(response, challenge.userId)
response.json({ verified: true })
})
Update the stored counter after a successful verification.
A counter that fails to increase can signal a cloned or malfunctioning authenticator, but zero counters are valid. Let the library verify the protocol and define a risk policy for counter warnings rather than inventing a universal rejection rule.
Username-less login
Passkeys are discoverable credentials. The browser can offer them without asking for an email first.
For that flow, generate authentication options without allowCredentials. The returned response includes a user handle and credential ID that your server uses to find the account.
Conditional UI can show passkeys in the username field’s autofill menu:
<input
name="email"
type="email"
autocomplete="username webauthn"
>
This improves sign-in, but it adds browser capability checks and a long-running conditional request. Build and test the explicit passkey button first.
Let users manage credentials
Users need a passkey settings page.
Show:
- a user-chosen name such as “MacBook”
- when the credential was added
- when it was last used
- whether it appears backed up, when known
- a remove action
Require recent authentication before adding or removing a passkey. Do not let an unattended logged-in session silently replace the account’s login methods.
Allow more than one passkey. A user might keep one synced passkey and one hardware key.
Recovery is part of authentication
Passkeys reduce password problems. They do not eliminate account recovery.
A user can lose a device, lose access to a passkey provider, or remove the last credential.
Choose a recovery model before launch:
- another registered passkey
- a carefully protected recovery code
- a verified support process for high-value accounts
- another existing login factor during migration
Recovery must not be much weaker than normal login. An attacker will choose the easiest path.
Do not rely on email alone for a high-risk account without considering mailbox compromise, session theft, and recent security changes.
Common mistakes
Generating challenges in the browser
The trusted server creates unpredictable challenges and verifies the returned value.
Reusing challenges
Challenges are short-lived and single-use. Consume them atomically.
Skipping origin checks
Verify an exact allowlist of expected HTTPS origins. Do not accept arbitrary subdomains without a deliberate reason.
Treating display names as identity
Use stable internal user IDs. Emails and display names can change.
Storing one challenge per user
Concurrent tabs can overwrite each other. Give each ceremony a flow ID.
Assuming every passkey syncs
Support multiple credentials and recovery.
Writing raw WebAuthn verification
Use a maintained library or provider. Encoding, COSE keys, CBOR, origin checks, flags, and signature verification are security-critical.
Forgetting abuse controls
Rate-limit option and verification endpoints. Generic errors alone do not stop account enumeration or request floods.
Test the complete flow
Test more than one successful registration.
Cover:
- expired challenge
- reused challenge
- wrong origin
- wrong RP ID
- unknown credential ID
- missing user verification when required
- two concurrent login tabs
- duplicate credential registration
- removed credential
- recovery after losing the primary device
Browsers include virtual authenticators in developer tools. Use them for automated cases, then test real platform and security-key flows before launch.
How I would add passkeys
I would add passkeys beside an existing login method first.
I would let a strongly authenticated user register more than one credential, name each one, and remove it from an account settings page. I would build recovery before encouraging users to depend on passkeys.
I would use a maintained library and keep my own code focused on users, sessions, challenges, credential records, rate limits, and product flows.
I would not make passkeys the only login method on the first day unless I controlled the devices and support process. The cryptography can be correct while the recovery experience is not ready.
Passkeys solve the shared-secret problem. A complete authentication system still needs good sessions, authorization, recovery, monitoring, and careful account changes.
Want me to talk about your product? You can sponsor this site.
Related posts about network: