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.
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.
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.
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.).
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.
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
);
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);
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.
rp.id to something other than location.hostname and observe the error. The browser enforces that the RP ID matches the page’s origin.file:///... instead of via localhost. WebAuthn will refuse to run outside a secure context.This lab demonstrated the WebAuthn registration flow using only browser APIs: