WebAuthn and JavaScript Passwordless Authentication
WebAuthn (Web Authentication API) is a modern web standard that
enables secure, passwordless authentication using asymmetric public-key
cryptography. This article explains what WebAuthn is, how it replaces
shared secrets like passwords with cryptographic key pairs, and how
developers can implement registration and login flows in browser-based
JavaScript using the navigator.credentials interface.
What is WebAuthn?
WebAuthn is a core component of the FIDO2 project, standardized by the W3C and the FIDO Alliance. It allows web applications to authenticate users via built-in platform authenticators (such as Apple Touch ID/Face ID, Windows Hello, and Android biometrics) or roaming authenticators (such as USB, NFC, or Bluetooth security keys like YubiKeys).
Instead of sending a password over the network, WebAuthn uses asymmetric public-key cryptography: * Private Key: Generated and securely stored on the user’s local authenticator hardware. It never leaves the device. * Public Key: Sent to the relying party (the web server) during registration and stored in the database alongside the user’s account.
This design eliminates the risk of credential stuffing, database password leaks, and phishing attacks, as the authenticator binds credentials directly to the origin (domain) of the website.
How WebAuthn Works
WebAuthn operates through two primary processes: Registration (Credential Creation) and Authentication (Assertion).
- Registration: The server creates a cryptographic challenge. The client requests the authenticator to generate a new key pair. The authenticator creates the keys and signs the challenge with the private key. The browser sends the public key and signed challenge back to the server for verification and storage.
- Authentication: The server issues a new challenge. The client asks the authenticator to sign this challenge with the stored private key. The server validates the signature against the previously stored public key.
Implementing WebAuthn with JavaScript
JavaScript interacts with the underlying authenticator hardware using
the navigator.credentials API.
1. User
Registration (navigator.credentials.create)
To register a new credential, the server first provides a set of
creation options (including a unique challenge and user details). The
client-side JavaScript converts binary data (such as user IDs and
challenges) into ArrayBuffer formats before invoking the
API.
async function registerUser(serverCreationOptions) {
// Decode base64url strings from the server into Uint8Arrays/ArrayBuffers
const publicKeyCredentialCreationOptions = {
challenge: Uint8Array.from(atob(serverCreationOptions.challenge), c => c.charCodeAt(0)),
rp: {
name: "Example App",
id: window.location.hostname,
},
user: {
id: Uint8Array.from(serverCreationOptions.userId, c => c.charCodeAt(0)),
name: "alex@example.com",
displayName: "Alex Doe",
},
pubKeyCredParams: [
{ alg: -7, type: "public-key" }, // ES256
{ alg: -257, type: "public-key" } // RS256
],
authenticatorSelection: {
authenticatorAttachment: "platform", // e.g., TouchID, Windows Hello
userVerification: "preferred",
},
timeout: 60000,
attestation: "none"
};
try {
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions
});
// Send the credential back to the server to store the public key
await sendRegistrationToServer(credential);
} catch (err) {
console.error("Registration failed:", err);
}
}2. User
Authentication (navigator.credentials.get)
When a user logs in, the server generates an authentication
challenge. The browser requests an assertion from the authenticator
using navigator.credentials.get().
async function authenticateUser(serverRequestOptions) {
// Decode base64url challenge and allowed credential IDs
const publicKeyCredentialRequestOptions = {
challenge: Uint8Array.from(atob(serverRequestOptions.challenge), c => c.charCodeAt(0)),
allowCredentials: serverRequestOptions.allowCredentials.map(cred => ({
id: Uint8Array.from(atob(cred.id), c => c.charCodeAt(0)),
type: 'public-key',
})),
timeout: 60000,
userVerification: "preferred"
};
try {
const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions
});
// Send the assertion (signature and client data) to the server for verification
await sendAssertionToServer(assertion);
} catch (err) {
console.error("Authentication failed:", err);
}
}Core Security Benefits
- No Shared Secrets: Servers store public keys rather than passwords or password hashes. A compromised server database does not reveal user private keys.
- Built-in Phishing Protection: The browser strictly binds the credential creation and signing operations to the origin (domain). If a user is tricked into visiting a phishing site, the authenticator refuses to sign the challenge for the spoofed domain.
- Hardware-Backed Protection: Private keys reside inside secure enclaves, Trusted Platform Modules (TPM), or physical security tokens, preventing key extraction via malware.