· deepdives  · 8 min read

Demystifying WebAuthn: A Beginner's Guide to Secure Authentication

Learn what WebAuthn is, how it replaces passwords with public-key cryptography, and get a practical step-by-step tutorial to add passwordless (or multi-factor) authentication to a simple web app using the Web Authentication API and Node.js.

Learn what WebAuthn is, how it replaces passwords with public-key cryptography, and get a practical step-by-step tutorial to add passwordless (or multi-factor) authentication to a simple web app using the Web Authentication API and Node.js.

Outcome first: by the end of this guide you’ll know how WebAuthn replaces brittle passwords with phishing‑resistant public‑key authentication, why that matters, and how to implement a working register-and-login flow in a simple web app.

Why WebAuthn? What you can achieve

You can give your users a stronger, easier sign-in experience: passwordless logins using platform biometrics (Touch ID, Windows Hello), or hardware keys (YubiKey), with cryptographic guarantees that stop phishing and credential replay. Short version: fewer support calls, fewer breaches, and better UX.

What is WebAuthn (high level)

WebAuthn (the Web Authentication API) is a W3C standard used together with FIDO2. It defines how browsers, platforms, and authenticators (biometrics or security keys) create and use public/private key pairs to authenticate users to web services.

Key resources:

Core concepts (simple vocabulary)

  • Relying Party (RP): The website/service that wants to authenticate a user.
  • Client: The user’s browser (plus platform) that interacts with an authenticator.
  • Authenticator: The device that stores/uses a private key (platform authenticator like Windows Hello, or roaming like a YubiKey).
  • Credential (aka public key credential): A key pair created for the RP.
  • Attestation: Proof the credential was created by a particular authenticator (optional, used for device trust).
  • Assertion: The authentication operation - the authenticator signs a challenge to prove possession of the private key.

How WebAuthn works - two flows

  1. Registration (create credential)
  • RP generates a challenge and sends options to the client.
  • Client invokes navigator.credentials.create() with those options.
  • Authenticator generates a key pair and returns an attestation object and public key to the RP.
  • RP verifies attestation (optional) and stores the credential public key and ID.
  1. Authentication (get credential / login)
  • RP generates a challenge and a list of allowed credential IDs and sends options to the client.
  • Client calls navigator.credentials.get(). The authenticator signs the challenge with the private key and returns an assertion.
  • RP verifies the signature with the stored public key and checks counters/flags.

Advantages over traditional authentication

  • Phishing resistance: Private keys never leave the authenticator and signatures are bound to origin/RP.
  • No password reuse risk: Keys are site‑specific.
  • Stronger multi-factor: Can require user verification (biometrics) plus possession.
  • Better UX: Passwordless or fast biometric logins reduce friction.

Browser support and privacy

Modern browsers support WebAuthn (Chrome, Edge, Firefox, Safari). WebAuthn is designed with privacy in mind - credential IDs and attestation avoid leaking cross-site identifiers unless explicitly requested.

See compatibility details: https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API#browser_compatibility

Practical tutorial - build a simple WebAuthn register/login with Node.js

This tutorial shows a minimal, practical implementation. It focuses on the key steps: generating challenges, calling the WebAuthn API from the browser, and verifying on the server. It uses Node.js + Express and the fido2-lib library to perform verification.

Notes before you start

  • You must use HTTPS (or localhost for local testing).
  • Use real origins (https://) in production.
  • This example stores credentials in-memory for demo purposes only; use a database in production.
  1. Project setup
mkdir webauthn-demo
cd webauthn-demo
npm init -y
npm install express body-parser fido2-lib base64url
  1. Minimal server (index.js)
const express = require('express');
const bodyParser = require('body-parser');
const { Fido2Lib } = require('fido2-lib');
const base64url = require('base64url');

const app = express();
app.use(bodyParser.json());

// In-memory user store. Replace with DB in prod.
const users = new Map();

const f2l = new Fido2Lib({
  timeout: 60000,
  rpId: 'localhost', // change to your domain in production
  rpName: 'WebAuthn Demo',
  challengeSize: 64,
  attestation: 'none', // set to 'direct' if you want attestation verification
  authenticatorAttachment: 'cross-platform',
});

// Utility: make random challenge
function makeChallenge() {
  return base64url(Buffer.from(f2l.challenge()));
}

// Begin registration
app.post('/register/begin', async (req, res) => {
  const { username, displayName } = req.body;

  if (!username) return res.status(400).send('username required');

  let user = users.get(username);
  if (!user) {
    user = {
      id: base64url(Buffer.from(username)),
      username,
      displayName,
      credentials: [],
    };
    users.set(username, user);
  }

  const registrationOptions = await f2l.attestationOptions();
  registrationOptions.user = {
    id: user.id,
    name: user.username,
    displayName: user.displayName || user.username,
  };
  registrationOptions.challenge = makeChallenge();
  registrationOptions.pubKeyCredParams = [
    { type: 'public-key', alg: -7 }, // ES256
    { type: 'public-key', alg: -257 }, // RS256
  ];

  // Save challenge for verification later
  user.currentChallenge = registrationOptions.challenge;

  res.json(registrationOptions);
});

// Complete registration
app.post('/register/complete', async (req, res) => {
  const { username, attestationResponse } = req.body;
  const user = users.get(username);
  if (!user) return res.status(400).send('unknown user');

  try {
    const attestationExpectations = {
      challenge: user.currentChallenge,
      origin: 'https://localhost:3000', // change in production
      factor: 'either',
    };

    const attestationResult = await f2l.attestationResult(
      attestationResponse,
      attestationExpectations
    );

    // Store credential public key and id
    const cred = {
      credId: attestationResult.authnrData.get('credId'),
      publicKey: attestationResult.authnrData.get('credentialPublicKeyPem'),
      fmt: attestationResult.fmt,
      counter: attestationResult.authnrData.get('signCount'),
    };
    user.credentials.push(cred);

    res.json({ ok: true });
  } catch (e) {
    console.error(e);
    res.status(400).json({ error: e.toString() });
  }
});

// Begin login
app.post('/login/begin', async (req, res) => {
  const { username } = req.body;
  const user = users.get(username);
  if (!user) return res.status(400).send('unknown user');

  const assertionOptions = await f2l.assertionOptions();
  assertionOptions.challenge = makeChallenge();
  assertionOptions.allowCredentials = user.credentials.map(c => ({
    type: 'public-key',
    id: c.credId,
  }));

  user.currentChallenge = assertionOptions.challenge;

  res.json(assertionOptions);
});

// Complete login
app.post('/login/complete', async (req, res) => {
  const { username, assertionResponse } = req.body;
  const user = users.get(username);
  if (!user) return res.status(400).send('unknown user');

  try {
    const assertionExpectations = {
      challenge: user.currentChallenge,
      origin: 'https://localhost:3000', // change
      factor: 'either',
      publicKey: user.credentials[0].publicKey,
      prevCounter: user.credentials[0].counter,
      userHandle: null,
    };

    const authnResult = await f2l.assertionResult(
      assertionResponse,
      assertionExpectations
    );

    // Update counter
    user.credentials[0].counter = authnResult.authnrData.get('signCount');

    res.json({ authenticated: true });
  } catch (e) {
    console.error(e);
    res.status(400).json({ error: e.toString() });
  }
});

app.listen(4000, () => console.log('Server on port 4000'));
  1. Client-side (browser) snippets

Create two pages or a single page with buttons: Register and Login. Example JS using the WebAuthn API:

<!-- include a small helper for base64url conversion -->
<script>
  function toArrayBuffer(base64urlString) {
    // base64url -> ArrayBuffer
    const padding = '='.repeat((4 - (base64urlString.length % 4)) % 4);
    const base64 = (base64urlString + padding)
      .replace(/-/g, '+')
      .replace(/_/g, '/');
    const rawData = window.atob(base64);
    const out = new Uint8Array(rawData.length);
    for (let i = 0; i < rawData.length; ++i) out[i] = rawData.charCodeAt(i);
    return out.buffer;
  }

  function fromArrayBuffer(buffer) {
    const bytes = new Uint8Array(buffer);
    let str = '';
    for (let i = 0; i < bytes.byteLength; i++)
      str += String.fromCharCode(bytes[i]);
    const base64 = btoa(str)
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
    return base64;
  }

  async function register(username, displayName) {
    // 1) Get options from server
    const res = await fetch('/register/begin', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, displayName }),
    });
    const options = await res.json();

    options.challenge = toArrayBuffer(options.challenge);
    options.user.id = toArrayBuffer(options.user.id);

    // Convert any existing allowedCredentials id fields
    if (options.excludeCredentials) {
      options.excludeCredentials = options.excludeCredentials.map(c => ({
        ...c,
        id: toArrayBuffer(c.id),
      }));
    }

    // 2) Ask authenticator to create credential
    const cred = await navigator.credentials.create({ publicKey: options });

    // Prepare attestation to send to server
    const attestationResponse = {
      id: cred.id,
      rawId: fromArrayBuffer(cred.rawId),
      response: {
        clientDataJSON: fromArrayBuffer(cred.response.clientDataJSON),
        attestationObject: fromArrayBuffer(cred.response.attestationObject),
      },
    };

    // 3) Send attestation to server
    await fetch('/register/complete', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, attestationResponse }),
    });
  }

  async function login(username) {
    const res = await fetch('/login/begin', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username }),
    });
    const options = await res.json();

    options.challenge = toArrayBuffer(options.challenge);
    if (options.allowCredentials) {
      options.allowCredentials = options.allowCredentials.map(c => ({
        ...c,
        id: toArrayBuffer(c.id),
      }));
    }

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

    const assertionResponse = {
      id: assertion.id,
      rawId: fromArrayBuffer(assertion.rawId),
      response: {
        clientDataJSON: fromArrayBuffer(assertion.response.clientDataJSON),
        authenticatorData: fromArrayBuffer(
          assertion.response.authenticatorData
        ),
        signature: fromArrayBuffer(assertion.response.signature),
        userHandle: assertion.response.userHandle
          ? fromArrayBuffer(assertion.response.userHandle)
          : null,
      },
    };

    await fetch('/login/complete', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, assertionResponse }),
    });
  }
