· 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.

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:
- WebAuthn on MDN: https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API
- FIDO Alliance overview: https://fidoalliance.org/
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
- 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.
- 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.
- Project setup
mkdir webauthn-demo
cd webauthn-demo
npm init -y
npm install express body-parser fido2-lib base64url- 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'));- 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>- 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.
- 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
- WebAuthn explainer and spec: https://www.w3.org/TR/webauthn/
- FIDO Alliance docs: https://fidoalliance.org/
- Yubico guides and examples: https://developers.yubico.com/
- fido2-lib GitHub: https://github.com/apowers313/fido2-lib
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.



