Passwords are a liability. Users reuse them, phishing kits harvest them, and your database becomes a target the moment you store anything derived from them.
Passkeys replace the shared secret with public-key cryptography. In this post we'll cover how they work, then walk through registering and signing in with the browser's WebAuthn API.
Note: The browser code below is the real WebAuthn API. The server side is described and not fully implemented on purpose: use a maintained library rather than writing signature and attestation verification yourself.
The Mental Model
A passkey is a key pair created per site (per relying party):
- The private key stays on the user's device (or in their synced credential manager). It never leaves.
- The public key is stored on your server, along with a credential ID.
- To sign in, your server sends a random challenge. The device signs it. Your server verifies the signature with the stored public key.
Because the credential is bound to your domain, a look-alike phishing site can't get a valid signature. That's the main security win over passwords and one-time codes.
| Password | Passkey | |
|---|---|---|
| Secret stored on server | Hash of a shared secret | Public key only |
| Phishable | Yes | Resistant (origin-bound) |
| Reusable across sites | Often (badly) | No, unique per site |
| User effort | Type/remember | Biometric or device PIN |
Terminology Cheat Sheet
- Relying Party (RP): your site.
-
RP ID: usually your domain, e.g.,
example.com. - Authenticator: the device or credential manager holding the private key.
- Challenge: random bytes your server generates per ceremony.
- Credential ID: identifier you store and use to look up the public key.
Step 1: Registration (Creating a Passkey)
Your server first generates options, including a fresh random challenge. Then the browser calls navigator.credentials.create().
// optionsFromServer is JSON your server created.
// Binary fields (challenge, user.id) are base64url strings in transit.
const b64urlToBuf = (s) => {
const pad = '='.repeat((4 - (s.length % 4)) % 4);
const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/');
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)).buffer;
};
const bufToB64url = (buf) =>
btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
async function registerPasskey() {
const optionsFromServer = await fetch('/webauthn/register/options', {
method: 'POST',
credentials: 'include',
}).then((r) => r.json());
const publicKey = {
challenge: b64urlToBuf(optionsFromServer.challenge),
rp: { name: 'Example App', id: 'example.com' },
user: {
id: b64urlToBuf(optionsFromServer.user.id), // opaque ID, NOT an email
name: optionsFromServer.user.name,
displayName: optionsFromServer.user.displayName,
},
pubKeyCredParams: [
{ type: 'public-key', alg: -7 }, // ES256
{ type: 'public-key', alg: -257 }, // RS256
],
authenticatorSelection: {
residentKey: 'preferred', // discoverable credential (a true passkey)
userVerification: 'preferred',
},
timeout: 60000,
};
const cred = await navigator.credentials.create({ publicKey });
// Send the result to the server for verification
await fetch('/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
id: cred.id,
rawId: bufToB64url(cred.rawId),
type: cred.type,
response: {
clientDataJSON: bufToB64url(cred.response.clientDataJSON),
attestationObject: bufToB64url(cred.response.attestationObject),
},
}),
});
}
Gotcha: user.id should be a stable, random, opaque identifier, not an email address or anything personally identifying.
Step 2: Authentication (Signing In)
async function signInWithPasskey() {
const options = await fetch('/webauthn/login/options', {
method: 'POST',
}).then((r) => r.json());
const assertion = await navigator.credentials.get({
publicKey: {
challenge: b64urlToBuf(options.challenge),
rpId: 'example.com',
userVerification: 'preferred',
// Leave allowCredentials empty to let the user pick a discoverable passkey
},
});
const res = await fetch('/webauthn/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: assertion.id,
rawId: bufToB64url(assertion.rawId),
type: assertion.type,
response: {
clientDataJSON: bufToB64url(assertion.response.clientDataJSON),
authenticatorData: bufToB64url(assertion.response.authenticatorData),
signature: bufToB64url(assertion.response.signature),
userHandle: assertion.response.userHandle
? bufToB64url(assertion.response.userHandle)
: null,
},
}),
});
return res.ok;
}
Bonus: Autofill UI (Conditional Mediation)
You can show passkeys in the browser's autofill dropdown so users don't need a separate button:
<input type="text" name="username" autocomplete="username webauthn" />
if (
window.PublicKeyCredential &&
PublicKeyCredential.isConditionalMediationAvailable &&
(await PublicKeyCredential.isConditionalMediationAvailable())
) {
// Call navigator.credentials.get({ publicKey, mediation: 'conditional' })
// on page load, then verify the result as usual.
}
Step 3: What Your Server MUST Verify
This is where implementations go wrong. For each ceremony the server must check at least:
- Challenge matches the one it issued, is single-use, and hasn't expired
-
Origin in
clientDataJSONmatches your expected origin - RP ID hash in the authenticator data matches your RP ID
-
Type is
webauthn.createorwebauthn.getas appropriate - Signature is valid against the stored public key (on sign-in)
- User verification / presence flags meet your policy
- Signature counter behavior, handled sensibly (some passkey providers always return 0)
Then store: credentialId, publicKey, userId, transports, and createdAt.
Don't hand-roll this. Use a well-maintained library such as SimpleWebAuthn for Node.js or an equivalent for your language, and check its docs for the current API since options shapes change between major versions.
Common Pitfalls
- Wrong RP ID or origin. Passkeys silently fail if the RP ID isn't your domain or a registrable suffix of it.
- Reusing challenges. Always random, always single-use.
- No recovery path. Offer multiple passkeys per account and a secure recovery flow. Locking users out is a real risk.
- Removing every fallback too early. Roll out passkeys as an option first, then measure adoption.
-
Using email as
user.id. Use an opaque random ID. -
HTTP in production. WebAuthn requires a secure context (HTTPS;
localhostworks for development). - Ignoring account recovery security. Attackers will target the weakest path. A passkey is only as strong as your reset flow.
Rollout Strategy That Works
- Add "Create a passkey" after a successful password login.
- Support passkey sign-in alongside existing methods.
- Track adoption and failures.
- Gradually encourage passkeys, and retire password-only paths later if your risk model allows.
Wrapping Up
Passkeys remove the shared secret from the equation, making phishing and credential stuffing much less effective. The browser API is small. The hard parts are correct server verification and thoughtful recovery flows.
If you've shipped passkeys, what tripped you up? Share it in the comments. I'd love to build a list of real-world gotchas.
Resources
- MDN Web Docs: Web Authentication API
Top comments (0)