Node.js WebAuthn: Passwordless Biometric Login for Developers
As software developers, we're constantly seeking robust authentication methods. For years, JSON Web Tokens (JWTs) have been a staple, offering a seemingly clean way to manage user sessions. However, the common pattern

As software developers, we're constantly seeking robust authentication methods. For years, JSON Web Tokens (JWTs) have been a staple, offering a seemingly clean way to manage user sessions. However, the common pattern of deploying long-lived, reusable bearer tokens introduces significant risks. When an attacker compromises a user's machine through malware, XSS, or session hijacking, a stolen JWT can grant them full access, as the server treats the replayed token as legitimate until it expires. This fundamental flaw — proving possession of a token, not possession of a trusted device — is where WebAuthn steps in to redefine our security posture.
WebAuthn radically shifts the authentication paradigm by leveraging asymmetric cryptography. Instead of a shared secret, a key pair is generated on the user's authenticator (e.g., Touch ID, Face ID, Windows Hello, a security key). The private key remains securely on the device, never leaving it. Your server, known as the Relying Party, stores only the public key, a credential ID, and a counter. Each login or registration involves a fresh cryptographic challenge issued by your server, which the user's device cryptographically signs. This entire process, involving your backend, the browser, and the authenticator, is what's known as a 'ceremony'.
This guide will walk you through implementing WebAuthn for passwordless biometric login in a Node.js Express application. We'll cover the registration and authentication ceremonies, proper passkey storage, and how to replace long-lived bearer tokens with short, server-managed sessions.
Why Traditional JWTs Fall Short
The vulnerability of JWTs isn't inherent in the token format itself, but in common deployment practices. Typically:
- A server issues a reusable bearer token upon login.
- The browser stores this token (often in local storage or cookies).
- If an attacker gains access to the browser environment (e.g., via XSS, malware), they can steal the token.
- The attacker then uses this token to impersonate the user, sending requests to the backend.
- The backend, seeing a valid, unexpired token, accepts these requests as authentic.
This replay attack vector is particularly dangerous for high-risk operations like financial transactions, administrative actions, or modifying sensitive user data. WebAuthn mitigates this by ensuring the secret (the private key) never leaves the user's device, making token replay impossible.
How WebAuthn Transforms Authentication
WebAuthn's core strength lies in its use of asymmetric cryptography. During registration, the user's authenticator creates a unique key pair. The private key is device-bound and never transmitted, while the public key is sent to your server for storage. For subsequent logins, your server issues a unique, time-sensitive challenge. The authenticator signs this challenge using the private key, and the resulting signature is sent back to your server. Your server then uses the stored public key to verify the signature, confirming the user's identity and device possession.
This changes key security aspects:
- No Reusable Secret: The browser never handles a password or a long-lived secret that an attacker could steal and replay.
- Useless Public Key: A stolen public key is useless to an attacker for login, as they cannot generate a valid signature without the corresponding private key.
- Fresh Challenge for Each Ceremony: Each authentication attempt requires a new challenge, preventing replay attacks.
Passkeys, built upon WebAuthn, offer a seamless user experience, allowing users to authenticate with local biometrics (Face ID, Touch ID, Windows Hello) or physical security keys. From a developer's perspective, your application interacts with standard WebAuthn objects like credential IDs, public keys, and counter values.
Setting Up the Node.js Backend
To demonstrate the WebAuthn flow, we'll build a basic Node.js Express application. This demo focuses on the core backend logic for registration, authentication, and session management.
First, initialize your project and install the necessary dependencies:
shell mkdir webauthn-node-demo cd webauthn-node-demo npm init -y npx tsc --init mkdir src
Install typescript, tsx, express, express-session, and simplewebauthn:
shell npm install -D typescript tsx @types/node npm install express express-session @types/express @types/express-session npm install @simplewebauthn/server @simplewebauthn/browser
Update your package.json scripts:
{ "scripts": { "dev": "tsx watch src/app.ts", "build": "tsc", "start": "node dist/app.js" } }
Define the Data Model
Unlike password hashes, WebAuthn requires storing specific credential data. Each user can have multiple passkeys, so we model them as a collection associated with a User.
typescript // src/app.ts type Passkey = { id: string; publicKey: Uint8Array; counter: number; deviceType: "singleDevice" | "multiDevice"; backedUp: boolean; transports?: string[]; };
type User = { id: string; email: string; webAuthnUserID: Uint8Array; passkeys: Passkey[]; };
const users = new Map<string, User>();
function findUserByEmail(email: string) { return [...users.values()].find((user) => user.email === email); }
Key fields include id (credential ID), publicKey (for verification), and counter (to detect cloned authenticators).
Build the Server Foundation
We'll use Express for routes and express-session for managing short-lived server-side session state. Define your Relying Party (RP) settings.
typescript // src/app.ts import express from "express"; import session from "express-session"; import { randomBytes, randomUUID } from "node:crypto"; import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, type WebAuthnCredential } from "@simplewebauthn/server";
const rpName = "Node Auth Lab"; const rpID = "localhost"; // Use 'localhost' for local dev, HTTPS domain for production const origin = "http://localhost:3000";
declare module "express-session" { interface SessionData { currentChallenge?: string; pendingUserId?: string; userId?: string; stepUpUntil?: number; } }
const app = express(); app.use(express.json()); app.use( session({ secret: "replace-this-in-production", // CHANGE THIS IN PRODUCTION! resave: false, saveUninitialized: false, cookie: { httpOnly: true, sameSite: "lax", secure: false, maxAge: 10 * 60 * 1000 }, }), );
Session data (currentChallenge, pendingUserId, userId) will track the state of ongoing WebAuthn ceremonies and authenticated sessions.
The WebAuthn Registration Ceremony
Registration is where a new passkey is created and associated with a user account. It involves three steps:
1. Return Registration Options from the Backend
Your server initiates the process by generating registration options and a unique challenge.
typescript // src/app.ts (excerpt) app.post("/auth/register/options", async (req, res) => { const { email } = req.body; let user = findUserByEmail(email); if (!user) { // Create new user if not exists user = { id: randomUUID(), email, webAuthnUserID: randomBytes(32), passkeys: [] }; users.set(user.id, user); }
const options = await generateRegistrationOptions({ rpName, rpID, userName: user.email, userDisplayName: user.email, userID: user.webAuthnUserID, attestationType: "none", // For lighter flow, unless full device provenance needed excludeCredentials: user.passkeys.map((passkey) => ({ id: passkey.id, transports: passkey.transports })), authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" }, });
req.session.currentChallenge = options.challenge; req.session.pendingUserId = user.id; res.json(options); });
Key details: excludeCredentials prevents re-registering the same authenticator, and userVerification: 'preferred' prioritizes biometric prompts.
2. Start Registration in the Browser
On the client, you fetch these options and pass them to @simplewebauthn/browser's startRegistration function. Note: In a real app, src/browser.ts would be bundled for the client.
typescript // src/browser.ts (excerpt) import { startRegistration } from "@simplewebauthn/browser";
export async function registerPasskey(email: string) { const optionsResp = await fetch("/auth/register/options", { /* ... */ }); const optionsJSON = await optionsResp.json();
const registrationResponse = await startRegistration({ optionsJSON });
const verifyResp = await fetch("/auth/register/verify", { /* ... */ }); return verifyResp.json(); }
This triggers the user's authenticator (e.g., Face ID prompt).
3. Verify the Registration Response and Save the Passkey
After the browser sends the authenticator's response, your backend verifies its authenticity and saves the new passkey details.
typescript // src/app.ts (excerpt) app.post("/auth/register/verify", async (req, res) => { const user = users.get(req.session.pendingUserId ?? ""); if (!user || !req.session.currentChallenge) { /* handle error */ return; }
let verification; try { verification = await verifyRegistrationResponse({ response: req.body, expectedChallenge: req.session.currentChallenge, expectedOrigin: origin, expectedRPID: rpID, }); } catch (error) { /* handle error */ return; }
if (!verification.verified || !verification.registrationInfo) { /* handle error */ return; }
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; user.passkeys.push({ id: credential.id, publicKey: credential.publicKey, counter: credential.counter, transports: credential.transports, deviceType: credentialDeviceType, backedUp: credentialBackedUp, });
req.session.currentChallenge = undefined; // Clear challenge req.session.pendingUserId = undefined; // Clear pending user res.json({ verified: true }); });
Upon successful verification, the public key, credential ID, and counter are stored, linking the passkey to the user. No password hash is ever stored.
The WebAuthn Authentication Ceremony
Authentication verifies that the user still possesses a registered passkey. This process is similar to registration, but instead of creating a new credential, it asserts an existing one.
1. Return Authentication Options
Your server prepares options, including a fresh challenge and a list of allowed credentials for the user.
typescript // src/app.ts (excerpt) app.post("/auth/login/options", async (req, res) => { const { email } = req.body; const user = findUserByEmail(email); if (!user) { /* handle error */ return; }
const options = await generateAuthenticationOptions({ rpID, allowCredentials: user.passkeys.map((passkey) => ({ id: passkey.id, transports: passkey.transports })), userVerification: "preferred", });
req.session.currentChallenge = options.challenge; req.session.pendingUserId = user.id; res.json(options); });
2. Start Authentication in the Browser
The browser uses startAuthentication to prompt the user's authenticator.
typescript // src/browser.ts (excerpt) import { startAuthentication } from "@simplewebauthn/browser";
export async function loginWithPasskey(email: string) { const optionsResp = await fetch("/auth/login/options", { /* ... */ }); const optionsJSON = await optionsResp.json();
const authenticationResponse = await startAuthentication({ optionsJSON });
const verifyResp = await fetch("/auth/login/verify", { /* ... */ }); return verifyResp.json(); }
3. Verify the Assertion and Update the Counter
This crucial backend step validates the signature and updates the credential's counter.
typescript // src/app.ts (excerpt) app.post("/auth/login/verify", async (req, res) => { const user = users.get(req.session.pendingUserId ?? ""); const passkey = user?.passkeys.find((item) => item.id === req.body.id); if (!user || !req.session.currentChallenge || !passkey) { /* handle error */ return; }
const credential = { id: passkey.id, publicKey: passkey.publicKey, counter: passkey.counter, transports: passkey.transports }; let verification; try { verification = await verifyAuthenticationResponse({ response: req.body, expectedChallenge: req.session.currentChallenge, expectedOrigin: origin, expectedRPID: rpID, credential, requireUserVerification: true, }); } catch (error) { /* handle error */ return; }
if (!verification.verified) { /* handle error */ return; }
passkey.counter = verification.authenticationInfo.newCounter; // CRITICAL: Update counter req.session.userId = user.id; // Establish user session req.session.currentChallenge = undefined; req.session.pendingUserId = undefined; res.json({ verified: true }); });
requireUserVerification: true ensures the strongest possible authentication, and updating newCounter helps detect cloned authenticators or suspicious activity.
What Replaces the Long-Lived JWT
After a successful WebAuthn authentication, avoid issuing a broad, long-lived bearer token. Instead, establish a short, server-managed session. The browser only receives an HTTP-only session cookie. This dramatically reduces the attack surface, as the session state resides solely on the server. For sensitive actions, you can implement 'step-up' authentication, requiring a fresh WebAuthn assertion.
typescript // src/app.ts (excerpt) function requireSession(req: express.Request, res: express.Response, next: express.NextFunction) { if (!req.session.userId) { return res.status(401).json({ error: "Unauthorized" }); } next(); }
app.get("/me", requireSession, (req, res) => { const user = users.get(req.session.userId ?? ""); if (!user) { return res.status(404).json({ error: "User not found" }); } res.json({ id: user.id, email: user.email, passkeys: user.passkeys.length }); });
This approach ensures that the proof of identity is strong (WebAuthn), while the session itself is short-lived and server-controlled, offering a far more robust security model than traditional JWT deployments.
FAQ
Q: Why is rpID important, and why localhost for development?
A: The rpID (Relying Party ID) specifies the origin for which the credential is valid. It's a security measure to prevent credentials registered for one site from being used on another. For development, localhost is allowed by WebAuthn spec, but in production, it must be your domain (e.g., example.com), and WebAuthn only works in secure contexts (HTTPS).
Q: What is the purpose of the counter in a WebAuthn credential?
A: The counter is a monotonically increasing value returned by the authenticator with each successful authentication. Your server stores this counter and expects it to strictly increase. If a received counter value is less than or equal to the stored one, it indicates a potential replay attack or a cloned authenticator, allowing your server to flag or reject the login attempt.
Q: Can a user have multiple passkeys, and how does that affect recovery?
A: Yes, users can (and should) have multiple passkeys registered to their account, allowing for multi-device support and recovery. If a user loses a device or security key, they can still log in with another registered passkey. WebAuthn also supports features like backedUp status, indicating if a passkey is cloud-synced, which can aid in designing recovery flows.
Related articles
Google Play's New Stance on 501(c)(6) Donations: AnkiDroid's Challenge
For developers deeply embedded in the open-source ecosystem, the challenge of sustainable funding is ever-present. Many projects rely on community donations, often facilitated by fiscal hosts that simplify legal and
Cold Cases & Data Integrity: Lessons from a Decades-Old Verdict
As software developers, we often deal with complex systems, legacy codebases, and the relentless pursuit of bugs that have evaded detection for years. The recent conviction in the 1996 murder of rapper Tupac Shakur
Reimagining Classic IM: Exploring Open OSCAR Server in Go
Open OSCAR Server is an open-source, Go-based instant messaging server compatible with classic AIM and ICQ clients. It enables developers and enthusiasts to self-host a private IM server, reviving the functionality of these legacy platforms. The project boasts broad client compatibility, detailed protocol implementations, and a management API for administration.
Rockstar's GTA VI Leak Response: Heartbreak, PR, and Legal Action
Verdict: Rockstar's official statement on the widespread Grand Theft Auto VI leaks is a carefully crafted blend of emotional appeal and strategic silence. While it acknowledges the developers' "heartbreaking" experience
Fixing Leaked API Keys: A Developer's Guide to Git Security
Discovering an API key in Git history is a serious security incident requiring immediate action. This guide outlines a developer's step-by-step response, emphasizing the critical need to invalidate the compromised key first, then systematically remove it from code and Git history. It also covers best practices for prevention, including using environment variables or secret managers, implementing least privilege, and integrating automated secret scanning into your workflow.
Ex-Dishonored Dev: GTA is "Amazing, But a Stab in the Heart
Why GTA Games Are a Paradox for an Immersive Sim Pioneer Ever wondered what goes through the minds of top-tier game developers when they play titles outside their own genre? Raphaël Colantonio, the industry veteran