</script>
  1. Key server-side checks (what fido2-lib helps with)
  • Verify challenge matches stored challenge.
  • Verify origin and rpId match expected values.
  • Validate attestation object signature and public key (attestation verification).
  • Validate assertion signature using stored public key.
  • Check sign counter to mitigate cloned authenticators.
  1. Production considerations and best practices
  • Always use HTTPS.
  • Use a real RP ID and origin. RP ID is usually your domain.
  • Store credential public keys and sign counters in a secure DB.
  • Consider attestation policies carefully; attestation can reveal device vendor details and may have privacy implications.
  • Require user verification (UV) when appropriate (biometrics or PIN) and record the UV requirement in policy.
  • Handle authenticator migration (multiple credentials per user) and provide account recovery flows.
  • Keep the challenge short-lived and single-use.

Troubleshooting common issues

  • “NotAllowedError”: user cancelled the prompt, or authenticator wasn’t available. Try again, check attachments.
  • “InvalidStateError”: trying to register the same credential twice. Offer an option to re-register or use a different device.
  • Cross-origin failures: ensure correct origin and rpId and that you’re using HTTPS.

Extensions and real-world features

  • Resident keys (discoverable credentials) allow passwordless without entering a username.
  • Passkeys: vendor term for synced credentials using platform sync (Apple/Google/Microsoft). They are based on the same standards.
  • Hybrid flows: allow WebAuthn as second factor (2FA) by requiring a password first then WebAuthn assertion.

Additional reading and tools

Final takeaway

WebAuthn replaces shared secrets with cryptographic keys bound to a website’s origin and stored on authenticators. That means strong, phishing‑resistant authentication that scales from hardware keys to platform biometrics - a practical, standards-based step beyond passwords and a major step forward for web security.

Back to Blog

Related Posts

View All Posts »