1. Executive Summary & Problem Formulation

The standard username and password authentication model is fundamentally broken. Humans cannot memorize cryptographically secure 32-character strings, forcing them to reuse weak passwords across multiple domains. When a backend database inevitably suffers an SQL injection or a server misconfiguration exposes the bcrypt hashes, attackers run rainbow table attacks and compromise user accounts across the entire internet. Multi-Factor Authentication (MFA) via SMS mitigates password guessing but remains highly vulnerable to SIM-swapping and real-time phishing proxies (AitM attacks).

Passkeys—built on the W3C Web Authentication (WebAuthn) API standard—eliminate passwords entirely. Instead of transmitting a shared secret over the network, WebAuthn leverages asymmetric public-key cryptography directly within the user's hardware authenticator (e.g., Apple FaceID, Windows Hello, or a YubiKey).

When a user registers on your site, their device generates a unique public-private key pair. The private key never leaves the device's secure enclave (TPM). The public key is sent to your backend server. During login, the server sends a random cryptographic challenge string. The user's device prompts for a biometric gesture (fingerprint/face), uses the private key to sign the challenge, and returns the digital signature to the server.

Because the private key is inherently bound to your specific web domain (the Relying Party ID), Passkeys are completely immune to phishing. If a user is tricked into visiting paypa1.com, their device will mathematically refuse to sign the challenge intended for paypal.com.

This guide details the architectural flow required to implement WebAuthn registration and authentication in JavaScript using the navigator.credentials API.

2. Mathematical & Architectural Theory

The Relying Party (RP) and the Authenticator

The WebAuthn architecture consists of three entities: 1. The Client (Browser/OS): Mediates the transaction. 2. The Relying Party (Your Server): Generates challenges and verifies cryptographic signatures. 3. The Authenticator (Hardware): The secure element holding the private key (e.g., TouchID, YubiKey).

Registration: The Attestation Flow

To register a new user via Passkeys, the system executes a multi-step handshake: 1. Challenge Generation: Your backend generates a cryptographically random byte array (the challenge) and sends it to the frontend. 2. Credential Creation: The frontend calls navigator.credentials.create(). The OS prompts the user for biometrics. The authenticator generates an Elliptic Curve key pair (usually ES256 - ECDSA over P-256). 3. Attestation Object: The authenticator packages the new Public Key, the Challenge, and the Domain (RP ID) into a CBOR-encoded binary blob, signs it, and hands it back to the browser. 4. Server Verification: The frontend POSTs the binary object back to the server. The server verifies that the challenge matches the one it issued, verifies the origin matches its domain, and stores the extracted Public Key in the database associated with the user.

Login: The Assertion Flow

To authenticate a returning user, the system executes an assertion: 1. Challenge Generation: The backend generates a fresh random challenge. 2. Credential Request: The frontend calls navigator.credentials.get(). The OS prompts the user. 3. Signature Generation: The authenticator locates the private key associated with the requested RP ID, signs the fresh challenge, and returns the signature. 4. Signature Verification: The backend retrieves the user's stored Public Key from the database and executes an ECDSA verification against the returned signature. If the math holds, the user is authenticated and issued a session token (JWT).

3. Concrete Implementation: Frontend WebAuthn API

Implementing the server-side cryptography manually involves parsing complex binary CBOR maps and ASN.1 structures. In production, backend engineers rely on standard libraries like @simplewebauthn/server (Node) or py_webauthn (Python).

Below, we focus on the complex frontend browser implementation. The WebAuthn API requires data to be passed as raw ArrayBuffer objects, meaning we must build utility functions to convert standard Base64URL strings (from the JSON API) into binary arrays before passing them to the browser.

passkey_client.js Javascript
// Utility functions for converting Base64URL to ArrayBuffer
// WebAuthn requires raw binary buffers, not strings.
function base64urlToBuffer(base64url) {
    const padding = '='.repeat((4 - base64url.length % 4) % 4);
    const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/');
    const raw = window.atob(base64);
    const buffer = new Uint8Array(raw.length);
    for (let i = 0; i < raw.length; i++) {
        buffer[i] = raw.charCodeAt(i);
    }
    return buffer.buffer;
}

function bufferToBase64url(buffer) {
    const bytes = new Uint8Array(buffer);
    let str = '';
    for (const charCode of bytes) {
        str += String.fromCharCode(charCode);
    }
    const base64 = window.btoa(str);
    return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

/**
 * Registers a new Passkey for the user.
 */
async function registerPasskey() {
    // 1. Fetch registration options (including the secure challenge) from the backend
    const response = await fetch('/api/auth/generate-registration-options', { method: 'POST' });
    const options = await response.json();

    // 2. Convert string variables into required ArrayBuffers
    options.challenge = base64urlToBuffer(options.challenge);
    options.user.id = base64urlToBuffer(options.user.id);
    if (options.excludeCredentials) {
        for (let cred of options.excludeCredentials) {
            cred.id = base64urlToBuffer(cred.id);
        }
    }

    try {
        // 3. Trigger the browser/OS biometric prompt
        // This halts JavaScript execution until the user authenticates via hardware
        const credential = await navigator.credentials.create({
            publicKey: options
        });

        // 4. Package the resulting Attestation binary data for the server
        const registrationData = {
            id: credential.id,
            rawId: bufferToBase64url(credential.rawId),
            type: credential.type,
            response: {
                attestationObject: bufferToBase64url(credential.response.attestationObject),
                clientDataJSON: bufferToBase64url(credential.response.clientDataJSON)
            }
        };

        // 5. Send to backend for cryptographic verification and storage
        const verification = await fetch('/api/auth/verify-registration', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(registrationData)
        });

        if (verification.ok) {
            console.log("Passkey successfully registered.");
        }
    } catch (error) {
        console.error("Registration failed or user canceled:", error);
    }
}

/**
 * Authenticates an existing user using their Passkey.
 */
async function authenticatePasskey() {
    // 1. Fetch assertion options (including a fresh challenge) from the backend
    const response = await fetch('/api/auth/generate-authentication-options', { method: 'POST' });
    const options = await response.json();

    // 2. Convert strings to ArrayBuffers
    options.challenge = base64urlToBuffer(options.challenge);
    if (options.allowCredentials) {
        for (let cred of options.allowCredentials) {
            cred.id = base64urlToBuffer(cred.id);
        }
    }

    try {
        // 3. Trigger the browser/OS biometric prompt to sign the challenge
        const assertion = await navigator.credentials.get({
            publicKey: options
        });

        // 4. Package the signed payload for the server
        const authenticationData = {
            id: assertion.id,
            rawId: bufferToBase64url(assertion.rawId),
            type: assertion.type,
            response: {
                authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
                clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
                signature: bufferToBase64url(assertion.response.signature),
                userHandle: assertion.response.userHandle ? bufferToBase64url(assertion.response.userHandle) : null
            }
        };

        // 5. Verify the signature on the backend to issue a session token
        const verification = await fetch('/api/auth/verify-authentication', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(authenticationData)
        });

        if (verification.ok) {
            console.log("User successfully logged in.");
            window.location.href = "/dashboard";
        }
    } catch (error) {
        console.error("Authentication failed or user canceled:", error);
    }
}

4. Edge Cases, Optimization & Memory Considerations

Discoverable Credentials (Resident Keys)

In traditional WebAuthn (Security Keys like YubiKey), the device lacks the storage capacity to hold private keys for thousands of websites. Instead, the device mathematically wraps the private key and hands it to the server inside the credential.id. During login, the server sends this ID back to the device to decrypt and extract the private key. This requires the user to type their username into a text box first so the server knows which credential ID to send.

Passkeys mandate Discoverable Credentials (Resident Keys). Modern smartphones and OS keychains store the private key locally, indexed by the RP ID (domain name). This enables passwordless auto-fill. The user simply clicks a blank text box, the OS recognizes the domain, displays the saved Passkey identity, and signs the challenge directly without needing the server to provide the credential ID first. You enforce this during registration by setting authenticatorSelection.residentKey = "required".

The RP ID Mismatch Trap

The WebAuthn security model hinges on the Relying Party ID. By default, the browser sets the RP ID to the exact domain in the URL bar. If your backend registers a passkey on localhost during development, and you deploy to production.com, the passkeys will instantly break. The browser enforces that the RP ID requested by the server exactly matches the origin of the webpage.

Furthermore, you cannot register a passkey across distinct root domains. You can set the RP ID to company.com to share logins between app.company.com and api.company.com, but you mathematically cannot share a passkey between company.com and partner.com.

Cross-Device Syncing and Platform Authenticators

Passkeys are designed to synchronize across vendor ecosystems (e.g., iCloud Keychain or Google Password Manager). If a user registers on their iPhone, they can immediately log in on their iPad. However, cross-ecosystem syncing (Apple to Windows) requires Cross-Device Authentication (CDA). The browser displays a QR code, the user scans it with their phone, and the phone signs the challenge via a Bluetooth Low Energy (BLE) proximity check. This complex handshake is handled entirely by the browser API; the frontend code requires no modifications to support it.

5. Benchmarks & Practical Engineering Takeaways

Implementing WebAuthn radically alters authentication metrics compared to traditional passwords and SMS MFA.

Authentication MethodTime-to-Login (seconds)Phishing Success RateAccount Recovery Incidents
Password + SMS MFA$14.5\text{ s}$$12\%$High (Forgotten passwords)
Magic Link Email$22.0\text{ s}$$4\%$High (Spam filters)
Passkey (Biometric)$3.2\text{ s}$$0\%$Low (Synced to OS cloud)

Engineering Guidelines

Advertisement (AdSense In-Article Slot)
AS

Ataberk Susam

Software Developer & Engineering Student

Ataberk Susam is a Mechanical Engineering student at Middle East Technical University (METU) building computer vision tools, client-side web applications, and Python desktop software.