← Applied Cryptography
Lab

Lab: WebAuthn Registration Flow

25 min JavaScript

Goal

Build a single HTML file that performs a WebAuthn registration ceremony using the browser’s navigator.credentials.create() API. You will generate a challenge, create a credential, and inspect the attestation response to see the public key and credential ID produced by your authenticator.

Setup

You need a modern browser (Chrome, Firefox, Safari, or Edge) and a way to serve the file over localhost. WebAuthn requires a secure context – either HTTPS or localhost.

The simplest way to serve the file locally:

# Python 3
python3 -m http.server 8080

Then open http://localhost:8080/webauthn-lab.html in your browser.

Create a file called webauthn-lab.html.

Step 1: Page Structure and Utility Functions

Start with the HTML skeleton and helper functions for encoding and decoding.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>WebAuthn Registration Lab</title>
  <style>
    body { font-family: monospace; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
    pre { background: #f4f4f4; padding: 1rem; overflow-x: auto; white-space: pre-wrap; }
    button { font-size: 1rem; padding: 0.5rem 1rem; cursor: pointer; margin: 0.5rem 0; }
    h2 { margin-top: 2rem; }
    .label { font-weight: bold; }
  </style>
</head>
<body>
  <h1>WebAuthn Registration Lab</h1>

  <button id="btn-register">Register a Credential</button>
  <div id="output"></div>

  <script>
    // Convert an ArrayBuffer to a hex string
    function bufToHex(buffer) {
      return Array.from(new Uint8Array(buffer))
        .map(function(b) { return b.toString(16).padStart(2, "0"); })
        .join("");
    }

    // Convert an ArrayBuffer to a Base64URL string
    function bufToBase64URL(buffer) {
      var bytes = new Uint8Array(buffer);
      var binary = "";
      for (var i = 0; i < bytes.length; i++) {
        binary += String.fromCharCode(bytes[i]);
      }
      return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
    }

    // Append a labeled section to the output div using safe DOM methods
    function appendSection(title, content) {
      var output = document.getElementById("output");
      var heading = document.createElement("h2");
      heading.textContent = title;
      var pre = document.createElement("pre");
      pre.textContent = content;
      output.appendChild(heading);
      output.appendChild(pre);
    }
  </script>
</body>
</html>

This gives you a button, an output area, and helper functions for encoding binary data. The appendSection function uses textContent to safely render output without HTML injection.

Step 2: Generate a Challenge and Define Credential Options

Add the registration function inside the <script> block, after the helper functions. In a real application, the challenge comes from the server. Here you generate it client-side to focus on the WebAuthn API itself.

    async function register() {
      var output = document.getElementById("output");
      output.textContent = "";

      // Step 2a: Generate a random challenge (32 bytes)
      // In production, this MUST come from the server
      var challenge = new Uint8Array(32);
      crypto.getRandomValues(challenge);

      appendSection("1. Challenge", bufToHex(challenge));

      // Step 2b: Define PublicKeyCredentialCreationOptions
      var createOptions = {
        publicKey: {
          rp: {
            name: "WebAuthn Lab",
            id: location.hostname
          },
          user: {
            id: new Uint8Array([1, 2, 3, 4]),  // arbitrary user ID
            name: "lab-user@example.com",
            displayName: "Lab User"
          },
          challenge: challenge,
          pubKeyCredParams: [
            { type: "public-key", alg: -7 },   // ES256 (ECDSA w/ SHA-256)
            { type: "public-key", alg: -257 }   // RS256 (RSASSA-PKCS1-v1_5 w/ SHA-256)
          ],
          timeout: 60000,
          authenticatorSelection: {
            authenticatorAttachment: "platform",  // use built-in authenticator
            residentKey: "preferred",
            userVerification: "preferred"
          },
          attestation: "none"  // skip attestation for this lab
        }
      };

      appendSection("2. Creation Options", JSON.stringify({
        rp: createOptions.publicKey.rp,
        user: {
          name: createOptions.publicKey.user.name,
          displayName: createOptions.publicKey.user.displayName,
          id: bufToHex(createOptions.publicKey.user.id)
        },
        pubKeyCredParams: createOptions.publicKey.pubKeyCredParams,
        authenticatorAttachment: createOptions.publicKey.authenticatorSelection.authenticatorAttachment,
        attestation: createOptions.publicKey.attestation
      }, null, 2));

The options specify the relying party (your localhost), a dummy user, the cryptographic algorithms you accept, and a preference for the platform authenticator (Touch ID, Windows Hello, etc.).

Step 3: Call the WebAuthn API and Inspect the Response

Continue inside the same register() function. This is where the browser shows the passkey dialog.

      // Step 3a: Create the credential
      var credential;
      try {
        credential = await navigator.credentials.create(createOptions);
      } catch (err) {
        appendSection("Error", "Registration failed: " + err.name + "\n" + err.message);
        return;
      }

      // Step 3b: Inspect the credential
      appendSection("3. Credential ID",
        "Base64URL: " + credential.id + "\n" +
        "Raw bytes (hex): " + bufToHex(credential.rawId) + "\n" +
        "Length: " + credential.rawId.byteLength + " bytes"
      );

      appendSection("4. Credential Type", credential.type);

When navigator.credentials.create() is called, the browser prompts you to verify your identity (fingerprint, face, PIN). After verification, the authenticator generates a new key pair and returns the public portion.

Step 4: Decode the Attestation Response

Continue inside register(). The attestation response contains the authenticator data and the attestation statement.

      // Step 4a: Attestation response metadata
      var response = credential.response;
      appendSection("5. Client Data JSON",
        new TextDecoder().decode(response.clientDataJSON)
      );

      // Step 4b: Parse the authenticator data
      var authData = new Uint8Array(response.getAuthenticatorData());
      var rpIdHash = authData.slice(0, 32);
      var flags = authData[32];
      var signCount = new DataView(authData.buffer, authData.byteOffset + 33, 4).getUint32(0);

      appendSection("6. Authenticator Data",
        "RP ID Hash: " + bufToHex(rpIdHash) + "\n" +
        "Flags byte: 0x" + flags.toString(16).padStart(2, "0") +
        " (binary: " + flags.toString(2).padStart(8, "0") + ")\n" +
        "  Bit 0 (UP - User Present):  " + ((flags & 0x01) ? "yes" : "no") + "\n" +
        "  Bit 2 (UV - User Verified): " + ((flags & 0x04) ? "yes" : "no") + "\n" +
        "  Bit 6 (AT - Attested Cred): " + ((flags & 0x40) ? "yes" : "no") + "\n" +
        "Sign count: " + signCount
      );

Step 5: Extract the Public Key

Finish the register() function by extracting the public key from the credential.

      // Step 5: Extract the public key
      var publicKey = response.getPublicKey();
      if (publicKey) {
        appendSection("7. Public Key (SPKI, hex)", bufToHex(publicKey));
        appendSection("8. Public Key Algorithm",
          "COSE algorithm: " + response.getPublicKeyAlgorithm() + "\n" +
          "(-7 = ES256, -257 = RS256)"
        );
      }

      // Summary
      appendSection("9. What Happened",
        "1. Browser generated a random challenge\n" +
        "2. navigator.credentials.create() triggered the authenticator\n" +
        "3. You verified your identity (biometric/PIN)\n" +
        "4. Authenticator generated a new key pair\n" +
        "5. Private key stayed on the authenticator\n" +
        "6. Public key + credential ID returned to this page\n\n" +
        "In production, the server would store the public key and\n" +
        "credential ID. Future logins use navigator.credentials.get()\n" +
        "to sign a challenge with the private key."
      );
    }

    document.getElementById("btn-register").addEventListener("click", register);

Step 6: Run the Lab

Save the file and serve it:

python3 -m http.server 8080

Open http://localhost:8080/webauthn-lab.html in your browser and click “Register a Credential”. Your browser will prompt for biometric verification or a PIN.

After successful registration, the page displays:

1. Challenge
   (32 random bytes as hex)

2. Creation Options
   { rp, user, pubKeyCredParams, attestation }

3. Credential ID
   Base64URL: (authenticator-generated ID)
   Raw bytes (hex): (same ID in hex)
   Length: 20 bytes (varies by authenticator)

4. Credential Type
   public-key

5. Client Data JSON
   {"type":"webauthn.create","challenge":"...","origin":"http://localhost:8080","crossOrigin":false}

6. Authenticator Data
   RP ID Hash: (SHA-256 of "localhost")
   Flags byte: 0x45 (binary: 01000101)
     Bit 0 (UP - User Present):  yes
     Bit 2 (UV - User Verified): yes
     Bit 6 (AT - Attested Cred): yes
   Sign count: 0

7. Public Key (SPKI, hex)
   (DER-encoded SubjectPublicKeyInfo)

8. Public Key Algorithm
   COSE algorithm: -7
   (-7 = ES256, -257 = RS256)

9. What Happened
   (step-by-step summary of the registration flow)

Note the Client Data JSON: the browser automatically includes the origin (http://localhost:8080). This is the origin binding that makes WebAuthn phishing-resistant – the signature covers the origin, so a credential created for one site cannot be used on another.

Things to Try

Summary

This lab demonstrated the WebAuthn registration flow using only browser APIs: