News Froggy
newsfroggy
HomeTechReviewProgrammingGamesHow ToAboutContacts
newsfroggy

Your daily source for the latest technology news, startup insights, and innovation trends.

More

  • About Us
  • Contact
  • Privacy Policy
  • Terms of Service

Categories

  • Tech
  • Review
  • Programming
  • Games
  • How To

© 2026 News Froggy. All rights reserved.

TwitterFacebook
Programming

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

PublishedMarch 20, 2026
Reading Time11 min
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 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:

  1. A server issues a reusable bearer token upon login.
  2. The browser stores this token (often in local storage or cookies).
  3. If an attacker gains access to the browser environment (e.g., via XSS, malware), they can steal the token.
  4. The attacker then uses this token to impersonate the user, sending requests to the backend.
  5. 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.

#programming#freeCodeCamp##webauthn#Node.js#biometric authentication#nodeMore

Related articles

Building Responsive, Accessible React UIs with Semantic HTML
Programming
freeCodeCampApr 8

Building Responsive, Accessible React UIs with Semantic HTML

Build responsive and accessible React UIs. This guide uses semantic HTML, mobile-first design, and ARIA to create inclusive applications, ensuring seamless user experiences across devices.

Beyond Vibe Coding: Engineering Quality in the AI Era
Programming
Hacker NewsApr 7

Beyond Vibe Coding: Engineering Quality in the AI Era

The concept of 'vibe coding,' an extreme form of dogfooding where developers avoid inspecting AI-generated code, often leads to significant quality issues. A more effective approach involves actively guiding AI tools to clean up technical debt and refactor, treating them as powerful assistants under human oversight. Ultimately, maintaining high software quality, even with AI, remains a deliberate choice for developers.

Programming
Hacker NewsApr 5

Offline-First Social Systems: The Rise of Phone-Free Venues

Mobile technology, while streamlining communication and access, has also ushered in an era of constant digital distraction. For developers familiar with context switching and notification fatigue, the impact on

Lisette: Rust-like Syntax, Go Runtime — Bridging Safety and
Programming
Hacker NewsApr 5

Lisette: Rust-like Syntax, Go Runtime — Bridging Safety and

Lisette is a new language inspired by Rust's syntax and type system, but designed to compile directly to Go. It aims to combine Rust's compile-time safety features—like exhaustive pattern matching, no nil, and strong error handling—with Go's efficient runtime and extensive ecosystem. This approach allows developers to write safer, more expressive code while seamlessly leveraging existing Go tools and libraries.

Linux 7.0 Halves PostgreSQL Performance: A Kernel Preemption Deep Dive
Programming
Hacker NewsApr 5

Linux 7.0 Halves PostgreSQL Performance: A Kernel Preemption Deep Dive

An AWS engineer reported a dramatic 50% performance drop for PostgreSQL on the upcoming Linux 7.0 kernel, caused by changes to kernel preemption modes. While a revert was proposed, kernel developers suggest PostgreSQL should adapt using Restartable Sequences (RSEQ). This could mean significant performance issues for databases on Linux 7.0 until PostgreSQL is updated.

Lessons from 15,031 Hours of Live Coding on Twitch with Chris Griffing
Programming
freeCodeCampApr 3

Lessons from 15,031 Hours of Live Coding on Twitch with Chris Griffing

In today's rapidly evolving software landscape, developers are constantly seeking insights into efficient learning, career growth, and adapting to new technologies. While traditional paths exist, some invaluable lessons

Back to Newsroom

Stay ahead of the curve

Get the latest technology insights delivered to your inbox every morning.