Implementing WebAuthn (FIDO2) in TypeScript

WebAuthn is the only widely deployed authenticator that breaks real-time phishing proxies, because every assertion is cryptographically bound to the origin that requested it — a look-alike domain simply cannot produce a valid signature. This guide implements both ceremonies end to end in TypeScript using @simplewebauthn/server on the backend and the browser’s native navigator.credentials API on the front end. It is a companion to the parent MFA Enforcement Patterns guide and sits within Secure Authentication & Session Architecture. Because passkeys move the trust boundary onto the client device, the front-end code that initiates each ceremony must itself be free of injection — review Cross-Site Scripting (XSS) mitigation before shipping, since a script injected into your login page can drive the WebAuthn API on the user’s behalf.

Prerequisites

  • Node.js 18+ and TypeScript 5+ with an existing session mechanism
  • @simplewebauthn/server v10+ installed on the backend; @simplewebauthn/browser optional for the client
  • HTTPS in every environment (WebAuthn requires a secure context) and a fixed, planned rpID
  • A datastore for per-user credential records (credential ID, public key, counter, transports)

Expected Outcomes

  • A working registration ceremony that stores a verified public-key credential
  • A working authentication ceremony that verifies an assertion and advances the counter
  • Each successful assertion bound to a rotated session carrying an MFA claim
  • expectedOrigin and expectedRPID enforced on every verification call

Step 1: Run the Registration Ceremony

Registration proves the user possesses an authenticator and hands the server a public key to store. The server generates options containing a fresh challenge, the browser calls navigator.credentials.create(), and the server verifies the returned attestation before persisting the credential.

The sequence below shows the round trip.

WebAuthn Registration and Authentication Sequence Sequence diagram: the server issues a challenge, the browser invokes the authenticator to create or assert a credential, and the server verifies the response against the expected origin and rpID before storing or accepting it. Browser Server (RP) Authenticator 1. request options 2. challenge + rpID 3. create() / get() — user verification 4. attestation / assertion (origin-bound signature) 5. POST response Verify signature origin + rpID + counter, then store

Generate the registration options, persisting the challenge and the user’s existing credential IDs so the authenticator can exclude duplicates.

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from '@simplewebauthn/server';
import type { RegistrationResponseJSON } from '@simplewebauthn/server';

const rpID = 'example.com';                 // registrable domain, NOT the full URL
const rpName = 'Example App';
const expectedOrigin = 'https://app.example.com';

export async function beginRegistration(userId: string) {
  const user = await users.get(userId);
  const existing = await credentials.listByUser(userId);

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userName: user.email,
    attestationType: 'none',                // no attestation statement to validate
    excludeCredentials: existing.map((c) => ({
      id: c.credentialId,
      transports: c.transports,
    })),
    authenticatorSelection: {
      residentKey: 'required',              // discoverable passkey
      userVerification: 'required',         // PIN / biometric — makes it a true 2FA
    },
  });

  await challenges.save(userId, options.challenge); // single-use, short TTL
  return options;
}

Verify the browser’s response against the stored challenge, the expected origin, and the rpID, then persist the credential.

export async function finishRegistration(userId: string, body: RegistrationResponseJSON) {
  const expectedChallenge = await challenges.take(userId); // consume once

  const verification = await verifyRegistrationResponse({
    response: body,
    expectedChallenge,
    expectedOrigin,
    expectedRPID: rpID,
    requireUserVerification: true,
  });

  if (!verification.verified || !verification.registrationInfo) {
    throw new Error('Registration verification failed');
  }

  const { credential } = verification.registrationInfo;
  await credentials.insert({
    userId,
    credentialId: credential.id,            // base64url
    publicKey: credential.publicKey,        // stored for signature verification
    counter: credential.counter,            // initial signature counter
    transports: body.response.transports ?? [],
  });

  return { registered: true };
}

Step 2: Run the Authentication Ceremony

Authentication proves possession of a previously registered credential. The server issues an assertion challenge, the browser calls navigator.credentials.get(), and the server verifies the signature with the stored public key and enforces the counter.

import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type { AuthenticationResponseJSON } from '@simplewebauthn/server';

export async function beginAuthentication(userId: string) {
  const creds = await credentials.listByUser(userId);

  const options = await generateAuthenticationOptions({
    rpID,
    userVerification: 'required',
    allowCredentials: creds.map((c) => ({
      id: c.credentialId,
      transports: c.transports,
    })),
  });

  await challenges.save(userId, options.challenge);
  return options;
}

export async function finishAuthentication(userId: string, body: AuthenticationResponseJSON) {
  const expectedChallenge = await challenges.take(userId);
  const cred = await credentials.getById(body.id);
  if (!cred || cred.userId !== userId) throw new Error('Unknown credential');

  const verification = await verifyAuthenticationResponse({
    response: body,
    expectedChallenge,
    expectedOrigin,
    expectedRPID: rpID,
    requireUserVerification: true,
    credential: {
      id: cred.credentialId,
      publicKey: cred.publicKey,
      counter: cred.counter,
      transports: cred.transports,
    },
  });

  if (!verification.verified) throw new Error('Assertion verification failed');

  // Counter regression check: reject clones.
  const { newCounter } = verification.authenticationInfo;
  if (newCounter !== 0 && newCounter <= cred.counter) {
    throw new Error('Signature counter regression — possible cloned authenticator');
  }
  await credentials.updateCounter(cred.credentialId, newCounter);

  return { authenticated: true };
}

