WebAuthn and Passkeys: Complete Developer Guide 2025

WebAuthn and Passkeys: Complete Developer Guide 2025

WebAuthn represents the W3C standard that's eliminating passwords from the web. This comprehensive developer guide covers passkey implementation from registration through authentication, with production-ready code examples and best practices for 2025 deployment. Learn how to integrate FIDO2 authentication across browsers, platforms, and devices—with full backward compatibility.

Alice Test
Alice Test
November 26, 2025 · 8 min read

Understanding WebAuthn and Passkeys

Try MagicAuth

Experience the technology discussed in this article.

Learn More →

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:

  1. Open DevTools → Three-dot menu → More tools → WebAuthn
  2. Enable "Virtual Authenticator Environment"
  3. Add a virtual authenticator (choose platform or cross-platform)
  4. Test registration/authentication flows without physical devices
  5. 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.

MagicAuth Blog
MagicAuth Blog

Insights on passwordless authentication

More from this blog →

Responses

No responses yet. Be the first to share your thoughts!