← Back to DevBytes

Web Authentication API: Complete Guide

Introduction to the Web Authentication API

The Web Authentication API, commonly known as WebAuthn, is a web standard published by the W3C and FIDO Alliance that enables passwordless authentication using public-key cryptography. It is exposed in browsers through the navigator.credentials interface and allows web applications to register and authenticate users with authenticators such as security keys, platform biometrics (Touch ID, Face ID, Windows Hello), or passkeys stored in a user's device.

Unlike traditional password-based authentication, WebAuthn does not transmit any shared secret over the network. Instead, each registered credential is bound to a specific origin (domain), and the private key never leaves the authenticator. This makes WebAuthn inherently resistant to phishing, credential stuffing, and man-in-the-middle attacks.

Why WebAuthn Matters

Core Concepts

Before writing code, it is important to understand the key actors and data structures involved in WebAuthn.

The Three Parties

Key Data Structures

WebAuthn revolves around two main operations: registration (creating a credential) and authentication (verifying a credential). Each operation involves the browser producing an assertion or attestation object that the server must verify.

The most important fields you will encounter are:

Registration Flow

Registration, also called "ceremony of creation," creates a new public-key credential on the user's authenticator. The flow has two halves: the browser call and the server verification.

Step 1: Server Generates Options

The server must generate a random challenge and assemble the public key creation options. The challenge must be stored in the user's session so it can be verified later.

// server.js (Node.js example)
import crypto from 'node:crypto';

export function generateRegistrationOptions(user) {
  const challenge = crypto.randomBytes(32);

  // Store challenge in session for later verification
  user.pendingChallenge = challenge;

  return {
    challenge: bufferToBase64url(challenge),
    rp: {
      name: 'My Secure App',
      id: 'example.com'
    },
    user: {
      id: bufferToBase64url(user.id),
      name: user.email,
      displayName: user.displayName
    },
    pubKeyCredParams: [
      { type: 'public-key', alg: -7 },   // ES256
      { type: 'public-key', alg: -257 }  // RS256
    ],
    authenticatorSelection: {
      authenticatorAttachment: 'platform',
      userVerification: 'required',
      residentKey: 'preferred'
    },
    timeout: 60000,
    attestation: 'none'
  };
}

function bufferToBase64url(buf) {
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

Step 2: Browser Creates the Credential

On the client side, the application calls navigator.credentials.create() with the options received from the server. The browser handles the user interaction (biometric prompt, security key tap, etc.).

// client.js
async function registerUser() {
  // Fetch options from the server
  const res = await fetch('/webauthn/register/options', {
    method: 'POST',
    credentials: 'include'
  });
  const options = await res.json();

  // Convert base64url strings back to ArrayBuffers
  const publicKey = {
    ...options,
    challenge: base64urlToBuffer(options.challenge),
    user: {
      ...options.user,
      id: base64urlToBuffer(options.user.id)
    },
    excludeCredentials: (options.excludeCredentials || []).map(c => ({
      ...c,
      id: base64urlToBuffer(c.id)
    }))
  };

  const credential = await navigator.credentials.create({ publicKey });

  // Send the credential to the server for verification
  const verificationRes = await fetch('/webauthn/register/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include',
    body: JSON.stringify({
      id: credential.id,
      rawId: bufferToBase64url(credential.rawId),
      type: credential.type,
      response: {
        attestationObject: bufferToBase64url(credential.response.attestationObject),
        clientDataJSON: bufferToBase64url(credential.response.clientDataJSON)
      }
    })
  });

  const result = await verificationRes.json();
  console.log('Registration result:', result);
}

function base64urlToBuffer(base64url) {
  const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
  const pad = '='.repeat((4 - base64.length % 4) % 4);
  const binary = atob(base64 + pad);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes.buffer;
}

function bufferToBase64url(buffer) {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

Step 3: Server Verifies the Credential

The server must rigorously verify the attestation response. This includes checking the challenge, origin, RP ID, and parsing the authenticator data. In production, use a library such as @simplewebauthn/server or fido2-lib rather than implementing verification from scratch.

// server.js
import { verifyRegistrationResponse } from '@simplewebauthn/server';

export async function verifyRegistration(req, res) {
  const user = req.session.user;
  const expectedChallenge = user.pendingChallenge;
  const expectedOrigin = 'https://example.com';
  const expectedRPID = 'example.com';

  try {
    const verification = await verifyRegistrationResponse({
      response: req.body,
      expectedChallenge: bufferToBase64url(expectedChallenge),
      expectedOrigin,
      expectedRPID,
      requireUserVerification: true
    });

    if (verification.verified && verification.registrationInfo) {
      const { credential } = verification.registrationInfo;
      // Store the credential public key, id, and counter in the database
      user.credentials = user.credentials || [];
      user.credentials.push({
        id: credential.id,
        publicKey: credential.publicKey,
        counter: credential.counter
      });
      user.pendingChallenge = null;
      res.json({ verified: true });
    } else {
      res.status(400).json({ error: 'Verification failed' });
    }
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
}

Authentication Flow

Authentication, also called the "assertion ceremony," proves possession of a previously registered credential. The server sends a fresh challenge and a list of allowed credential IDs; the browser asks the authenticator to sign the challenge with the matching private key.

Step 1: Server Generates Assertion Options

// server.js
export function generateAuthenticationOptions(user) {
  const challenge = crypto.randomBytes(32);
  user.pendingChallenge = challenge;

  return {
    challenge: bufferToBase64url(challenge),
    rpId: 'example.com',
    allowCredentials: user.credentials.map(c => ({
      type: 'public-key',
      id: c.id
    })),
    userVerification: 'required',
    timeout: 60000
  };
}

Step 2: Browser Requests the Assertion

// client.js
async function authenticateUser() {
  const res = await fetch('/webauthn/auth/options', {
    method: 'POST',
    credentials: 'include'
  });
  const options = await res.json();

  const publicKey = {
    ...options,
    challenge: base64urlToBuffer(options.challenge),
    allowCredentials: (options.allowCredentials || []).map(c => ({
      type: c.type,
      id: base64urlToBuffer(c.id)
    }))
  };

  const assertion = await navigator.credentials.get({ publicKey });

  const verifyRes = await fetch('/webauthn/auth/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include',
    body: JSON.stringify({
      id: assertion.id,
      rawId: bufferToBase64url(assertion.rawId),
      type: assertion.type,
      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
      }
    })
  });

  const result = await verifyRes.json();
  if (result.verified) {
    console.log('User authenticated successfully');
  }
}