On the client, both ceremonies reduce to a single call. Convert the server options to the browser format (or let @simplewebauthn/browser do it) and post the result back:

import { startAuthentication } from '@simplewebauthn/browser';

async function login(userId: string) {
  const optionsJSON = await fetch('/webauthn/auth/begin', {
    method: 'POST', body: JSON.stringify({ userId }),
  }).then((r) => r.json());

  const assertion = await startAuthentication({ optionsJSON }); // invokes navigator.credentials.get

  return fetch('/webauthn/auth/finish', {
    method: 'POST', body: JSON.stringify({ userId, ...assertion }),
  }).then((r) => r.json());
}

Step 3: Bind the Assertion to the Session and Enforce Origin/rpID

A verified assertion is only useful if it upgrades the session. On success, rotate the session identifier and stamp an MFA claim recording the method and assurance level, exactly as the parent enforcement model expects.

export async function completeStepUp(session: Session, userId: string, body: AuthenticationResponseJSON) {
  await finishAuthentication(userId, body); // throws on any failure

  await session.regenerate();               // rotate ID to prevent fixation
  session.mfa = {
    method: 'webauthn',
    aal: 'aal3',                            // phishing-resistant, hardware-backed
    verifiedAt: Math.floor(Date.now() / 1000),
  };
  await session.save();
}

The single most important control is that expectedOrigin and expectedRPID are non-negotiable constants, never derived from the request. If you read the origin from the incoming Origin header, an attacker’s proxy supplies its own value and the phishing resistance collapses. Pin both to configuration:

// Correct: constants from trusted config.
const expectedOrigin = process.env.WEBAUTHN_ORIGIN!; // "https://app.example.com"
const expectedRPID = process.env.WEBAUTHN_RPID!;     // "example.com"

// If you support multiple front-end origins, pass an allowlist array —
// never echo req.headers.origin into the verifier.
const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];

Verification

Confirm both ceremonies work and the origin binding holds:

# Registration returns options containing a challenge and rp.id
curl -sX POST https://app.example.com/webauthn/register/begin \
  -H 'content-type: application/json' -d '{"userId":"u_123"}' | jq '.challenge, .rp.id'

# Run the unit suite for verification and counter logic
npm test -- --testPathPattern=webauthn

# Negative test: a response verified against the wrong origin must fail
npm test -- --testNamePattern="rejects wrong origin"

Expected: the begin endpoint returns a non-empty challenge and an rp.id of example.com; the verification tests pass; and the wrong-origin negative test confirms verifyAuthenticationResponse throws when expectedOrigin does not match — the property that defeats a phishing proxy.

import { describe, it, expect } from 'vitest';

describe('WebAuthn origin binding', () => {
  it('rejects an assertion verified against the wrong origin', async () => {
    await expect(
      finishAuthentication('u_123', assertionCapturedByProxy)
    ).rejects.toThrow(/verification failed/i);
  });
});

Troubleshooting

Symptom Likely cause Fix
Unexpected authentication response origin expectedOrigin does not match the scheme-host-port the browser used Set expectedOrigin to the exact front-end URL, including https:// and any non-default port; use an allowlist for multiple hosts
Unexpected RP ID hash rpID is set to a full URL or a host that is not a registrable suffix of the origin Use the bare registrable domain (example.com), which must be a suffix of the origin host
Signature counter regression error on every login Authenticator legitimately reports a static counter, or the stored counter was not updated Allow newCounter === 0 as an accepted exception; ensure updateCounter persists after each success
verifyRegistrationResponse fails with attestation error Requested attestationType: 'direct' without validating the certificate chain Use attestationType: 'none' unless a compliance regime requires attestation, then validate the chain
Login prompt skips PIN/biometric userVerification left at 'preferred', so the factor may be single-factor Set userVerification: 'required' on both options and verification to guarantee true two-factor

Common Implementation Mistakes


Frequently Asked Questions

What is the difference between rpID and origin in WebAuthn?

The rpID is the domain a credential is scoped to — for example example.com — and it must be a registrable-domain suffix of the site’s origin. The origin is the full scheme-host-port that made the request, such as https://app.example.com. Verification pins both: the authenticator hashes the rpID into the signed authenticator data, and the server compares the full origin against an allowlist. A phishing proxy hosted on a different origin cannot produce a signature that satisfies both checks, which is the mechanism behind WebAuthn’s phishing resistance.

Do I need attestation for a WebAuthn passkey login?

For most consumer applications, no. Request attestationType: 'none' to avoid handling privacy-sensitive attestation statements and to maximise compatibility across authenticators. Direct attestation is only worth the added complexity when a compliance regime requires you to verify the authenticator’s make and model, and in that case you must validate the attestation certificate chain against trusted roots rather than merely accepting the statement.

Why does the signature counter matter?

The counter is a value the authenticator increments each time it signs an assertion. On each login you compare the new counter against the stored one: if the new value is less than or equal to the stored value, the authenticator may have been cloned and you fail closed. Some authenticators — many platform passkeys included — always report zero, which is an accepted exception you record explicitly. The check costs nothing and catches a whole class of credential-cloning attacks that would otherwise be invisible.