Understanding WebAuthn and Passkeys
The Web Authentication API (WebAuthn) is a W3C specification that enables strong, public-key-based authentication on the web. Passkeys are credentials built on WebAuthn, allowing users to authenticate using biometrics (fingerprint, face recognition) or device PINs instead of passwords. In 2025, all evergreen browsers—Chrome, Safari, Firefox, Edge—support WebAuthn natively, and all modern operating systems including Android, iOS, macOS, and Windows have integrated platform authenticators.
The technical distinction matters: WebAuthn is the specification that allows developers to implement passkey support in web applications, while passkeys are the credentials that authenticate users securely without phishing risks. Think of WebAuthn as the protocol, passkeys as the implementation.
Microsoft recently reported that passkey adoption has reached one million daily registrations globally—a 350% increase from 2024. This surge reflects both improved UX (users prefer biometrics over password typing) and enhanced security (passkeys are phishing-resistant by cryptographic design). Organizations implementing passwordless authentication increasingly choose WebAuthn as their foundational technology.
Browser Support and Platform Compatibility
Since all major browsers in 2025 support WebAuthn, developers can confidently integrate it without worrying about compatibility issues. Here's the current landscape:
- Chrome 90+: Full WebAuthn Level 2 support, including conditional UI and autofill integration
- Safari 14+: Native passkey support in iCloud Keychain with cross-device sync
- Firefox 60+: WebAuthn support with CTAP2 protocol for external authenticators
- Edge 90+: Windows Hello integration plus cross-device passkey support
Starting with Chrome 133 (January 2025), the
getClientCapabilities() WebAuthn API helps
developers determine which authentication features are
supported by a browser. By calling
PublicKeyCredential.getClientCapabilities(), you can retrieve a list of supported capabilities and
adapt authentication workflows accordingly:
// Feature detection for WebAuthn capabilities
if (window.PublicKeyCredential) {
PublicKeyCredential.getClientCapabilities()
.then(capabilities => {
console.log('Supported features:', capabilities);
// Example output:
// {
// conditionalCreate: true,
// conditionalGet: true,
// hybridTransport: true,
// userVerifyingPlatformAuthenticator: true
// }
if (capabilities.userVerifyingPlatformAuthenticator) {
// Device has built-in biometric authentication
enablePasskeyRegistration();
}
});
}
WebAuthn Registration Flow: Creating Passkeys
Passkey registration follows a precise protocol involving client-server coordination. The server generates a cryptographic challenge, the client (browser/device) creates a public-private key pair, and the public key is sent to the server for storage.
Server-Side: Generate Registration Options
Your backend must generate registration options
including a random challenge, user details, and relying
party information. Here's a Node.js example using the
@simplewebauthn/server library:
import { generateRegistrationOptions } from '@simplewebauthn/server';
// Backend endpoint: /auth/register/options
app.post('/auth/register/options', async (req, res) => {
const { userId, email, username } = req.body;
const options = await generateRegistrationOptions({
rpName: 'MyApp',
rpID: 'myapp.com', // Your domain
userID: userId,
userName: email,
userDisplayName: username,
// Challenge validity: 5 minutes
timeout: 300000,
// Require platform authenticator (device biometric)
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'required',
residentKey: 'required' // Discoverable credential
},
// Support ES256 and RS256 algorithms
supportedAlgorithmIDs: [-7, -257],
});
// Store challenge in session for verification
req.session.challenge = options.challenge;
res.json(options);
});
Client-Side: Create Credential
The browser's
navigator.credentials.create() API triggers
the platform authenticator (Touch ID, Face ID, Windows
Hello) to create a passkey:
// Frontend: Register passkey
async function registerPasskey(email, username) {
try {
// 1. Get registration options from server
const optionsRes = await fetch('/auth/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: crypto.randomUUID(),
email,
username
})
});
const options = await optionsRes.json();
// 2. Trigger platform authenticator
const credential = await navigator.credentials.create({
publicKey: {
...options,
challenge: Uint8Array.from(
atob(options.challenge), c => c.charCodeAt(0)
),
user: {
...options.user,
id: Uint8Array.from(
atob(options.user.id), c => c.charCodeAt(0)
)
}
}
});
// 3. Send public key to server
const verificationRes = await fetch('/auth/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credential: {
id: credential.id,
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))),
response: {
attestationObject: btoa(String.fromCharCode(...new Uint8Array(credential.response.attestationObject))),
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON)))
},
type: credential.type
}
})
});
const result = await verificationRes.json();
if (result.verified) {
console.log('Passkey registered successfully!');
return true;
}
} catch (error) {
console.error('Passkey registration failed:', error);
// Handle errors: user cancelled, no authenticator, etc.
return false;
}
}
Server-Side: Verify and Store Public Key
The backend logic must rigorously verify the client's response by checking the signature against the stored public key, the challenge, and the origin (RP ID):
import { verifyRegistrationResponse } from '@simplewebauthn/server';
app.post('/auth/register/verify', async (req, res) => {
const { credential } = req.body;
const expectedChallenge = req.session.challenge;
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge,
expectedOrigin: 'https://myapp.com',
expectedRPID: 'myapp.com',
});
if (verification.verified) {
// Store public key and credential ID in database
await db.savePasskey({
userId: req.session.userId,
credentialId: verification.registrationInfo.credentialID,
publicKey: verification.registrationInfo.credentialPublicKey,
counter: verification.registrationInfo.counter,
transports: credential.response.transports
});
res.json({ verified: true });
} else {
res.status(400).json({ error: 'Verification failed' });
}
});
This verification process ensures the passkey was created on an authentic device and associates it with the correct user account. Similar security-critical verification flows power systems like behavioral CAPTCHA authentication.
WebAuthn Authentication Flow: Using Passkeys
Authentication follows a similar challenge-response pattern but verifies the user possesses the private key corresponding to their registered public key.
Server-Side: Generate Authentication Challenge
import { generateAuthenticationOptions } from '@simplewebauthn/server';
app.post('/auth/login/options', async (req, res) => {
const { email } = req.body;
// Retrieve user's registered passkeys
const passkeys = await db.getPasskeysByEmail(email);
const options = await generateAuthenticationOptions({
rpID: 'myapp.com',
userVerification: 'required',
allowCredentials: passkeys.map(pk => ({
id: pk.credentialId,
type: 'public-key',
transports: pk.transports
}))
});
req.session.challenge = options.challenge;
res.json(options);
});
Client-Side: Authenticate with Passkey
async function authenticateWithPasskey(email) {
try {
// 1. Get authentication options
const optionsRes = await fetch('/auth/login/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
const options = await optionsRes.json();
// 2. Trigger device authentication
const assertion = await navigator.credentials.get({
publicKey: {
...options,
challenge: Uint8Array.from(
atob(options.challenge), c => c.charCodeAt(0)
)
}
});
// 3. Send signed assertion to server
const verifyRes = await fetch('/auth/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
assertion: {
id: assertion.id,
rawId: btoa(String.fromCharCode(...new Uint8Array(assertion.rawId))),
response: {
authenticatorData: btoa(String.fromCharCode(...new Uint8Array(assertion.response.authenticatorData))),
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(assertion.response.clientDataJSON))),
signature: btoa(String.fromCharCode(...new Uint8Array(assertion.response.signature))),
userHandle: assertion.response.userHandle ? btoa(String.fromCharCode(...new Uint8Array(assertion.response.userHandle))) : null
},
type: assertion.type
}
})
});
const result = await verifyRes.json();
if (result.verified) {
console.log('Authentication successful!');
window.location.href = '/dashboard';
}
} catch (error) {
console.error('Authentication failed:', error);
}
}
Server-Side: Verify Authentication Signature
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
app.post('/auth/login/verify', async (req, res) => {
const { assertion } = req.body;
const expectedChallenge = req.session.challenge;
// Retrieve stored passkey
const passkey = await db.getPasskeyByCredentialId(assertion.id);
const verification = await verifyAuthenticationResponse({
response: assertion,
expectedChallenge,
expectedOrigin: 'https://myapp.com',
expectedRPID: 'myapp.com',
authenticator: {
credentialID: passkey.credentialId,
credentialPublicKey: passkey.publicKey,
counter: passkey.counter
}
});
if (verification.verified) {
// Update counter to prevent replay attacks
await db.updatePasskeyCounter(
assertion.id,
verification.authenticationInfo.newCounter
);
// Create session
req.session.userId = passkey.userId;
res.json({ verified: true });
} else {
res.status(401).json({ error: 'Authentication failed' });
}
});
Advanced Implementation: Conditional UI and Autofill
Chrome 108+ and Safari 16+ support "conditional UI"—passkeys appear as autofill suggestions in username/email fields. This provides seamless UX where users tap their email field and see their passkey as an option alongside saved passwords.
// Enable passkey autofill
async function setupPasskeyAutofill() {
if (window.PublicKeyCredential &&
PublicKeyCredential.isConditionalMediationAvailable) {
const available = await PublicKeyCredential.isConditionalMediationAvailable();
if (available) {
// Trigger conditional mediation
const assertion = await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(32), // Placeholder
rpId: 'myapp.com',
userVerification: 'required'
},
mediation: 'conditional' // Key parameter
});
// Process assertion when user selects passkey
if (assertion) {
authenticateWithAssertion(assertion);
}
}
}
}
// Add autocomplete attribute to enable autofill UI
// HTML: <input type="email" autocomplete="username webauthn" />
This pattern works seamlessly with reward platform authentication systems where users expect quick, frictionless login experiences.
Security Best Practices for Production
Implementing WebAuthn securely requires attention to several critical details:
1. Validate Relying Party ID (RP ID)
The RP ID must match your domain. For
app.example.com, valid RP IDs are
app.example.com or
example.com (parent domain), but NOT
different.com. This prevents credential
theft via phishing sites—passkeys created for
bank.com will never work on
bank-login.scam.com.
2. Enforce User Verification
Always set userVerification: 'required' to
ensure biometric or PIN confirmation. This prevents
unauthorized access if someone steals a user's unlocked
device. The authentication satisfies "something you
have" (device) AND "something you are" (biometric)
factors.
3. Implement Counter Validation
Authenticators return a signature counter that increments with each use. If you receive a counter value lower than the stored value, it indicates a cloned authenticator—a potential security breach. Always verify and update counters:
if (receivedCounter > 0 && receivedCounter <= storedCounter) {
// Possible authenticator cloning detected
await securityAlert(userId, 'Suspicious authentication attempt');
throw new Error('Invalid authenticator counter');
}
// Update stored counter
await db.updateCounter(credentialId, receivedCounter);
4. Use Attestation for High-Security Applications
Attestation allows you to verify the authenticator's authenticity—confirming it's a genuine YubiKey, Apple Secure Enclave, etc. For most applications, attestation is optional, but regulated industries (banking, healthcare) should validate attestation statements during registration.
5. Handle Edge Cases Gracefully
Not all users will have compatible devices. Provide fallback authentication methods:
- Magic links for users without biometric devices
- SMS OTP as legacy fallback (though less secure)
- Recovery codes for account recovery scenarios
- Progressive enhancement: offer passkeys, don't require them
Multi-layered authentication strategies work best, similar to how collaborative tools handle varying user capabilities across devices.
Cross-Platform Passkey Syncing
One historical WebAuthn limitation was device-specific credentials—lose your phone, lose your passkey. Apple's iCloud Keychain, Google Password Manager, and Microsoft Authenticator now sync passkeys across devices using end-to-end encryption.
As a developer, you don't need to change implementation—the operating system handles syncing automatically. A user who registers a passkey on their iPhone can authenticate on their iPad or Mac without re-registering. This dramatically improves UX compared to hardware security keys that remain device-bound.
For maximum compatibility, support both platform authenticators (built-in biometrics) and cross-platform authenticators (USB security keys). Let users register multiple passkeys—one synced across their Apple devices, another on their YubiKey for high-security scenarios.
Testing and Debugging WebAuthn
WebAuthn testing requires HTTPS in production but works
on localhost (with http://)
during development. Chrome DevTools includes a WebAuthn
emulator:
- Open DevTools → Three-dot menu → More tools → WebAuthn
- Enable "Virtual Authenticator Environment"
- Add a virtual authenticator (choose platform or cross-platform)
- Test registration/authentication flows without physical devices
- Inspect created credentials and their properties
For automated testing, libraries like
@simplewebauthn/browser work with
Playwright and Puppeteer. Mock the
navigator.credentials API or use virtual
authenticators programmatically.
NIST Guidelines and Compliance
NIST is finalizing its Digital Identity Guidelines, SP 800-63-4, with the final version expected on July 31, 2025. This revision is significant as it formally recognizes passkeys (as 'syncable authenticators') and confirms they can achieve Authenticator Assurance Level 2 (AAL2)—the same level as hardware security keys.
For regulated industries, this means passkeys now satisfy compliance requirements previously reserved for physical tokens. Government agencies, healthcare providers, and financial institutions can deploy passkey-based authentication while maintaining regulatory compliance—eliminating the cost and complexity of distributing hardware authenticators.
Migration Strategy: From Passwords to Passkeys
Transitioning existing users requires a phased approach:
Phase 1: Optional Passkey Registration (Months 1-3)
- Offer passkeys as an optional enhancement during login
- Prompt users with compatible devices: "Upgrade to passkey for faster login?"
- Maintain password authentication as primary method
- Monitor adoption metrics: target 10-20% of active users
Phase 2: Encourage Passkey Adoption (Months 4-9)
- Incentivize passkey setup with UX benefits (skip 2FA prompts)
- Email campaigns highlighting security and convenience
- In-app notifications for users on compatible devices
- Target: 50%+ of active users with registered passkeys
Phase 3: Deprecate Passwords (Months 10-12)
- Set sunset date for password authentication
- Require passkey setup for new accounts
- Provide migration tools and support documentation
- Maintain password recovery for edge cases (lost device)
This gradual approach minimizes disruption while moving your user base toward passwordless authentication. Measure success through reduced support tickets (password resets), improved security metrics (fewer credential stuffing attacks), and user satisfaction scores.
Frequently Asked Questions
What happens if a user loses their device?
If using synced passkeys (iCloud Keychain, Google Password Manager), credentials are accessible on the user's other devices. For device-bound passkeys, implement account recovery flows: email verification links, backup passkeys registered during setup, or support team verification for high-value accounts. Always let users register multiple passkeys to prevent single-point-of-failure scenarios.
Can passkeys work on older browsers?
Browsers without WebAuthn support (Internet Explorer,
old Chrome/Safari versions) can't use passkeys.
Feature-detect with
if (window.PublicKeyCredential) and provide
fallback authentication methods. In 2025, less than 2%
of web traffic comes from non-WebAuthn browsers, so the
impact is minimal for most applications.
Are passkeys more secure than passwords + 2FA?
Yes. Passkeys are cryptographically phishing-resistant—they only work on the correct domain, preventing credential theft via fake login pages. SMS-based 2FA is vulnerable to SIM-swapping attacks. TOTP authenticators can be phished by real-time proxy attacks. Passkeys eliminate these attack vectors entirely because the private key never leaves the user's device.
How do I handle users with multiple devices?
Allow users to register multiple passkeys—one per device or one per platform ecosystem. Store all passkeys associated with a user account and let them authenticate with any registered credential. Provide a management interface where users can view registered passkeys, nickname them ("iPhone 15", "YubiKey"), and revoke lost/stolen devices.
What about accessibility concerns?
Biometric authentication may exclude users with disabilities affecting fingerprints or facial features. Always support PIN/password fallback on the device itself (Windows Hello allows PIN instead of face scan). Ensure your authentication UI is keyboard-navigable and screen-reader compatible. Test with assistive technologies before launch.
Conclusion: The Path to Passwordless
WebAuthn and passkeys represent the most significant evolution in web authentication since the invention of passwords. The technology is mature, standardized, and widely supported—2025 is the year to implement passkey authentication in production applications.
Start with registration flows for new users, progressively encourage existing users to upgrade, and maintain password fallbacks during the transition period. The security, UX, and cost benefits justify the implementation effort: eliminated password reset tickets, reduced fraud losses, and measurably improved user satisfaction.
The code examples in this guide provide production-ready starting points, but every application has unique requirements. Leverage libraries like SimpleWebAuthn for well-tested implementations, rigorously validate all server-side verification logic, and test across multiple browsers and devices before launch.
Passkeys aren't the future—they're the present. Organizations that adopt passwordless authentication now position themselves ahead of competitors still fighting password credential stuffing, phishing, and account takeover attacks. The era of passwords is ending. Build for what comes next.