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
Registration ceremony
This is where the user binds an authenticator to your service. Six steps:
- Server: generate a random
challenge(16+ bytes) and store it pending. - Server: send
PublicKeyCredentialCreationOptionsto the browser, including the user’s ID, the relying party (origin), the supported algorithms (-7= ES256 is mandatory), and the challenge. - Browser: invokes
navigator.credentials.create({ publicKey }). - 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.
- Browser: returns the
AuthenticatorAttestationResponseto your server. - Server: verify the attestation, store
credentialId+publicKeyagainst 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
| Pitfall | What we learned |
|---|---|
| RP ID mismatch | The 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 regression | Some 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 checks | Always pin expectedOrigin. We’ve seen reverse-proxy setups where it silently shifted to https://internal-lb.local. |
| User verification flag | userVerification: 'required' excludes plain U2F devices. Use 'preferred' unless you really need biometric / PIN gating. |
| Challenge timing | Tie the challenge to the user session, not a global pool. Otherwise you have a replay window. |
Related reading
- Why use Multi-factor Authentication?
- Top 3 Benefits of MFA
- Product: AmbiSecure OnePass Card
- Service: FIDO Validation Server
- Tool: APDU parser — useful when you start poking at smart-card-side FIDO traffic.