Step 3: Server Verifies the Assertion

// server.js
import { verifyAuthenticationResponse } from '@simplewebauthn/server';

export async function verifyAuthentication(req, res) {
  const user = req.session.user;
  const expectedChallenge = user.pendingChallenge;
  const expectedOrigin = 'https://example.com';
  const expectedRPID = 'example.com';

  const credential = user.credentials.find(c => c.id === req.body.id);
  if (!credential) {
    return res.status(400).json({ error: 'Unknown credential' });
  }

  try {
    const verification = await verifyAuthenticationResponse({
      response: req.body,
      expectedChallenge: bufferToBase64url(expectedChallenge),
      expectedOrigin,
      expectedRPID,
      credential: {
        id: credential.id,
        publicKey: credential.publicKey,
        counter: credential.counter
      },
      requireUserVerification: true
    });

    if (verification.verified) {
      // Update the stored counter to detect cloned authenticators
      credential.counter = verification.authenticationInfo.newCounter;
      user.pendingChallenge = null;
      // Establish the authenticated session
      req.session.authenticated = true;
      res.json({ verified: true });
    } else {
      res.status(400).json({ error: 'Authentication failed' });
    }
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
}

Feature Detection and Fallbacks

Not every browser or device supports WebAuthn, so always perform feature detection before invoking the API. Provide a graceful fallback to a password or one-time code flow when WebAuthn is unavailable.

function isWebAuthnSupported() {
  return typeof window.PublicKeyCredential !== 'undefined';
}

async function isConditionalUISupported() {
  if (!isWebAuthnSupported()) return false;
  // Conditional UI lets users autofill credentials in a password field
  if (typeof window.PublicKeyCredential.isConditionalMediationAvailable !== 'function') {
    return false;
  }
  return await window.PublicKeyCredential.isConditionalMediationAvailable();
}

if (isWebAuthnSupported()) {
  showPasskeyButton();
} else {
  showPasswordForm();
}

Best Practices

Always Require User Verification

Set userVerification: 'required' in both registration and authentication options. This ensures the authenticator performs a local biometric or PIN check, protecting against stolen devices being used to sign in.

Use Sufficiently Long Challenges

Challenges should be at least 16 bytes of cryptographically random data; 32 bytes is recommended. Never reuse a challenge, and always invalidate it after a single use.

Validate Origin and RP ID Strictly

The server must verify that the origin in the client data JSON matches the expected origin exactly, including scheme and port. The RP ID must be a registrable domain suffix of the origin.

Store the Signature Counter

Authenticators maintain a monotonically increasing counter. After each successful authentication, store the new counter value. If a future authentication reports a counter lower than or equal to the stored value, the credential may have been cloned and should be revoked.

Offer Passkeys for Cross-Device Sync

Set residentKey: 'preferred' and authenticatorAttachment: 'platform' to encourage creation of passkeys that sync across a user's devices via iCloud Keychain or Google Password Manager. This dramatically improves the recovery story compared to traditional security keys.

Never Roll Your Own Crypto Verification

WebAuthn verification involves CBOR decoding, signature validation, and attestation parsing. Use well-audited libraries such as @simplewebauthn/server, fido2-lib, or @github/webauthn-json for client-side encoding helpers.

Provide Account Recovery Paths

Even with passkeys, users may lose access to all their devices. Always provide a recovery mechanism such as backup codes, email-based reauthentication, or additional registered security keys.

Conclusion

The Web Authentication API represents a fundamental shift away from passwords toward cryptographic, phishing-resistant credentials. By understanding the registration and authentication ceremonies, validating responses correctly on the server, and following best practices around user verification, challenge handling, and counter tracking, you can build a sign-in experience that is both more secure and more convenient than traditional password flows. As passkey support continues to expand across operating systems and browsers, WebAuthn is rapidly becoming the default foundation for modern web authentication, and integrating it today positions your application for a safer, passwordless future.

— Ad —

Google AdSense will appear here after approval

← Back to all articles