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

Microsoft Unveils ASSERT, Simplifying AI Behavior Testing with Text
Tech
TechCrunchJun 2

Microsoft Unveils ASSERT, Simplifying AI Behavior Testing with Text

Microsoft has launched ASSERT, an open-source framework designed to simplify AI behavior testing. It enables developers to create comprehensive, application-specific evaluations using natural language descriptions, ensuring AI systems act as intended for particular products and services. The tool translates high-level goals into structured tests, generates scenarios, scores results, and logs execution paths.

Programming
Hacker NewsJun 2

Great Question (YC W21) Seeks Applied AI Interns: A Deep Dive

As fellow developers, we’re constantly scanning the landscape for companies pushing the boundaries, especially in the rapidly evolving AI space. Great Question, a Y Combinator W21 alumnus, has caught our eye with an

Navigating the Global AI Arena: Beyond Silicon Valley's Borders
Programming
Stack Overflow BlogJun 2

Navigating the Global AI Arena: Beyond Silicon Valley's Borders

The international AI landscape presents unique challenges and opportunities, requiring developers to think beyond traditional tech hubs. Key aspects include adapting AI models to local languages and cultures, navigating the complex global supply chain for critical hardware like semiconductors, and understanding how venture capital assesses these international ventures. Success hinges on deep local market understanding, robust technical solutions for localization, and resilience against logistical hurdles.

Programming
Hacker NewsJun 2

Engineering a Solution: Debugging Global Mosquito-Borne Diseases

As developers, we're constantly tasked with solving complex problems, whether it's optimizing a database query or architecting a distributed system. But what if the 'bug' we're trying to fix is biological, with global

Enhanced Security: Your Galaxy Phone's New Lockdown Mode Explained
How To
LifehackerJun 1

Enhanced Security: Your Galaxy Phone's New Lockdown Mode Explained

Discover how Samsung Galaxy phones are adopting an iPhone-like security feature, automatically disabling biometrics when accessing the power menu. Learn what this means for your phone's safety and how to experience it.

Self-Host S3-Compatible Object Storage with MinIO on Staging
Programming
freeCodeCampJun 2

Self-Host S3-Compatible Object Storage with MinIO on Staging

This guide demonstrates how to self-host an S3-compatible object store using MinIO on your staging server. By leveraging Docker Compose and Traefik for HTTPS, you can significantly reduce cloud storage costs while maintaining a production-like environment for development and testing. It covers setup, application configuration, and secure file interactions.

Back to Newsroom

Stay ahead of the curve

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