Passkey (WebAuthn) Authentication for Node.js APIs
Stop storing password hashes. Here is a working WebAuthn / passkey implementation for Node.js that replaces passwords with biometric or device-bound credentials using the FIDO2 standard, with server-side validation, credential storage in Postgres, and a minimal client implementation.
Your login form collects a password, ships it over TLS to your API, hashes it with bcrypt, and compares it to a stored hash. This is the same architecture we have used for 25 years, and over those 25 years, attackers have gotten very good at stealing passwords. Phishing pages that mimic your login screen exactly. Credential-stuffing scripts that try the same email and password across 500 breached databases. Session-token theft via XSS. Even if you do everything right with bcrypt, CSP, and HTTPS, the user themselves is the weakest link: they reuse passwords, they fall for phishing, and they type credentials into anything that looks like a login box.
Passkeys (also called WebAuthn credentials or FIDO2 keys) eliminate the password entirely. The user authenticates with a biometric (fingerprint, face scan) or a device PIN, and the cryptographic key material never leaves their device. Your server never sees a secret that can be phished, stolen, or reused.
This is not a future thing. Passkeys are supported by all major browsers, both platform authenticators (Touch ID, Windows Hello, Android biometrics) and cross-device roaming (iCloud Keychain, Google Password Manager, 1Password). The WebAuthn spec is a W3C Recommendation and every browser API is stable. By mid-2026, the infrastructure is mature enough that adding passkey support to your Node.js API is a few hundred lines of well-understood code.
This post walks through a complete, production-ready passkey authentication flow in Node.js with TypeScript and Postgres. You will see the server-side registration ceremony, the assertion (login) ceremony, the database schema, and how to handle the hardware-specific edge cases that the spec does not warn you about.
The two ceremonies
WebAuthn defines two operations: registration (creating a credential) and assertion (using a credential to prove identity). Both are challenge-response protocols that run over the WebAuthn browser API on the client and the FIDO2 validation logic on the server.
The client never sends a private key. Instead:
- The server generates a random challenge and sends it to the client.
- The client asks the authenticator (e.g., Touch ID) to sign the challenge with a newly generated or existing private key.
- The server verifies the signature against the stored public key.
Your database stores only the public key, the credential ID, and a counter that increases with each assertion to detect cloned authenticators. If an attacker steals your database, they get public keys, which are useless without the corresponding private keys locked inside the user’s devices.
The server setup
We need three packages plus a Postgres schema. The @simplewebauthn/server library is the de facto standard server-side package for WebAuthn in Node.js. It handles the cryptographic verification, parsing the CBOR-encoded authenticator data, and validating attestation statements.
npm install @simplewebauthn/server @simplewebauthn/browser
We also need a way to store challenges temporarily (they expire after a few minutes) and credentials permanently.
Postgres schema
CREATE TABLE webauthn_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
challenge TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '5 minutes'
);
CREATE INDEX idx_webauthn_challenges_user ON webauthn_challenges(user_id);
CREATE TABLE webauthn_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
counter BIGINT NOT NULL DEFAULT 0,
transports TEXT[] NOT NULL DEFAULT '{}',
device_name TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ
);
CREATE INDEX idx_webauthn_credentials_user ON webauthn_credentials(user_id);
CREATE INDEX idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id);
The credential_id is the device-specific identifier returned by the authenticator. The counter is critical: if an attacker clones a hardware authenticator and uses it, the counter on the clone starts at 0 while the real device has an advanced counter. On next assertion, the server detects a counter decrease and rejects the credential.
Helper for HMAC-based origin verification
WebAuthn binds credentials to a specific origin (your domain). The @simplewebauthn/server package verifies this automatically if you pass the correct rpID (relying party ID, e.g., yourapp.com). But during development, when your origin is http://localhost:5173 and your rpID is localhost, you need to handle the mismatch. The simplest approach is to parameterize the origin and rpID from environment variables and keep them consistent.
// webauthn-config.ts
export const webAuthnConfig = {
rpName: 'The Practical Developer Demo App',
rpID: process.env.WEBAUTHN_RP_ID ?? 'localhost',
origin: process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:5173',
};
In production, WEBAUTHN_RP_ID is your bare domain (yourapp.com without protocol or port) and WEBAUTHN_ORIGIN is the full HTTPS origin (https://yourapp.com). Get this wrong and the authenticator silently refuses to sign, with no useful error in the browser console.
Registration ceremony (creating a passkey)
The registration flow has two endpoints: one to generate registration options and one to receive and verify the credential.
Step 1: Generate registration options
When a user clicks “Add a passkey,” the API generates a challenge and returns options the browser needs to create the credential.
import { generateRegistrationOptions } from '@simplewebauthn/server';
import { v4 as uuid } from 'uuid';
export async function generatePasskeyRegistrationOptions(userId: string, userEmail: string) {
// The challenge is a random buffer the authenticator will sign.
// We store it temporarily to verify the response later.
const challengePayload = await generateRegistrationOptions({
rpName: webAuthnConfig.rpName,
rpID: webAuthnConfig.rpID,
userName: userEmail,
// Exclude existing credential IDs so the user does not register
// the same device twice.
excludeCredentials: [], // fetch from DB and pass credential IDs here
attestationType: 'none', // skip attestation for simplicity; use 'direct' if you need hardware-level verification
authenticatorSelection: {
// 'platform' = built-in (Touch ID, Windows Hello)
// 'cross-platform' = roaming (YubiKey, phone)
// Leave undefined to let the user choose.
residentKey: 'preferred',
userVerification: 'preferred',
},
});
// Store the challenge for verification
await db.query(
`INSERT INTO webauthn_challenges (id, user_id, challenge, expires_at)
VALUES ($1, $2, $3, now() + interval '5 minutes')`,
[uuid(), userId, challengePayload.challenge]
);
return challengePayload;
}
The excludeCredentials parameter is important. If a user has already registered their phone as a passkey on your site, and you do not pass the existing credential IDs in excludeCredentials, the phone’s authenticator will not show an error, but the second registration will produce a duplicate credential that the user must manage later. Always pass the existing credential IDs from webauthn_credentials for this user.
Step 2: Verify and store the credential
The browser calls navigator.credentials.create() with the options from step 1, then sends the result back to the server.
import { verifyRegistrationResponse } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
export async function verifyPasskeyRegistration(
userId: string,
response: any // the browser's PublicKeyCredential JSON
) {
// Look up the stored challenge
const { rows } = await db.query(
`SELECT challenge FROM webauthn_challenges
WHERE user_id = $1 AND expires_at > now()
ORDER BY created_at DESC LIMIT 1`,
[userId]
);
if (rows.length === 0) {
throw new Error('No valid challenge found. Start registration again.');
}
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: rows[0].challenge,
expectedOrigin: webAuthnConfig.origin,
expectedRPID: webAuthnConfig.rpID,
});
if (!verification.verified || !verification.registrationInfo) {
throw new Error('Registration verification failed.');
}
const { credential } = verification.registrationInfo;
// Store the credential
await db.query(
`INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key, counter, transports)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
uuid(),
userId,
isoBase64URL.fromBytes(credential.id), // credential.id is an ArrayBuffer
isoBase64URL.fromBytes(credential.publicKey),
credential.counter,
response.response.transports ?? [],
]
);
// Clean up used challenges
await db.query('DELETE FROM webauthn_challenges WHERE user_id = $1', [userId]);
return { verified: true, credentialId: credential.id };
}
The isoBase64URL encoding is important. The raw credential data comes as an ArrayBuffer from the authenticator, and you cannot store that in a Postgres TEXT column. The @simplewebauthn/server helpers encode and decode these buffers to URL-safe base64, which maps cleanly to text fields.
Assertion ceremony (logging in)
The login flow is similar: generate a challenge, the client signs it, the server verifies.
Step 1: Generate assertion options
import { generateAuthenticationOptions } from '@simplewebauthn/server';
export async function generatePasskeyAssertionOptions() {
const options = await generateAuthenticationOptions({
rpID: webAuthnConfig.rpID,
// Allow any credential. If you want to limit to specific users,
// pass allowCredentials with credential IDs from the database.
userVerification: 'preferred',
});
// Store the challenge. Note: at this point we do not know who the user is.
// The challenge is stored globally (or per session) and matched after verification.
await redis.setEx(
`webauthn:challenge:${options.challenge}`,
300, // 5 minutes TTL
options.challenge
);
return options;
}
A subtle point: during login, you do not know the user ID yet. The assertion challenge is stored in Redis (or a session) rather than associated with a user. After verification succeeds, the credential lookup tells you who the user is. Using Redis with a TTL is cleaner than periodically vacuuming expired challenges from Postgres for this unauthenticated flow.
Step 2: Verify assertion and identify the user
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
export async function verifyPasskeyAssertion(response: any) {
// Retrieve the challenge from Redis
const storedChallenge = await redis.get(`webauthn:challenge:${response.response.clientDataJSON.challenge}`);
if (!storedChallenge) {
throw new Error('Challenge not found or expired.');
}
// Look up the credential by ID from the response
const credentialId = isoBase64URL.fromBytes(response.rawId);
const { rows } = await db.query(
`SELECT * FROM webauthn_credentials WHERE credential_id = $1`,
[credentialId]
);
if (rows.length === 0) {
throw new Error('Credential not found.');
}
const credential = rows[0];
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: storedChallenge,
expectedOrigin: webAuthnConfig.origin,
expectedRPID: webAuthnConfig.rpID,
credential: {
id: isoBase64URL.fromBytes(response.rawId),
publicKey: isoBase64URL.toBytes(credential.public_key),
counter: Number(credential.counter),
transports: credential.transports,
},
});
if (!verification.verified) {
throw new Error('Assertion verification failed.');
}
// CRITICAL: Update the counter. If the stored counter is higher than
// the new counter from verification, a cloned authenticator is in use.
const newCounter = verification.authenticationInfo.newCounter;
await db.query(
`UPDATE webauthn_credentials
SET counter = $1, last_used_at = now()
WHERE credential_id = $2 AND counter < $1`,
[newCounter, credentialId]
);
if (db.query.rowCount === 0) {
// Counter did not advance. Possible cloned authenticator.
throw new Error('Cloned authenticator detected. Credential revoked.');
}
// Return the authenticated user
return { verified: true, userId: credential.user_id };
}
The counter check with WHERE counter < $1 is a guard against race conditions. If two quick assertions arrive from the same credential (unlikely but possible), the second update gets zero row count even though no clone is present. A better approach is to use SELECT ... FOR UPDATE in a transaction, but for most workloads the query guard plus a soft revocation threshold (allow one non-advancing counter, revoke on two) is sufficient.
The client side
The browser side uses the @simplewebauthn/browser library, which wraps the navigator.credentials API. The two functions mirror the server ceremonies.
import { startRegistration } from '@simplewebauthn/browser';
async function registerPasskey() {
// 1. Get options from your server
const res = await fetch('/api/auth/passkey/register/begin', {
method: 'POST',
credentials: 'include',
});
const options = await res.json();
// 2. Start the browser registration ceremony
// This prompts the user for Touch ID / Windows Hello / etc.
const authResponse = await startRegistration({ optionsJSON: options });
// 3. Send the credential to your server for verification
const verifyRes = await fetch('/api/auth/passkey/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authResponse),
credentials: 'include',
});
const result = await verifyRes.json();
console.log(result.verified ? 'Passkey registered!' : 'Failed.');
}
The login side is nearly identical:
import { startAuthentication } from '@simplewebauthn/browser';
async function loginWithPasskey() {
const res = await fetch('/api/auth/passkey/login/begin', {
method: 'POST',
credentials: 'include',
});
const options = await res.json();
const authResponse = await startAuthentication({ optionsJSON: options });
const verifyRes = await fetch('/api/auth/passkey/login/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authResponse),
credentials: 'include',
});
const { verified, user } = await verifyRes.json();
if (verified) {
// Set session token, redirect to dashboard, etc.
window.location.href = '/dashboard';
}
}
The three traps that break WebAuthn in production
I have shipped passkey auth in two production services. Each time, the same three issues caused the most debugging time.
Trap 1: Origin and RPID must agree exactly
The WebAuthn spec says the rpId must match the origin’s effective domain. If your origin is https://app.yourapp.com, your rpID must be app.yourapp.com (not yourapp.com). If you use a subdomain for your API but your frontend is on another subdomain, the passkey cannot span both unless you set rpID to the registrable domain (yourapp.com). The catch: if you set rpID to yourapp.com, the origin must be https://yourapp.com or a subdomain of it. Any deviation and the authenticator returns a DOMException with a vague “The operation is not supported” message.
The fix is to pick one origin for all passkey operations and route all WebAuthn flows through it. If your API is at api.yourapp.com and your frontend is at app.yourapp.com, do the WebAuthn ceremony on app.yourapp.com and send the credential JSON to api.yourapp.com as a normal fetch. The origin check happens against the page origin, not the fetch target.
Trap 2: The challenge expires faster than the user authenticates
The default challenge lifetime in most WebAuthn tutorials is “a few minutes.” In practice, on mobile devices, the user may switch apps to use their password manager, the challenge sits in a detached tab, or the biometric prompt takes longer than expected. If the challenge expires before the user finishes, they get an opaque error.
Set the challenge timeout to at least 5 minutes, and show a clear “Challenge expired, please try again” error message on the client. Better yet, poll the server for a fresh challenge when the user initiates the flow, and refresh the challenge in the background if the first one expires.
Trap 3: Not all authenticators support discoverable credentials
WebAuthn supports two credential types: discoverable (resident) keys stored on the authenticator and server-side keys where the credential ID is stored on the server and passed to the authenticator during assertion. Platform authenticators (Touch ID, Windows Hello) support discoverable credentials. Roaming authenticators (some YubiKeys, security keys) may not.
If you use residentKey: 'required' in your authenticatorSelection, users with unsupported hardware cannot register. Use residentKey: 'preferred' (or omit it) and handle both cases. When a credential is not discoverable, pass its ID in allowCredentials during the assertion options so the authenticator knows which key to use.
The practical takeaway
Passkey authentication is production-ready in 2026. The libraries are mature, the browser support is universal, and the API surface has stabilized. The implementation cost is about 200 lines of TypeScript on the server and 40 lines on the client, and the security benefit is enormous: you eliminate password phishing, credential stuffing, and database-hash theft as attack vectors in one deploy.
Here is the migration playbook for an existing app:
- Add the two Postgres tables. Backfill nothing.
- Expose the registration endpoints. Add a “Add a passkey” button in account settings.
- Expose the login endpoints. Add a “Sign in with passkey” button on the login page.
- Let users who have registered a passkey bypass the password form entirely. For users without a passkey, keep the password form as a fallback.
- After 90 days, measure the login-method split. If more than 60% of active users have registered a passkey, consider making passkey the primary flow and password the fallback.
- Never remove password login entirely. There is always a user with a lost device, a locked iCloud Keychain, or a browser that does not support WebAuthn (mostly old Safari and some embedded WebViews).
A final note on UX: if your passkey registration fails silently on some devices, open the browser DevTools console and look for DOMException messages. The WebAuthn browser API throws exceptions with codes like NotAllowedError, AbortError, and SecurityError. Catch them explicitly and show human-readable messages.
try {
const authResponse = await startRegistration({ optionsJSON: options });
} catch (err: unknown) {
if (err instanceof DOMException) {
switch (err.name) {
case 'NotAllowedError':
// User cancelled the biometric prompt or no authenticator available
showToast('Biometric authentication was cancelled or unavailable.');
break;
case 'AbortError':
// Another registration ceremony was started (e.g., user clicked twice)
showToast('A registration was already in progress. Please try again.');
break;
case 'SecurityError':
// Origin or RPID mismatch
showToast('This browser does not support passkeys on this page.');
break;
default:
showToast('An unexpected error occurred. Please try again.');
}
}
}
Your users will never have to type a password into your site again. No password manager, no reset flow, no credential-stuffing protection. Just a biometric scan or a device PIN. That is the end state. The code above gets you there today.
A note from Yojji
Shipping passkey authentication correctly requires more than wiring up two endpoints. You have to handle the origin/rpID alignment, the counter-based clone detection, the challenge expiry race, and the subtle differences between platform and roaming authenticators across dozens of device configurations. This kind of production-security engineering is exactly what Yojji’s teams work on every day, building full-stack authentication systems and cloud-native applications for clients across Europe and the US. If your roadmap includes passkey support and you want it done right the first time, Yojji’s senior engineers can take it from the registration ceremony to the production deploy.
Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and building secure, production-grade systems from discovery through DevOps.