惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
V2EX - 技术
D
DataBreaches.Net
阮一峰的网络日志
阮一峰的网络日志
Recent Announcements
Recent Announcements
V
V2EX
Hugging Face - Blog
Hugging Face - Blog
T
The Exploit Database - CXSecurity.com
Simon Willison's Weblog
Simon Willison's Weblog
Cisco Talos Blog
Cisco Talos Blog
Microsoft Security Blog
Microsoft Security Blog
C
Cyber Attacks, Cyber Crime and Cyber Security
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
K
Kaspersky official blog
F
Fortinet All Blogs
GbyAI
GbyAI
Forbes - Security
Forbes - Security
The Cloudflare Blog
博客园 - 司徒正美
博客园_首页
量子位
Schneier on Security
Schneier on Security
G
GRAHAM CLULEY
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
P
Proofpoint News Feed
N
News | PayPal Newsroom
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 聂微东
T
Tor Project blog
V
Vulnerabilities – Threatpost
Y
Y Combinator Blog
Jina AI
Jina AI
Help Net Security
Help Net Security
T
Threat Research - Cisco Blogs
Recent Commits to openclaw:main
Recent Commits to openclaw:main
C
Cybersecurity and Infrastructure Security Agency CISA
Project Zero
Project Zero
N
News and Events Feed by Topic
I
Intezer
B
Blog
美团技术团队
C
CERT Recently Published Vulnerability Notes
NISL@THU
NISL@THU
L
LINUX DO - 最新话题
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Blog — PlanetScale
Blog — PlanetScale
AWS News Blog
AWS News Blog
T
Tailwind CSS Blog
The Last Watchdog
The Last Watchdog
雷峰网
雷峰网
有赞技术团队
有赞技术团队

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution Node.js Cluster Mode: Scaling the Event Loop Across CPU Cores Postgres Partial Indexes: Stopping Soft Deletes from Ruining Your Query Performance Request Coalescing with the Singleflight Pattern: Stop Drowning Your Database on Every Cache Miss The Bulkhead Pattern: Why One Slow Endpoint Should Not Drown Your Whole Service Node.js AsyncLocalStorage: End-to-End Request Context Without the Propagation Hell Postgres Deadlocks: Logging the Victim, Reproducing the Race, and Fixing the Lock Order Your Node.js HTTP Client Is the Bottleneck: Connection Pool Tuning That Works Optimistic Locking in Postgres: Stop Losing Data to Race Conditions Postgres Read Replicas: Stop Serving Stale Data to Your Users Cursor Pagination: Why Offset Queries Explode at Scale and How to Fix Them Node.js Worker Threads: 60 Lines That Stop a CSV Upload from Timing Out Every Other Request Reliable Webhook Delivery: Architecture for Outbound HTTP You Can Trust Request Timeouts and Deadline Propagation: Stop the Chain of Slowness Advanced Security Practices in Node.js Graceful Shutdown in Node.js: The 40 Lines That Stop 502s During Deploys Finding Node.js Memory Leaks with Heap Snapshots Idempotency Keys in 30 Lines: Stop Your Webhook From Charging Customers Twice Backpressure In Node.js: The Fix For Slow-Motion Queue Meltdowns Retries Done Right: Jitter, Budgets, and the Stampede You Did Not See Coming The Cache Stampede: Why Your "Just Add Redis" Layer Crashes Postgres at 3 a.m. Postgres SKIP LOCKED: An 80-Line Job Queue You Can Run Without Redis Stop Doing Work Nobody Wants: AbortController in Node.js, Done Right The N+1 Query Problem: We Found 23 In One Codebase And Killed Every One I Tried 5 AI Coding Tools for a Month. Here Is What I Actually Use CI/CD From Zero to Production in 30 Minutes With GitHub Actions Node.js vs Bun vs Deno: Which Runtime Should You Pick in 2025? Kubernetes Resource Requests And Limits: The Numbers That Decide If Your Cluster Is Stable The Three Pillars of Observability Are A Myth: What Actually Matters In Production pnpm Vs npm Vs yarn Vs Bun For Monorepos: Which One Earns The Migration In 2024 JSONB Indexing In Postgres: GIN Vs Expression Indexes, And When Each Is The Right Choice A Code Review Checklist That Ends The Same Three Arguments Every Sprint gRPC Vs REST In 2024: When The Switch Pays For Itself React Suspense For Data Fetching: The Pattern That Replaces Half Your Loading State Code The Five-Stage Rollout: How To Ship A Risky Change Without Holding Your Breath GitHub Actions In A Monorepo: Caching, Path Filters, And Secret Boundaries That Actually Work The Blameless Postmortem That Actually Improves Things: A Template And Six Hard-Won Rules Recursive CTEs In Postgres: How To Query A Tree Without N Round Trips Node.js Streams: When They Actually Help, And When They Just Add Complexity Playwright Vs Cypress In 2024: The Honest Comparison Of Which One Earns The Test Time React Server Components: The Mental Model That Makes The "use client" Boundary Obvious Pod Disruption Budgets: The K8s Object That Keeps Your Service Up During Cluster Maintenance Postgres LISTEN/NOTIFY: The Pub/Sub You Already Have And Are Not Using Chaos Engineering Starter Kit: The Five Drills That Don't Need Netflix-Scale Spec-Driven API Development With OpenAPI: How To Stop Drifting From Your Docs Kubernetes Autoscaling Beyond CPU: The Custom-Metric HPA Pattern That Actually Works Postgres Partitioning For Time-Series: The Boring Setup That Saves Your Database Distributed Locks With Redis: An Honest Look At Redlock And When You Don't Need It HTTP/2 vs HTTP/3: What Actually Changes For Your App, And What Doesn't Image Optimization For The Web In 2023: srcset, AVIF, And The Lighthouse Score You Actually Want Kafka vs RabbitMQ: A Decision Tree That Doesn't Hate You UUID vs Bigint Primary Keys In Postgres: The Index Math That Decides For You Flame Graphs: How To Find The Slow Function In 30 Seconds Without Profiling Theatre Postgres Streaming Vs. Logical Replication: Which One Solves Your Actual Problem ESLint Rules That Earn Their Keep: The Twelve I Enable On Every Project Pre-Commit Hooks That Pay For Themselves: Husky, lint-staged, And The Five Rules That Stick Zero-Downtime Database Migrations: The Six-Step Pattern That Rules Them All Circuit Breakers In Node.js: 50 Lines That Stop A Failing Dependency From Taking Down Your Service Postgres VACUUM Is Not Magic: How Your Hot Table Bloats To 80GB And How To Fix It Kubernetes Liveness And Readiness Probes: The Difference That Causes Half Your Outages Rate Limiting In Production: A Token Bucket In 30 Lines Of Redis The Outbox Pattern: How To Stop Losing Events When Postgres And Kafka Disagree Load Testing With k6: The Three Scenarios That Find Real Bugs (Not Synthetic Numbers) Postgres Row-Level Security For Multi-Tenant Apps: The Pattern That Stops You From Leaking Data Rebase vs. Merge: The Team Policy That Ends The Argument Forever OpenTelemetry in Node.js: Distributed Tracing That Actually Helps During an Incident Feature Flags That Pay Rent: The 4 Flag Types And When To Delete Each ETag, Last-Modified, and the Caching Headers Most APIs Get Wrong Connection Pooling Without the Cargo Cult: pgbouncer in 100 Lines of Config JSONB Is Not a Schema: When To Reach For It in Postgres, And When To Stop Bash Strict Mode: The Three Lines That Stop Your Deploy Script From Lying To You
Passkey (WebAuthn) Authentication for Node.js APIs
The Practica · 2026-06-07 · via The Practical Developer

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:

  1. The server generates a random challenge and sends it to the client.
  2. The client asks the authenticator (e.g., Touch ID) to sign the challenge with a newly generated or existing private key.
  3. 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:

  1. Add the two Postgres tables. Backfill nothing.
  2. Expose the registration endpoints. Add a “Add a passkey” button in account settings.
  3. Expose the login endpoints. Add a “Sign in with passkey” button on the login page.
  4. Let users who have registered a passkey bypass the password form entirely. For users without a passkey, keep the password form as a fallback.
  5. 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.
  6. 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.