Passkeys and WebAuthn Implementation Guide 2026: Browser Support & UX Patterns
Implement passkeys and WebAuthn in 2026: browser support matrix, registration and authentication flows, server-side verification, UX best practices, and library recommendations.
Passwords are the single largest source of account breaches. Passkeys solve this at the protocol level: theyβre phishing-resistant, canβt be reused across sites, and authenticate via biometrics or device PIN β nothing the user needs to remember or protect. In 2026, passkey support is built into every major browser and OS. This guide covers the implementation from first registration to production-ready authentication flow.
What Are Passkeys?
A passkey is a FIDO2 credential β a cryptographic key pair generated on your device. The private key never leaves the device (stored in the secure enclave or TPM). The public key is stored on your server. Authentication works by challenging the device to sign a random nonce with the private key. No password is transmitted. No password database to breach.
Under the hood, passkeys use the WebAuthn API (navigator.credentials), the W3C standard for authenticating with cryptographic credentials from the browser.
Why passkeys beat passwords:
- Phishing-resistant: bound to your domain, canβt be used on fake sites
- No shared secrets: the server never sees a password to store or leak
- No reuse: each site gets a unique key pair
- Faster: authenticate with FaceID/TouchID/Windows Hello β no typing
Browser and Platform Support Matrix (2026)
| Platform | Status | Notes |
|---|---|---|
| Chrome 108+ | Full | Android, Windows, macOS, Linux |
| Safari 16+ | Full | macOS, iOS β iCloud Keychain sync |
| Firefox 122+ | Full | Desktop; Android still limited |
| Edge 108+ | Full | Same as Chrome (Chromium) |
| iOS 16+ | Full | Safari + passkey sync via iCloud |
| Android 9+ | Full | Google Password Manager sync |
| Windows 11 | Full | Windows Hello (biometric + PIN) |
| macOS Ventura+ | Full | Touch ID + iCloud Keychain |
Conditional UI (passkey suggestions in autofill) is supported in Chrome, Safari, and Edge. Firefox is catching up.
Registration Flow
Registration creates a new credential and stores the public key on your server.
// 1. Request challenge from your server
const response = await fetch('/api/auth/register/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: '[email protected]' })
});
const options = await response.json();
// 2. Create the credential on the device
const credential = await navigator.credentials.create({
publicKey: {
challenge: base64urlDecode(options.challenge),
rp: {
name: "My App",
id: "myapp.com" // Must match your domain
},
user: {
id: base64urlDecode(options.userId),
name: "[email protected]",
displayName: "Alice"
},
pubKeyCredParams: [
{ alg: -7, type: "public-key" }, // ES256
{ alg: -257, type: "public-key" } // RS256
],
authenticatorSelection: {
authenticatorAttachment: "platform", // Use device authenticator
residentKey: "required", // Enable discoverable credentials
userVerification: "required" // Require biometric/PIN
},
timeout: 60000,
attestation: "none" // "direct" if you need attestation
}
});
// 3. Send credential to server for verification and storage
const verifyResponse = await fetch('/api/auth/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: credential.id,
rawId: base64urlEncode(credential.rawId),
response: {
clientDataJSON: base64urlEncode(credential.response.clientDataJSON),
attestationObject: base64urlEncode(credential.response.attestationObject)
},
type: credential.type
})
});
Authentication Flow
Authentication challenges the stored credential to sign a nonce.
// 1. Request challenge and allowed credentials from server
const response = await fetch('/api/auth/login/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: '[email protected]' })
});
const options = await response.json();
// 2. Authenticate with the device passkey
const assertion = await navigator.credentials.get({
publicKey: {
challenge: base64urlDecode(options.challenge),
rpId: "myapp.com",
allowCredentials: options.allowCredentials.map(cred => ({
id: base64urlDecode(cred.id),
type: "public-key"
})),
userVerification: "required",
timeout: 60000
}
});
// 3. Send assertion to server for verification
const verifyResponse = await fetch('/api/auth/login/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: assertion.id,
rawId: base64urlEncode(assertion.rawId),
response: {
clientDataJSON: base64urlEncode(assertion.response.clientDataJSON),
authenticatorData: base64urlEncode(assertion.response.authenticatorData),
signature: base64urlEncode(assertion.response.signature),
userHandle: assertion.response.userHandle
? base64urlEncode(assertion.response.userHandle) : null
},
type: assertion.type
})
});
Server-Side Verification
Server verification must:
- Validate the challenge matches what was issued
- Verify the signature using the stored public key
- Check the
rpIdHashmatches your domain - Check the
userPresentanduserVerifiedflags
Use a library rather than implementing this yourself:
// Node.js with SimpleWebAuthn
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
const verification = await verifyAuthenticationResponse({
response: assertionFromClient,
expectedChallenge: storedChallenge,
expectedOrigin: 'https://myapp.com',
expectedRPID: 'myapp.com',
authenticator: {
credentialID: storedCredential.credentialID,
credentialPublicKey: storedCredential.publicKey,
counter: storedCredential.counter,
transports: storedCredential.transports
},
requireUserVerification: true
});
if (verification.verified) {
// Update counter to prevent replay attacks
await db.updateCredentialCounter(
storedCredential.id,
verification.authenticationInfo.newCounter
);
// Issue session token
}
Recommended Libraries
| Library | Language | Stars | Notes |
|---|---|---|---|
| SimpleWebAuthn | TypeScript/Node | 2.8k | Most ergonomic, well-maintained |
| go-webauthn | Go | 1.9k | Production-grade, used by GitHub |
| py_webauthn | Python | 1.2k | Django/Flask friendly |
| webauthn4j | Java/Kotlin | 890 | Spring Boot integration |
| Hanko | TypeScript | 7.2k | Full auth service + passkeys UI components |
Hanko is worth highlighting separately β itβs an open-source auth platform that handles passkeys, email magic links, and OAuth, with pre-built React/Vue components that cover the full UX. Use it when you want passkeys without building the flow from scratch.
UX Patterns
Always provide a password fallback. Even in 2026, some devices and browsers have edge cases. A passkey-first flow with a βsign in another wayβ option is the right pattern:
[Sign in with Passkey] β primary CTA
[Continue with Google] β OAuth fallback
[Use password instead] (link) β password fallback
Conditional UI (autofill suggestions). Add autocomplete="username webauthn" to your username input. Browsers that support conditional UI will show passkey suggestions in the autofill dropdown automatically:
<input
type="email"
name="username"
autocomplete="username webauthn"
placeholder="Enter your email"
/>
Then trigger conditional mediation on page load:
if (PublicKeyCredential.isConditionalMediationAvailable) {
const available = await PublicKeyCredential.isConditionalMediationAvailable();
if (available) {
// Trigger without UI blocking β autofill will show passkey suggestions
navigator.credentials.get({
mediation: 'conditional',
publicKey: { challenge: ..., rpId: 'myapp.com' }
}).then(handleAuthentication);
}
}
Registration timing. Prompt users to add a passkey after a successful password login, not during signup. Users are in the app, already trust it, and are more likely to complete the flow.
Security Checklist
- Generate challenges server-side (minimum 16 random bytes)
- Validate
originmatches your domain exactly - Validate
rpIdHashin every assertion - Store and check the credential counter (detects cloned authenticators)
- Set challenge TTL (60 seconds is standard)
- Use HTTPS everywhere β WebAuthn requires a secure context
- Implement account recovery flow (email OTP or backup codes) for lost devices
Passkeys wonβt eliminate every auth problem, but they eliminate password breaches, credential stuffing, and phishing attacks at the protocol level. For any application handling sensitive user data, the implementation investment pays for itself quickly.
50 CLI Commands That Save Hours Every Week
The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.
- Git mastery (10 cmds)
- File & search wizardry
- Docker & DevOps tricks
- Process & network tools
- Productivity boosters
- macOS & Linux install guide
No spam. 1 email/week max. Unsubscribe anytime.
Found this guide useful? Check out our free developer tools.
Affiliate disclosure: Some links below are affiliate links β we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.