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

Pentagon Halts 155 Wind Projects in 24 States Over Drone Fears
Tech
The Next WebJul 18

Pentagon Halts 155 Wind Projects in 24 States Over Drone Fears

The Pentagon has frozen permitting for 155 wind projects across 24 states for nearly a year, citing concerns that drones can hide within wind farms. This impacts 44 gigawatts of capacity and has cost developers $2 billion. The wind industry claims the freeze is politically motivated and has filed a lawsuit.

Build Your Own Local NMT App with React Native and QVAC
Programming
freeCodeCampJul 18

Build Your Own Local NMT App with React Native and QVAC

This article explores how Neural Machine Translation (NMT), powered by the Transformer architecture, revolutionized translation by understanding context. We then delve into QVAC, a local-first AI development platform, and its Bergamot engine, enabling private, on-device translation. Learn to set up a React Native app with QVAC and manage model lifecycles for efficient local translation.

Unpacking Roman Concrete's Durability: Carbonation and Self-Healing
Programming
Hacker NewsJul 17

Unpacking Roman Concrete's Durability: Carbonation and Self-Healing

The Enduring Legacy: Roman Concrete's Millennia-Long Stand As software developers, we're familiar with the ephemeral nature of technology; systems evolve, frameworks deprecate, and codebases undergo constant

PayPal in Microservices: NestJS, gRPC, and Docker Blueprint
Programming
freeCodeCampJul 17

PayPal in Microservices: NestJS, gRPC, and Docker Blueprint

Integrating payment logic directly into every microservice within a distributed system often leads to significant challenges. Scattering PayPal API calls across services like user-service, order-service, or

Demystifying Dijkstra's Algorithm: The Shortest Path Pioneer
Programming
freeCodeCampJul 16

Demystifying Dijkstra's Algorithm: The Shortest Path Pioneer

Explore Dijkstra's Algorithm, the foundational pathfinding technique conceived by Edsger W. Dijkstra. This guide explains how it solves shortest path problems using graphs, nodes, edges, and weights. Learn its greedy approach and the critical role of data structures like adjacency lists and priority queues in its efficient Python implementation.

AWS Leadership Shift: What It Means for Compute and AI/ML
Programming
GeekWireJul 16

AWS Leadership Shift: What It Means for Compute and AI/ML

Dave Brown, a key figure in AWS's EC2 and AI/ML growth, is departing. His successor, Dave Treadwell, brings extensive experience from Microsoft and Amazon's eCommerce Foundation, potentially signaling new directions for core cloud services and AI innovation.

Back to Newsroom

Stay ahead of the curve

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