← Back to DevBytes

Implementing Web Authentication API in Modern Web Applications

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:

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:

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

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

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles