Understanding the Web Authentication API (WebAuthn)
The Web Authentication API, often shortened to WebAuthn, is a browser-based credential management API that enables websites to authenticate users using public-key cryptography instead of traditional passwords. It was developed by the World Wide Web Consortium (W3C) in partnership with the FIDO Alliance and is now a core component of modern secure authentication systems. The API exposes two primary methods: navigator.credentials.create() for registering a new credential and navigator.credentials.get() for authenticating with an existing one. Under the hood, it relies on an asymmetric key pair where the private key never leaves the user’s authenticator (such as a security key, biometric sensor, or platform authenticator like Touch ID or Windows Hello), while the public key is stored on the server. This shift eliminates the risk of server-side password database breaches and phishing attacks that steal reusable credentials.
Why WebAuthn Matters for Modern Web Applications
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Password-based authentication has long been the weakest link in application security. Users reuse passwords across services, fall victim to phishing, and often choose weak secrets. Even when hashed and salted, a leaked password database can be cracked offline. WebAuthn addresses these issues fundamentally:
- Phishing resistance – Credentials are bound to the specific domain (relying party ID), so a credential created for
example.comwill not work on a lookalike domain. - No shared secrets – The server only stores a public key. Even if the server is compromised, the attacker cannot impersonate the user because the private key remains secure inside the authenticator.
- User experience – Authentication can be as simple as a fingerprint scan, face recognition, or a tap on a security key, reducing friction and eliminating the need to remember or rotate passwords.
- Compliance and trust – Implementing WebAuthn helps meet regulatory requirements for strong authentication (such as PSD2, GDPR considerations, and various cybersecurity frameworks) while boosting user confidence.
By adopting WebAuthn, developers can provide a passwordless login experience or strengthen multi-factor authentication (MFA) flows without relying on SMS codes or one-time passwords that are also susceptible to phishing.
How WebAuthn Works: Core Concepts
Before diving into code, it’s essential to understand the key actors and data structures involved in a WebAuthn ceremony.
Relying Party (RP)
The web application that wants to authenticate the user. It must have a unique identifier (relying party ID), which is typically the domain name (e.g., example.com) or a subset of the domain (e.g., auth.example.com). The RP generates challenges, stores public keys, and verifies authentication assertions.
Authenticator
The hardware or software entity that creates and stores the credential key pair. Examples include:
- Platform authenticators – Built into the device (Touch ID on macOS/iOS, Windows Hello, Android biometrics).
- Cross-platform authenticators – Removable security keys like YubiKey, Feitian, or Titan Security Key that connect via USB, NFC, or BLE.
PublicKeyCredential
The object returned by the browser’s credential operations. It contains the credential ID, the raw attestation or assertion data, and client extension outputs.
Challenge
A random, server-generated byte array that must be signed by the authenticator and verified by the RP to prevent replay attacks. Each registration or authentication ceremony requires a fresh, unpredictable challenge.
Attestation vs. Assertion
- Attestation – During registration, the authenticator provides a certificate-like statement that proves it is a genuine, trusted device. The RP can choose to verify this or skip it (e.g., for privacy).
- Assertion – During authentication, the authenticator signs a challenge and some context data with the private key, proving possession.
Implementing Registration: Creating a New Credential
Registration is the process where a user establishes a new WebAuthn credential for your application. The flow involves the server generating a challenge and configuration options, the browser calling navigator.credentials.create(), and the server verifying the resulting attestation object.
Client-Side Registration Flow
The server must first provide a JSON object containing the public key creation options. The client decodes the challenge (which is base64url-encoded) and passes it to the API. The example below assumes the server endpoint /api/register/options returns the required parameters.
// Fetch registration options from the server
const optionsResponse = await fetch('/api/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'alice@example.com' })
});
const publicKeyCredentialCreationOptions = await optionsResponse.json();
// Decode the challenge from base64url to ArrayBuffer
publicKeyCredentialCreationOptions.challenge = base64urlToBuffer(
publicKeyCredentialCreationOptions.challenge
);
// Convert user.id to ArrayBuffer if needed (same base64url treatment)
publicKeyCredentialCreationOptions.user.id = base64urlToBuffer(
publicKeyCredentialCreationOptions.user.id
);
try {
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions
});
// credential is a PublicKeyCredential object
const registrationData = {
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
response: {
attestationObject: bufferToBase64url(credential.response.attestationObject),
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON)
},
type: credential.type
};
// Send registration data to the server for verification
await fetch('/api/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registrationData)
});
console.log('Registration successful!');
} catch (error) {
console.error('Registration failed:', error);
}
// Helper functions to convert between base64url and ArrayBuffer
function base64urlToBuffer(baseurlString) {
const base64 = baseurlString.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 binary = '';
bytes.forEach(b => binary += String.fromCharCode(b));
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
Server-Side Registration Verification (Node.js Example)
The server must decode and verify the attestation object and client data JSON. This involves checking the challenge, origin, and optionally the attestation statement. The example below uses the cbor and crypto libraries along with a helper to parse the authenticator data. For production, consider using a dedicated WebAuthn server library like webauthn-node.
const crypto = require('crypto');
const cbor = require('cbor');
// Assume 'registrationData' comes from the client, and 'expectedChallenge' is stored in session
async function verifyRegistration(registrationData, expectedChallenge) {
const { id, rawId, response } = registrationData;
const attestationObject = base64urlToBuffer(response.attestationObject);
const clientDataJSON = base64urlToBuffer(response.clientDataJSON);
// Decode clientDataJSON
const clientData = JSON.parse(Buffer.from(clientDataJSON).toString('utf8'));
// 1. Verify challenge matches
if (clientData.challenge !== bufferToBase64url(expectedChallenge)) {
throw new Error('Challenge mismatch');
}
// 2. Verify origin (must match the relying party's origin)
if (clientData.origin !== 'https://example.com') {
throw new Error('Origin mismatch');
}
// Decode attestationObject (CBOR encoded)
const attestationStruct = await cbor.decodeFirst(attestationObject);
const authData = attestationStruct.authData;
// Parse authenticator data (first 37 bytes are fixed)
const rpIdHash = authData.slice(0, 32);
const flags = authData[32];
const signCount = authData.slice(33, 37); // 4 bytes
// Verify RP ID hash
const expectedRpIdHash = crypto.createHash('sha256')
.update('example.com')
.digest();
if (!rpIdHash.equals(expectedRpIdHash)) {
throw new Error('RP ID hash mismatch');
}
// Extract credential ID and public key (after fixed header, depending on flags)
// This simplified example assumes no extensions and a single credential
let ptr = 37;
// AAGUID (16 bytes) if present (flag AT)
if (flags & 0x40) { // AT flag: attested credential data included
const aaguid = authData.slice(ptr, ptr + 16);
ptr += 16;
const credIdLength = authData.readUInt16BE(ptr);
ptr += 2;
const credentialId = authData.slice(ptr, ptr + credIdLength);
ptr += credIdLength;
const cosePublicKey = authData.slice(ptr);
// Decode COSE key (CBOR map)
const publicKeyMap = await cbor.decodeFirst(cosePublicKey);
// Convert to PEM or JWK format for storage
const publicKey = coseToPublicKey(publicKeyMap); // implement based on algorithm
}
// Store credential ID, public key, signCount and user association
// e.g., save to database
console.log('Registration verified successfully');
return { credentialId: id, publicKey };
}
// Base64url utility for Node
function base64urlToBuffer(str) {
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
return Buffer.from(base64, 'base64');
}
function bufferToBase64url(buf) {
return buf.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
Note: A production-ready implementation should handle attestation verification (checking certificate chains), support multiple credential algorithms (ES256, RS256), and properly store user identifiers. Libraries like @simplewebauthn/server greatly simplify this process.
Implementing Authentication: Using an Existing Credential
Authentication (login) follows a similar pattern: the server issues a challenge, the client calls navigator.credentials.get(), and the server verifies the signed assertion.
Client-Side Authentication Flow
// Fetch authentication options from the server
const authOptionsResponse = await fetch('/api/auth/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'alice@example.com' })
});
const publicKeyCredentialRequestOptions = await authOptionsResponse.json();
// Decode the challenge
publicKeyCredentialRequestOptions.challenge = base64urlToBuffer(
publicKeyCredentialRequestOptions.challenge
);
// Allow credentials that were previously registered
if (publicKeyCredentialRequestOptions.allowCredentials) {
for (let cred of publicKeyCredentialRequestOptions.allowCredentials) {
cred.id = base64urlToBuffer(cred.id);
}
}
try {
const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions
});
const authData = {
id: assertion.id,
rawId: bufferToBase64url(assertion.rawId),
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
},
type: assertion.type
};
// Send authentication data to server for verification
const result = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authData)
});
if (result.ok) {
console.log('Authentication successful!');
}
} catch (error) {
console.error('Authentication failed:', error);
}
Server-Side Authentication Verification
The server must validate the client data JSON, authenticator data, and signature using the stored public key. It also checks that the authenticator’s signature counter has increased (to detect cloned authenticators, if supported).
async function verifyAuthentication(authData, expectedChallenge, storedCredential) {
const clientDataJSON = Buffer.from(base64urlToBuffer(authData.response.clientDataJSON));
const clientData = JSON.parse(clientDataJSON.toString('utf8'));
// 1. Verify challenge
if (clientData.challenge !== bufferToBase64url(expectedChallenge)) {
throw new Error('Challenge mismatch');
}
// 2. Verify origin
if (clientData.origin !== 'https://example.com') {
throw new Error('Origin mismatch');
}
const authenticatorData = base64urlToBuffer(authData.response.authenticatorData);
// Parse authenticator data
const rpIdHash = authenticatorData.slice(0, 32);
const flags = authenticatorData[32];
const signCount = authenticatorData.readUInt32BE(33); // 4 bytes starting at index 33
// Verify RP ID hash
const expectedRpIdHash = crypto.createHash('sha256')
.update('example.com')
.digest();
if (!rpIdHash.equals(expectedRpIdHash)) {
throw new Error('RP ID hash mismatch');
}
// Verify user presence (UP flag)
if (!(flags & 0x01)) {
throw new Error('User presence not indicated');
}
// Check signature counter (if supported)
if (signCount !== 0 && signCount <= storedCredential.signCount) {
throw new Error('Signature counter did not increase');
}
// Construct the data to be signed
const clientDataHash = crypto.createHash('sha256')
.update(clientDataJSON)
.digest();
const signedData = Buffer.concat([
authenticatorData,
clientDataHash
]);
const signature = base64urlToBuffer(authData.response.signature);
// Verify signature using the stored public key
const publicKey = storedCredential.publicKey; // previously imported
const verify = crypto.createVerify('SHA256');
verify.update(signedData);
verify.end();
const isValid = verify.verify(publicKey, signature);
if (!isValid) {
throw new Error('Signature verification failed');
}
// Update stored signCount
storedCredential.signCount = signCount;
console.log('Authentication verified successfully');
return true;
}
Best Practices for WebAuthn Implementation
- Always generate challenges server-side – Never let the client create or influence the challenge. Use a cryptographically secure random number generator with at least 16 bytes of entropy.
- Validate origin and RP ID rigorously – The client data JSON contains the origin; check it against an allowlist. The RP ID hash in authenticator data must match the SHA-256 of your relying party ID.
- Use user verification appropriately – The
userVerificationoption (required,preferred, ordiscouraged) lets you enforce biometric or PIN checks. For sensitive transactions, always require user verification. - Handle timeout and transport selection gracefully – Set
timeoutin the options to a reasonable value (e.g., 60 seconds) to avoid indefinite waiting. Provide fallback UI when authenticators are unavailable. - Support multiple credential algorithms – While ES256 (EC P-256) is most common, RS256 may be encountered. Parse COSE key structures accordingly and store the algorithm alongside the credential.
- Decide on attestation conveyance – Use
nonefor privacy if you don’t need to verify the authenticator model, ordirect/indirectif you need to enforce an allowlist of trusted authenticators. - Store credential IDs securely – Associate each credential with the user account and device metadata. A user may register multiple authenticators (e.g., a security key and platform biometric).
- Implement proper error handling – The API throws exceptions for user cancellation, timeout, or invalid state. Distinguish between
NotAllowedError,NotSupportedError, and others to provide helpful guidance. - Use established server libraries – For production, leverage mature packages like
@simplewebauthn/server,webauthn, orfido2-libto avoid subtle cryptographic pitfalls. - Gradually migrate to passwordless – WebAuthn can be used as a second factor alongside passwords first, and later moved to primary authentication once user adoption grows.
Conclusion
The Web Authentication API represents a fundamental improvement in how we secure user accounts on the web. By replacing shared secrets with public-key credentials, it eliminates the most common attack vectors while simultaneously improving the login experience. Implementing it involves understanding the two-step ceremony (registration and authentication), handling binary data conversions between the browser and server, and performing rigorous server-side verification of challenges, origins, and signatures. While the cryptographic details can be intricate, modern server libraries and the built-in browser API make it accessible to any development team. Start by adding WebAuthn as a multi-factor option, follow the best practices outlined above, and move toward a future where passwords become optional. Your users and your security posture will thank you.