← 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: