WebAuthn and JavaScript Passwordless Authentication

WebAuthn (Web Authentication API) is a modern web standard that enables secure, passwordless authentication using asymmetric public-key cryptography. This article explains what WebAuthn is, how it replaces shared secrets like passwords with cryptographic key pairs, and how developers can implement registration and login flows in browser-based JavaScript using the navigator.credentials interface.


What is WebAuthn?

WebAuthn is a core component of the FIDO2 project, standardized by the W3C and the FIDO Alliance. It allows web applications to authenticate users via built-in platform authenticators (such as Apple Touch ID/Face ID, Windows Hello, and Android biometrics) or roaming authenticators (such as USB, NFC, or Bluetooth security keys like YubiKeys).

Instead of sending a password over the network, WebAuthn uses asymmetric public-key cryptography: * Private Key: Generated and securely stored on the user’s local authenticator hardware. It never leaves the device. * Public Key: Sent to the relying party (the web server) during registration and stored in the database alongside the user’s account.

This design eliminates the risk of credential stuffing, database password leaks, and phishing attacks, as the authenticator binds credentials directly to the origin (domain) of the website.


How WebAuthn Works

WebAuthn operates through two primary processes: Registration (Credential Creation) and Authentication (Assertion).

  1. Registration: The server creates a cryptographic challenge. The client requests the authenticator to generate a new key pair. The authenticator creates the keys and signs the challenge with the private key. The browser sends the public key and signed challenge back to the server for verification and storage.
  2. Authentication: The server issues a new challenge. The client asks the authenticator to sign this challenge with the stored private key. The server validates the signature against the previously stored public key.

Implementing WebAuthn with JavaScript

JavaScript interacts with the underlying authenticator hardware using the navigator.credentials API.

1. User Registration (navigator.credentials.create)

To register a new credential, the server first provides a set of creation options (including a unique challenge and user details). The client-side JavaScript converts binary data (such as user IDs and challenges) into ArrayBuffer formats before invoking the API.

async function registerUser(serverCreationOptions) {
  // Decode base64url strings from the server into Uint8Arrays/ArrayBuffers
  const publicKeyCredentialCreationOptions = {
    challenge: Uint8Array.from(atob(serverCreationOptions.challenge), c => c.charCodeAt(0)),
    rp: {
      name: "Example App",
      id: window.location.hostname,
    },
    user: {
      id: Uint8Array.from(serverCreationOptions.userId, c => c.charCodeAt(0)),
      name: "alex@example.com",
      displayName: "Alex Doe",
    },
    pubKeyCredParams: [
      { alg: -7, type: "public-key" }, // ES256
      { alg: -257, type: "public-key" } // RS256
    ],
    authenticatorSelection: {
      authenticatorAttachment: "platform", // e.g., TouchID, Windows Hello
      userVerification: "preferred",
    },
    timeout: 60000,
    attestation: "none"
  };

  try {
    const credential = await navigator.credentials.create({
      publicKey: publicKeyCredentialCreationOptions
    });

    // Send the credential back to the server to store the public key
    await sendRegistrationToServer(credential);
  } catch (err) {
    console.error("Registration failed:", err);
  }
}

2. User Authentication (navigator.credentials.get)

When a user logs in, the server generates an authentication challenge. The browser requests an assertion from the authenticator using navigator.credentials.get().

async function authenticateUser(serverRequestOptions) {
  // Decode base64url challenge and allowed credential IDs
  const publicKeyCredentialRequestOptions = {
    challenge: Uint8Array.from(atob(serverRequestOptions.challenge), c => c.charCodeAt(0)),
    allowCredentials: serverRequestOptions.allowCredentials.map(cred => ({
      id: Uint8Array.from(atob(cred.id), c => c.charCodeAt(0)),
      type: 'public-key',
    })),
    timeout: 60000,
    userVerification: "preferred"
  };

  try {
    const assertion = await navigator.credentials.get({
      publicKey: publicKeyCredentialRequestOptions
    });

    // Send the assertion (signature and client data) to the server for verification
    await sendAssertionToServer(assertion);
  } catch (err) {
    console.error("Authentication failed:", err);
  }
}

Core Security Benefits