Introduction to the Web Authentication API
The Web Authentication API, commonly known as WebAuthn, is a web standard published by the W3C and FIDO Alliance that enables passwordless authentication using public-key cryptography. It is exposed in browsers through the navigator.credentials interface and allows web applications to register and authenticate users with authenticators such as security keys, platform biometrics (Touch ID, Face ID, Windows Hello), or passkeys stored in a user's device.
Unlike traditional password-based authentication, WebAuthn does not transmit any shared secret over the network. Instead, each registered credential is bound to a specific origin (domain), and the private key never leaves the authenticator. This makes WebAuthn inherently resistant to phishing, credential stuffing, and man-in-the-middle attacks.
Why WebAuthn Matters
- Phishing resistance: Credentials are scoped to the origin, so a fake site cannot use credentials created for the real one.
- No shared secrets: Servers store only public keys, eliminating the risk of password database leaks.
- Strong user experience: Users authenticate with a fingerprint, face scan, or device PIN instead of typing passwords.
- Industry momentum: Major platforms (Apple, Google, Microsoft) now support passkeys built on WebAuthn.
Core Concepts
Before writing code, it is important to understand the key actors and data structures involved in WebAuthn.
The Three Parties
- Relying Party (RP): The web application that wants to authenticate a user. Identified by its origin and RP ID (the effective domain).
- Client: The browser that mediates between the RP and the authenticator.
- Authenticator: The hardware or software component that generates and stores key pairs. This can be a roaming authenticator (USB/NFC/BLE security key) or a platform authenticator (built into the device).
Key Data Structures
WebAuthn revolves around two main operations: registration (creating a credential) and authentication (verifying a credential). Each operation involves the browser producing an assertion or attestation object that the server must verify.
The most important fields you will encounter are:
challenge: A cryptographically random byte sequence generated by the server, used to prevent replay attacks.rpId: The domain the credential is bound to (e.g.,example.com).user.id: An opaque, unique identifier for the user (not the username).pubKeyCredParams: A list of acceptable public key algorithms, such as ES256 or RS256.authenticatorSelection: Preferences such as requiring a platform authenticator or user verification.
Registration Flow
Registration, also called "ceremony of creation," creates a new public-key credential on the user's authenticator. The flow has two halves: the browser call and the server verification.
Step 1: Server Generates Options
The server must generate a random challenge and assemble the public key creation options. The challenge must be stored in the user's session so it can be verified later.
// server.js (Node.js example)
import crypto from 'node:crypto';
export function generateRegistrationOptions(user) {
const challenge = crypto.randomBytes(32);
// Store challenge in session for later verification
user.pendingChallenge = challenge;
return {
challenge: bufferToBase64url(challenge),
rp: {
name: 'My Secure App',
id: 'example.com'
},
user: {
id: bufferToBase64url(user.id),
name: user.email,
displayName: user.displayName
},
pubKeyCredParams: [
{ type: 'public-key', alg: -7 }, // ES256
{ type: 'public-key', alg: -257 } // RS256
],
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'required',
residentKey: 'preferred'
},
timeout: 60000,
attestation: 'none'
};
}
function bufferToBase64url(buf) {
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
Step 2: Browser Creates the Credential
On the client side, the application calls navigator.credentials.create() with the options received from the server. The browser handles the user interaction (biometric prompt, security key tap, etc.).
// client.js
async function registerUser() {
// Fetch options from the server
const res = await fetch('/webauthn/register/options', {
method: 'POST',
credentials: 'include'
});
const options = await res.json();
// Convert base64url strings back to ArrayBuffers
const publicKey = {
...options,
challenge: base64urlToBuffer(options.challenge),
user: {
...options.user,
id: base64urlToBuffer(options.user.id)
},
excludeCredentials: (options.excludeCredentials || []).map(c => ({
...c,
id: base64urlToBuffer(c.id)
}))
};
const credential = await navigator.credentials.create({ publicKey });
// Send the credential to the server for verification
const verificationRes = await fetch('/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
attestationObject: bufferToBase64url(credential.response.attestationObject),
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON)
}
})
});
const result = await verificationRes.json();
console.log('Registration result:', result);
}
function base64urlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const pad = '='.repeat((4 - base64.length % 4) % 4);
const binary = atob(base64 + pad);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
Step 3: Server Verifies the Credential
The server must rigorously verify the attestation response. This includes checking the challenge, origin, RP ID, and parsing the authenticator data. In production, use a library such as @simplewebauthn/server or fido2-lib rather than implementing verification from scratch.
// server.js
import { verifyRegistrationResponse } from '@simplewebauthn/server';
export async function verifyRegistration(req, res) {
const user = req.session.user;
const expectedChallenge = user.pendingChallenge;
const expectedOrigin = 'https://example.com';
const expectedRPID = 'example.com';
try {
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: bufferToBase64url(expectedChallenge),
expectedOrigin,
expectedRPID,
requireUserVerification: true
});
if (verification.verified && verification.registrationInfo) {
const { credential } = verification.registrationInfo;
// Store the credential public key, id, and counter in the database
user.credentials = user.credentials || [];
user.credentials.push({
id: credential.id,
publicKey: credential.publicKey,
counter: credential.counter
});
user.pendingChallenge = null;
res.json({ verified: true });
} else {
res.status(400).json({ error: 'Verification failed' });
}
} catch (err) {
res.status(400).json({ error: err.message });
}
}
Authentication Flow
Authentication, also called the "assertion ceremony," proves possession of a previously registered credential. The server sends a fresh challenge and a list of allowed credential IDs; the browser asks the authenticator to sign the challenge with the matching private key.
Step 1: Server Generates Assertion Options
// server.js
export function generateAuthenticationOptions(user) {
const challenge = crypto.randomBytes(32);
user.pendingChallenge = challenge;
return {
challenge: bufferToBase64url(challenge),
rpId: 'example.com',
allowCredentials: user.credentials.map(c => ({
type: 'public-key',
id: c.id
})),
userVerification: 'required',
timeout: 60000
};
}
Step 2: Browser Requests the Assertion
// client.js
async function authenticateUser() {
const res = await fetch('/webauthn/auth/options', {
method: 'POST',
credentials: 'include'
});
const options = await res.json();
const publicKey = {
...options,
challenge: base64urlToBuffer(options.challenge),
allowCredentials: (options.allowCredentials || []).map(c => ({
type: c.type,
id: base64urlToBuffer(c.id)
}))
};
const assertion = await navigator.credentials.get({ publicKey });
const verifyRes = await fetch('/webauthn/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
id: assertion.id,
rawId: bufferToBase64url(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
signature: bufferToBase64url(assertion.response.signature),
userHandle: assertion.response.userHandle
? bufferToBase64url(assertion.response.userHandle)
: null
}
})
});
const result = await verifyRes.json();
if (result.verified) {
console.log('User authenticated successfully');
}
}
Step 3: Server Verifies the Assertion
// server.js
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
export async function verifyAuthentication(req, res) {
const user = req.session.user;
const expectedChallenge = user.pendingChallenge;
const expectedOrigin = 'https://example.com';
const expectedRPID = 'example.com';
const credential = user.credentials.find(c => c.id === req.body.id);
if (!credential) {
return res.status(400).json({ error: 'Unknown credential' });
}
try {
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: bufferToBase64url(expectedChallenge),
expectedOrigin,
expectedRPID,
credential: {
id: credential.id,
publicKey: credential.publicKey,
counter: credential.counter
},
requireUserVerification: true
});
if (verification.verified) {
// Update the stored counter to detect cloned authenticators
credential.counter = verification.authenticationInfo.newCounter;
user.pendingChallenge = null;
// Establish the authenticated session
req.session.authenticated = true;
res.json({ verified: true });
} else {
res.status(400).json({ error: 'Authentication failed' });
}
} catch (err) {
res.status(400).json({ error: err.message });
}
}
Feature Detection and Fallbacks
Not every browser or device supports WebAuthn, so always perform feature detection before invoking the API. Provide a graceful fallback to a password or one-time code flow when WebAuthn is unavailable.
function isWebAuthnSupported() {
return typeof window.PublicKeyCredential !== 'undefined';
}
async function isConditionalUISupported() {
if (!isWebAuthnSupported()) return false;
// Conditional UI lets users autofill credentials in a password field
if (typeof window.PublicKeyCredential.isConditionalMediationAvailable !== 'function') {
return false;
}
return await window.PublicKeyCredential.isConditionalMediationAvailable();
}
if (isWebAuthnSupported()) {
showPasskeyButton();
} else {
showPasswordForm();
}
Best Practices
Always Require User Verification
Set userVerification: 'required' in both registration and authentication options. This ensures the authenticator performs a local biometric or PIN check, protecting against stolen devices being used to sign in.
Use Sufficiently Long Challenges
Challenges should be at least 16 bytes of cryptographically random data; 32 bytes is recommended. Never reuse a challenge, and always invalidate it after a single use.
Validate Origin and RP ID Strictly
The server must verify that the origin in the client data JSON matches the expected origin exactly, including scheme and port. The RP ID must be a registrable domain suffix of the origin.
Store the Signature Counter
Authenticators maintain a monotonically increasing counter. After each successful authentication, store the new counter value. If a future authentication reports a counter lower than or equal to the stored value, the credential may have been cloned and should be revoked.
Offer Passkeys for Cross-Device Sync
Set residentKey: 'preferred' and authenticatorAttachment: 'platform' to encourage creation of passkeys that sync across a user's devices via iCloud Keychain or Google Password Manager. This dramatically improves the recovery story compared to traditional security keys.
Never Roll Your Own Crypto Verification
WebAuthn verification involves CBOR decoding, signature validation, and attestation parsing. Use well-audited libraries such as @simplewebauthn/server, fido2-lib, or @github/webauthn-json for client-side encoding helpers.
Provide Account Recovery Paths
Even with passkeys, users may lose access to all their devices. Always provide a recovery mechanism such as backup codes, email-based reauthentication, or additional registered security keys.
Conclusion
The Web Authentication API represents a fundamental shift away from passwords toward cryptographic, phishing-resistant credentials. By understanding the registration and authentication ceremonies, validating responses correctly on the server, and following best practices around user verification, challenge handling, and counter tracking, you can build a sign-in experience that is both more secure and more convenient than traditional password flows. As passkey support continues to expand across operating systems and browsers, WebAuthn is rapidly becoming the default foundation for modern web authentication, and integrating it today positions your application for a safer, passwordless future.