DEV Community

Deniss Semjonovs
Deniss Semjonovs

Posted on • Originally published at blog.magicauth.app

WebAuthn & Passkeys Developer Guide 2025

WebAuthn and Passkeys: Complete Developer Guide 2025 | MagicAuth
Blog

    - 

        @import url("https://fonts.googleapis.com/css2?family=Charter:wght@400;700&family=Inter:wght@300;400;500;600;700&display=swap");

        body {
            font-family: "Charter", "Georgia", serif;
        }

        .font-sans {
            font-family: "Inter", sans-serif;
        }

        /* Medium-style article typography */
        .article-content {
            font-size: 21px;
            line-height: 1.58;
            letter-spacing: -0.003em;
            color: #242424;
        }

        .article-content h1 {
            font-size: 2.5em;
            line-height: 1.2;
            margin: 1.5em 0 0.5em;
            font-weight: 700;
        }

        .article-content h2 {
            font-size: 2em;
            line-height: 1.3;
            margin: 1.5em 0 0.5em;
            font-weight: 700;
        }

        .article-content h3 {
            font-size: 1.5em;
            line-height: 1.4;
            margin: 1.5em 0 0.5em;
            font-weight: 700;
        }

        .article-content p {
            margin: 1.5em 0;
        }

        .article-content a {
            color: inherit;
            text-decoration: underline;
        }

        .article-content blockquote {
            border-left: 3px solid #242424;
            padding-left: 1.5em;
            margin: 1.5em 0;
            font-style: italic;
        }

        .article-content pre {
            background: #f4f4f4;
            padding: 1em;
            border-radius: 4px;
            overflow-x: auto;
            font-family: "Courier New", monospace;
            font-size: 0.85em;
            line-height: 1.5;
        }

        .article-content code {
            background: #f4f4f4;
            padding: 0.2em 0.4em;
            border-radius: 3px;
            font-family: "Courier New", monospace;
            font-size: 0.85em;
        }

        .article-content img {
            max-width: 100%;
            height: auto;
            margin: 2em 0;
        }

        .article-content ul,
        .article-content ol {
            margin: 1.5em 0;
            padding-left: 2em;
        }

        .article-content li {
            margin: 0.5em 0;
        }

        .article-content strong {
            font-weight: 700;
        }

        .article-content em {
            font-style: italic;
        }
Enter fullscreen mode Exit fullscreen mode

{
"@context": "https://schema.org",
"@type": "Article",
"headline": "WebAuthn and Passkeys: Complete Developer Guide 2025",
"description": "Technical guide to implementing WebAuthn and passkeys with production-ready code examples.",
"image": "https://images.unsplash.com/photo-1614064641938-3bbee52942c7?w=800",
"author": {
"@type": "Organization",
"name": "MagicAuth",
"url": "https://magicauth.app"
},
"publisher": {
"@type": "Organization",
"name": "MagicAuth",
"logo": {
"@type": "ImageObject",
"url": "https://magicauth.app/logo.png"
}
},
"datePublished": "2025-11-26",
"dateModified": "2025-12-02",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://blog.magicauth.app/articles/webauthn-passkeys-developer-guide-2025.html"
}
}

**
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

// 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();
        }
    });
Enter fullscreen mode Exit fullscreen mode

}`

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.
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

`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);
Enter fullscreen mode Exit fullscreen mode

});`

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:
Enter fullscreen mode Exit fullscreen mode

`// 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;
}
Enter fullscreen mode Exit fullscreen mode

}`

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):
Enter fullscreen mode Exit fullscreen mode

`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' });
}
Enter fullscreen mode Exit fullscreen mode

});`

                    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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

});`

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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' });
    }
});
Enter fullscreen mode Exit fullscreen mode
                    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.
Enter fullscreen mode Exit fullscreen mode

`// 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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

}

// 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.
Enter fullscreen mode Exit fullscreen mode

Security Best Practices for Production

                    Implementing WebAuthn securely requires attention to
                    several critical details:
Enter fullscreen mode Exit fullscreen mode

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`.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

`if (receivedCounter > 0 && receivedCounter


Originally published at blog.magicauth.app

Top comments (0)