Ambimat GroupAmbimatAmbiSecureeSIM InitiativeEngineering BlogAhmedabad · India · Est. 1981

Implementing FIDO2 Authentication: A Complete Developer Guide

Passwords are one of the biggest security risks for modern systems. FIDO2 is what comes next — phishing-resistant, hardware-bound credentials, deployable across browsers, mobile, and smart cards. Here is the practical end-to-end walkthrough we wish we’d had on day one.

This is a long post. If you read only one section, read Registration ceremony — the rest follows.

Why FIDO2 (in fifteen seconds)

FIDO2 replaces a shared secret (a password, an OTP seed) with a public-key challenge. Each origin gets its own key pair, generated and stored inside the authenticator (a USB key, smart card, or platform TPM). The server never holds anything that, if leaked, lets an attacker log in. Phishing pages cannot reuse a credential, because the browser binds it to the origin.

The moving parts

FIDO2 is two specs working together:

  • WebAuthn — the W3C JavaScript API exposed to the browser. navigator.credentials.create() and .get().
  • CTAP2 — the Client-To-Authenticator Protocol the browser uses to talk to the actual hardware authenticator over USB-HID, NFC, or BLE.

On your server, you implement a relying party (RP) that issues challenges and verifies signatures. AmbiSecure ships a hardened FIDO Validation Server that does exactly that, but the protocol is open — you can roll your own.

The trust chain

Authenticator (USB key, smart card, TPM)PRIVATE KEY
CTAP2 over USB-HID / NFC / BLETRANSPORT
Browser / platform (WebAuthn)CLIENT
Your application + Relying Party serverVERIFY

Registration ceremony

This is where the user binds an authenticator to your service. Six steps:

  1. Server: generate a random challenge (16+ bytes) and store it pending.
  2. Server: send PublicKeyCredentialCreationOptions to the browser, including the user’s ID, the relying party (origin), the supported algorithms (-7 = ES256 is mandatory), and the challenge.
  3. Browser: invokes navigator.credentials.create({ publicKey }).
  4. Authenticator: generates a fresh key pair scoped to your origin, stores the private key, returns a credential ID, the public key, and an attestation proving it came from a real authenticator.
  5. Browser: returns the AuthenticatorAttestationResponse to your server.
  6. Server: verify the attestation, store credentialId + publicKey against the user.

Server-side: the registration challenge

// Express + @simplewebauthn/server, Node 20+
const { generateRegistrationOptions } = require('@simplewebauthn/server');

app.post('/auth/register/begin', async (req, res) => {
  const user = await users.findOrCreate(req.body.email);
  const opts = await generateRegistrationOptions({
    rpName: 'AmbiSecure Demo',
    rpID: 'auth.example.com',
    userID: user.id,
    userName: user.email,
    timeout: 60_000,
    attestationType: 'direct',
    excludeCredentials: user.credentials.map(c => ({
      id: c.id, type: 'public-key', transports: c.transports,
    })),
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'preferred',
    },
    supportedAlgorithmIDs: [-7, -257],   // ES256, RS256
  });
  await sessions.set(user.id, { challenge: opts.challenge });
  res.json(opts);
});

Browser-side

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

const opts = await fetch('/auth/register/begin', {...}).then(r => r.json());
const att  = await startRegistration(opts);
await fetch('/auth/register/finish', {
  method: 'POST', body: JSON.stringify(att),
  headers: { 'content-type': 'application/json' }
});

Server-side: verification

const { verifyRegistrationResponse } = require('@simplewebauthn/server');

app.post('/auth/register/finish', async (req, res) => {
  const user = req.user;
  const { challenge } = await sessions.get(user.id);
  const result = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge: challenge,
    expectedOrigin: 'https://app.example.com',
    expectedRPID: 'auth.example.com',
    requireUserVerification: false,
  });
  if (!result.verified) return res.status(400).end();
  const { credentialID, credentialPublicKey, counter } = result.registrationInfo;
  await user.credentials.add({ id: credentialID, publicKey: credentialPublicKey, counter });
  res.sendStatus(204);
});

Authentication ceremony

Same shape, different verb. Server generates a challenge, browser hands it to the authenticator, the authenticator signs it with the private key, the server verifies with the stored public key.

const { generateAuthenticationOptions, verifyAuthenticationResponse }
  = require('@simplewebauthn/server');

app.post('/auth/login/begin', async (req, res) => {
  const user = await users.findByEmail(req.body.email);
  const opts = await generateAuthenticationOptions({
    rpID: 'auth.example.com',
    timeout: 60_000,
    userVerification: 'preferred',
    allowCredentials: user.credentials.map(c => ({
      id: c.id, type: 'public-key', transports: c.transports,
    })),
  });
  await sessions.set(user.id, { challenge: opts.challenge });
  res.json(opts);
});

A word on attestation

Attestation is the authenticator’s way of proving it is what it says it is — signed by the manufacturer’s root key. Most consumer apps don’t need it (use attestationType: 'none'). Enterprise apps that require certified hardware (e.g., AAGUID-allowlisted FIDO Certified L1+ devices) verify the attestation against the FIDO Metadata Service (MDS).

Resident vs. non-resident credentials

A non-resident credential lives only on the server (encoded in the credential ID handed back to the authenticator on each login). A resident (discoverable) credential lives on the authenticator and enables the smoothest UX: you don’t even type a username. The OnePass Card stores up to 25 resident credentials.

Five pitfalls we have walked into

PitfallWhat we learned
RP ID mismatchThe RP ID must be a registrable suffix of the origin. app.example.com can use example.com as RP ID; the reverse won’t work.
Counter regressionSome authenticators (especially smart cards) don’t implement a sign counter. Don’t reject auths just because the counter didn’t increment — check the AAGUID first.
Origin checksAlways pin expectedOrigin. We’ve seen reverse-proxy setups where it silently shifted to https://internal-lb.local.
User verification flaguserVerification: 'required' excludes plain U2F devices. Use 'preferred' unless you really need biometric / PIN gating.
Challenge timingTie the challenge to the user session, not a global pool. Otherwise you have a replay window.

Need a hardware authenticator?

The OnePass Card and OnePass USB Key are FIDO2-certified devices we’ve shipped to enterprise customers. Pilot batches of 100 cards in 6–8 weeks.

View OnePass Card