Passwords are having their worst year yet. Phishing kits now ship with built-in session-hijacking, credential-stuffing lists trade for pennies per account, and SMS one-time codes get intercepted by SIM-swap crews on a near-daily basis. The fix that finally has enough browser and OS support to matter at scale is WebAuthn, the W3C standard behind passkeys. On August 25, 2026, WebAuthn Level 3 became an official W3C Recommendation, locking in the usability improvements that made passkeys practical for mainstream login flows, according to the FIDO Alliance.
This tutorial walks through building a working passwordless login system with WebAuthn passkeys from scratch: a Node.js server, a browser client, registration (attestation) and login (assertion) ceremonies, and the database fields needed to store credentials safely. By the end you will have a running demo app, a clear mental model of how the cryptography actually works, and a troubleshooting reference for the errors that trip up almost every first implementation. The primary term here is WebAuthn, since that is the underlying browser API that makes passkeys possible, and every code example below uses it directly rather than treating it as a black box.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
What WebAuthn Passkeys Actually Replace
A passkey is a public-private key pair generated by a device authenticator, a phone, laptop, or security key, and bound to a specific website origin. The private key never leaves the device or its secure synchronization service. The website only ever receives a public key during registration and a signed assertion during login. There is no shared secret sitting in a database for an attacker to steal, and nothing for a user to type into a fake login page, because the browser enforces origin matching before it will even ask the authenticator to sign anything.
WebAuthn is the browser-facing API, navigator.credentials.create() and navigator.credentials.get(), that triggers these ceremonies. CTAP2.2 is the protocol spoken between the browser and an external authenticator such as a USB security key, and it now covers hybrid transport support, JSON-based messages, and persistent PIN state, among other additions. As an application developer you almost never touch CTAP directly. You implement the WebAuthn browser calls and the matching server-side verification, and the browser and operating system handle the rest.
Adoption numbers back up why this is worth the implementation effort. The FIDO Alliance has reported more than 15 billion user accounts with passwordless access via passkeys and over 1 billion passkey activations across consumer platforms, according to figures compiled by PanicVault’s 2026 passkey adoption tracker. Google alone has said roughly 800 million accounts now use passkeys, generating more than 2.5 billion sign-ins, while Microsoft reported registering close to one million new passkeys per day after making them the default for new consumer accounts in May 2025. Those are self-reported platform numbers, not independently audited figures, so treat them as directional rather than precise.
Prerequisites: What You Need Before You Start
You do not need a security key or a physical authenticator to follow this tutorial. Modern laptops and phones ship with platform authenticators, Touch ID, Windows Hello, Android fingerprint unlock, that work fine for local development. Here is what to have installed first:
- Node.js 20 LTS or newer, since WebAuthn server libraries rely on crypto primitives available in current Node releases.
- npm or pnpm for package management.
- @simplewebauthn/server (latest version from npm), which handles registration and authentication option generation plus verification on the server.
- @simplewebauthn/browser (latest version from npm), a thin wrapper around the raw
navigator.credentialscalls that handles base64url encoding for you. - mkcert, needed because WebAuthn requires a secure context. Localhost is treated as secure by browsers without a certificate, but testing on a real device over your LAN requires valid local HTTPS certificates.
- A current browser, such as Chrome, Edge, Firefox, or Safari, each with passkey support enabled by default in 2026.
- A device with a platform authenticator, Touch ID, Face ID, Windows Hello, or Android biometric unlock, or a FIDO2 security key as a fallback.
- Basic familiarity with Express.js and asynchronous JavaScript.
If you are implementing WebAuthn in Python, Go, Java, PHP, or .NET instead of Node, the ceremony logic stays the same, only the library calls differ. The comparison table later in this guide lists the equivalent package for each ecosystem.
How the Registration and Login Ceremonies Work
Every WebAuthn interaction is called a ceremony, and there are exactly two: registration, which creates a passkey, and authentication, which uses one to log in. Both follow the same three-step pattern. The server generates options, the browser collects a signed response from the authenticator, and the server verifies that response before trusting it.
During registration, the server sends a random challenge, a relying party (RP) identifier, typically your effective domain such as example.com, and a user object containing a stable, opaque user ID that is never an email address, since those can change. The browser calls navigator.credentials.create(), the authenticator generates a new key pair, and the browser returns an attestation object containing the public key, the credential ID, and a signature over the challenge and RP ID hash. The server verifies the challenge matches what it issued, confirms the origin and RP ID are correct, and stores the public key, credential ID, and a signature counter.
During authentication, the pattern repeats with navigator.credentials.get(). The server issues a fresh challenge, the authenticator signs it using the stored private key, and the server verifies the signature against the public key it saved during registration. No secret ever crosses the network in a form that could be replayed at a different site, because the signature is scoped to the exact origin and RP ID that requested it.
Step 1-3: Project Setup, HTTPS Certificates, and the Server Skeleton
Start by scaffolding the project and installing dependencies:
mkdir webauthn-demo && cd webauthn-demo
npm init -y
npm install express @simplewebauthn/server
npm install @simplewebauthn/browser
mkdir -p public certs
Local development over plain localhost is treated as a secure context by every current browser, so you can skip certificates for the first pass. If you need to test on a physical phone over your LAN, generate trusted local certificates with mkcert:
# macOS
brew install mkcert
mkcert -install
mkcert 192.168.1.50 localhost 127.0.0.1 ::1
# Produces a .pem cert and key pair you can load with Node's https module
Now create server.js with the basic Express skeleton and in-memory stores, which you should swap for a real database in production:
import express from "express";
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
const app = express();
app.use(express.json());
app.use(express.static("public"));
const rpName = "WebAuthn Demo";
const rpID = "localhost";
const origin = `http://${rpID}:3000`;
const userStore = new Map(); // userId -> { username, credentials: [] }
const challengeStore = new Map(); // userId -> current challenge
app.listen(3000, () => console.log("Server running on http://localhost:3000"));
Running node server.js at this point should print Server running on http://localhost:3000 with no errors. That confirms your dependencies resolved correctly before you add any ceremony logic.
Step 4-6: Building the Registration Endpoint
Add the endpoint that generates registration options. This is the server’s half of the passkey-creation ceremony:
app.post("/register/options", async (req, res) => {
const { username } = req.body;
const userId = Buffer.from(username).toString("base64url");
const user = userStore.get(userId) || { username, credentials: [] };
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: Buffer.from(userId),
userName: username,
attestationType: "none",
excludeCredentials: user.credentials.map((c) => ({
id: c.id,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: "required",
userVerification: "preferred",
},
});
challengeStore.set(userId, options.challenge);
userStore.set(userId, user);
res.json(options);
});
A few choices here matter. Setting attestationType to "none" is correct for the vast majority of consumer applications, since requiring full attestation reduces compatibility and raises privacy questions without a matching security benefit for a typical login flow. residentKey: "required" creates a discoverable credential, which enables usernameless login later. excludeCredentials stops a user from accidentally registering the same authenticator twice.
On the client, call this endpoint and hand the options to the browser’s WebAuthn API through SimpleWebAuthn’s browser helper:
import { startRegistration } from "@simplewebauthn/browser";
async function registerPasskey(username) {
const optionsRes = await fetch("/register/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
const options = await optionsRes.json();
let attestation;
try {
attestation = await startRegistration(options);
} catch (err) {
console.error("Registration ceremony failed:", err.name, err.message);
return;
}
const verifyRes = await fetch("/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, attestation }),
});
const result = await verifyRes.json();
console.log(result.verified ? "Passkey created" : "Registration failed");
}
Finally, verify the attestation on the server and persist the credential:
app.post("/register/verify", async (req, res) => {
const { username, attestation } = req.body;
const userId = Buffer.from(username).toString("base64url");
const expectedChallenge = challengeStore.get(userId);
const user = userStore.get(userId);
let verification;
try {
verification = await verifyRegistrationResponse({
response: attestation,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
} catch (err) {
console.error(err);
return res.status(400).json({ verified: false });
}
const { verified, registrationInfo } = verification;
if (verified && registrationInfo) {
user.credentials.push({
id: registrationInfo.credential.id,
publicKey: registrationInfo.credential.publicKey,
counter: registrationInfo.credential.counter,
transports: attestation.response.transports || [],
backedUp: registrationInfo.credentialBackedUp,
});
userStore.set(userId, user);
}
challengeStore.delete(userId);
res.json({ verified });
});
If this works, the browser presents its native passkey prompt, a Touch ID sheet, a Windows Hello dialog, or an Android biometric prompt, immediately after the fetch to /register/options resolves, and the console prints “Passkey created” once the round trip finishes. A typical successful JSON response from /register/verify looks like {"verified": true}.
Step 7-9: Building the Login (Authentication) Endpoint
Login mirrors registration but uses generateAuthenticationOptions and verifyAuthenticationResponse:
app.post("/login/options", async (req, res) => {
const { username } = req.body;
const userId = Buffer.from(username).toString("base64url");
const user = userStore.get(userId);
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: user
? user.credentials.map((c) => ({ id: c.id, transports: c.transports }))
: [],
userVerification: "preferred",
});
challengeStore.set(userId, options.challenge);
res.json(options);
});
app.post("/login/verify", async (req, res) => {
const { username, assertion } = req.body;
const userId = Buffer.from(username).toString("base64url");
const user = userStore.get(userId);
const expectedChallenge = challengeStore.get(userId);
const credential = user?.credentials.find((c) => c.id === assertion.id);
if (!credential) return res.status(400).json({ verified: false });
let verification;
try {
verification = await verifyAuthenticationResponse({
response: assertion,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential,
});
} catch (err) {
console.error(err);
return res.status(400).json({ verified: false });
}
if (verification.verified) {
credential.counter = verification.authenticationInfo.newCounter;
}
challengeStore.delete(userId);
res.json({ verified: verification.verified });
});
The client side is almost identical to registration, swapping startRegistration for startAuthentication:
import { startAuthentication } from "@simplewebauthn/browser";
async function loginWithPasskey(username) {
const optionsRes = await fetch("/login/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
const options = await optionsRes.json();
const assertion = await startAuthentication(options);
const verifyRes = await fetch("/login/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, assertion }),
});
const result = await verifyRes.json();
if (result.verified) window.location.href = "/dashboard";
}
With residentKey: "required" set during registration, you can also drop the username field entirely and call startAuthentication with an empty allowCredentials array. The browser then shows every discoverable passkey for your site and lets the user pick one, which is the usernameless flow most passkey-first products use today.
Step 10-11: Preventing Duplicate Registrations and Handling Backup Flags
Two details separate a demo from something production-ready: duplicate-registration handling and backup-flag awareness.
excludeCredentials, already wired into the registration options above, tells the browser which credential IDs the user already has for your site so the platform authenticator can refuse to create a second one silently. It is not a substitute for a server-side uniqueness check, but it does stop most accidental duplicate taps.
Every verified registration also returns a credentialBackedUp flag, the BS flag in the raw authenticator data, and a related credentialDeviceType value indicating whether the credential is eligible for backup at all, the BE flag. A synced passkey, one stored in iCloud Keychain, Google Password Manager, or a synced Windows Hello credential, can appear on a new device without the user re-registering, because the private key material travels with the account rather than the hardware. Store these flags so your risk engine can treat a sudden sign-in from an unfamiliar device differently for a synced credential versus a device-bound security key, rather than applying one blanket policy to both.
// After verifyRegistrationResponse succeeds
user.credentials.push({
id: registrationInfo.credential.id,
publicKey: registrationInfo.credential.publicKey,
counter: registrationInfo.credential.counter,
deviceType: registrationInfo.credentialDeviceType, // "singleDevice" | "multiDevice"
backedUp: registrationInfo.credentialBackedUp, // boolean
});
Do not treat a non-incrementing signature counter as proof of a cloned authenticator by itself. Many synced passkey implementations intentionally keep the counter at zero across devices, since the clone-detection model that counters were originally designed for assumes one physical authenticator rather than a key synced across a user’s whole device fleet. Log the anomaly, apply it as one risk signal among several, and avoid auto-locking accounts purely on counter behavior.
Storing Credentials: A Production Database Schema
The in-memory Map objects used so far make the ceremony logic easy to follow, but nothing in them survives a server restart. Before deploying, replace them with real tables. A WebAuthn credential record needs to capture everything the verification functions rely on, plus enough metadata to support account recovery and risk decisions later. A minimal relational schema looks like this:
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE webauthn_credentials (
credential_id TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
public_key BYTEA NOT NULL,
sign_count BIGINT NOT NULL DEFAULT 0,
transports TEXT[] DEFAULT '{}',
device_type TEXT NOT NULL, -- 'singleDevice' or 'multiDevice'
backed_up BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ
);
CREATE TABLE webauthn_challenges (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
challenge TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
A few column choices are worth calling out. public_key is stored as raw bytes, never as a reusable secret the way a password hash would be, since a public key leaking does not let anyone sign a new assertion. sign_count persists the counter from Step 10-11 so you can keep comparing it across logins, while device_type and backed_up carry the BE and BS flags discussed earlier. The separate webauthn_challenges table, keyed by user rather than appended to a generic sessions table, makes it trivial to enforce a short expiry and delete a challenge the moment it is consumed, which closes the replay window described in the pitfalls section below. Add a scheduled job that purges expired rows from webauthn_challenges every few minutes so abandoned ceremonies do not accumulate indefinitely.
Step 12-13: Deploying to Production and Testing Across Browsers
Moving from localhost to a real domain changes exactly two values: rpID becomes your production domain, such as example.com, which also covers subdomains like login.example.com, and origin becomes the full HTTPS origin your users actually see in the address bar. If your app sits behind a reverse proxy or load balancer, confirm that the proxy forwards the original Host header correctly and that your server reconstructs the public-facing origin rather than an internal one. This is one of the most common production breakages, and it only shows up once you deploy, since local testing never touches a proxy.
Test the full flow on at least three real browser and OS combinations before shipping: a Chromium browser on desktop, Safari on macOS or iOS, and Android Chrome. Each exercises a different authenticator path, Windows Hello or a synced Chrome passkey, iCloud Keychain, and Google Password Manager respectively, and subtle platform differences in conditional UI and cross-device hybrid flow only surface this way.
Browser and Platform Support in 2026
Passkey support is close to universal across evergreen browsers in 2026, but supported does not mean every optional feature behaves identically everywhere. A benchmark from Corbado’s Web Passkey Readiness report put Chrome’s readiness score at roughly 88% in early 2026, with Edge close behind around 85% and Firefox trailing at approximately 66%, largely due to gaps in conditional UI and cross-device hybrid transport support.
| Platform / Browser | Passkey Support | 2026 Notes |
|---|---|---|
| Chrome (desktop + Android) | Full support | Highest readiness score (about 88%); syncs via Google Password Manager |
| Microsoft Edge | Full support | Edge 142 added native passkey saving and syncing on Windows in November 2025 |
| Firefox | Partial support | Core WebAuthn works; conditional UI and hybrid flows lag (about 66% readiness) |
| Safari (macOS/iOS) | Full support | Syncs via iCloud Keychain; supports cross-device QR login |
| iOS / iPadOS | Full support | Native since iOS 16; deeply integrated with Face ID/Touch ID |
| Android | Full support | Supported from Android 9 onward via Google Password Manager |
| Windows 11 | Full support | Windows Hello provides the strongest desktop platform experience |
WebAuthn Libraries by Language
The ceremony logic in this tutorial translates directly to other server-side languages. Pin the exact version from your package registry at build time rather than assuming a specific release, since these projects update frequently.
| Language / Platform | Common Library | Install Command | Notes |
|---|---|---|---|
| Node.js | @simplewebauthn/server + @simplewebauthn/browser | npm install @simplewebauthn/server | Used throughout this tutorial |
| Python | webauthn (py_webauthn) | pip install webauthn | Mirrors SimpleWebAuthn’s generate/verify function pattern |
| Go | go-webauthn | go get github.com/go-webauthn/webauthn | Popular in Go-based identity services |
| Java | webauthn4j | Maven/Gradle dependency | Widely used in enterprise Spring applications |
| PHP | web-auth/webauthn-lib | composer require web-auth/webauthn-lib | Integrates with Symfony security bundles |
| .NET / C# | Fido2NetLib | dotnet add package Fido2 | Also used by ASP.NET Core Identity passkey support |
| Rust | webauthn-rs | cargo add webauthn-rs | Used in several self-hosted identity providers |
Passkeys vs Passwords vs SMS OTP vs TOTP
The reason teams are migrating to WebAuthn passkeys instead of adding yet another multi-factor layer on top of passwords comes down to phishing resistance. A password or an OTP code is just a string a user can be tricked into typing into the wrong site. A WebAuthn signature is cryptographically bound to the origin that requested it, so a phishing clone of your login page simply cannot obtain a valid signature, even if the victim is fully fooled by the fake page’s appearance.
| Method | Phishing Resistant | Depends On | Typical Failure Mode |
|---|---|---|---|
| WebAuthn Passkey | Yes | Device authenticator + origin binding | Lost device without backup/sync enabled |
| Password | No | User memory or password manager | Reuse, phishing, database breach |
| SMS OTP | No | Carrier network, phone number | SIM swap, interception, user disclosure to attacker |
| TOTP (authenticator app) | No | Shared secret + device clock | User pastes code into phishing site |
| Roaming FIDO2 security key | Yes | Physical hardware token | Key is lost or left at home/office |
Passwords and OTPs are not disappearing overnight. Most production rollouts of WebAuthn keep a password or recovery flow available as a fallback, since account recovery for a passkey-only account is an unsolved problem for users who lose every device at once. According to Clerk’s 2026 authentication trends analysis, the practical pattern most teams land on is passkeys as the primary sign-in method with a monitored, rate-limited fallback rather than a hard passkey-only requirement.
5 Common Pitfalls When Implementing WebAuthn
- RP ID mismatch across subdomains. A credential registered with
rpID: "app.example.com"cannot be used to log in atexample.com. Pick the broadest domain your login flow will ever run on and use that consistently. - Origin mismatch behind a reverse proxy. If your app runs behind nginx, a CDN, or a load balancer, the server may compute an internal origin that does not match what the browser actually saw. Always validate against the exact public-facing HTTPS origin.
- Deriving the user ID from an email address. WebAuthn’s
user.idmust be a stable, opaque identifier. Emails change, get reused, or get normalized differently across systems, so use an internal user ID or a random opaque value instead. - Requiring attestation without a real need for it. Setting
attestationTypeto anything other than"none"for a typical consumer login reduces authenticator compatibility and raises unnecessary privacy questions. Reserve attestation requirements for enterprise or regulatory scenarios that genuinely need device provenance. - Not expiring or reusing challenges. Every ceremony needs a fresh, single-use challenge bound to the session. Reusing a stored challenge or forgetting to delete it after verification opens a replay window.
Troubleshooting: 8 WebAuthn Errors and How to Fix Them
Client-side errors
- NotAllowedError. The most common error. Causes include the user canceling the prompt, the ceremony timing out, a second credential operation already running, or the API being called outside a user gesture. Fix: ensure the call originates from a click handler, avoid overlapping create and get calls, and raise the timeout modestly if users are hitting it legitimately.
- SecurityError. Usually an insecure context (non-HTTPS outside localhost), an RP ID that is not a valid suffix of the current origin’s hostname, or a cross-origin iframe missing the right permissions policy. Fix: serve over HTTPS, log both the browser’s visible origin and the server’s expected origin, and confirm they match exactly.
- InvalidStateError. Thrown when the authenticator already has a credential for this RP and user combination. Fix: pass
excludeCredentialsduring registration and surface this as a normal “you already have a passkey” message rather than a crash. - NotSupportedError. No available authenticator satisfies the requested algorithms or authenticator requirements. Fix: include both ES256 (
-7) and RS256 (-257) inpubKeyCredParamsfor broader compatibility, and avoid overly strictauthenticatorAttachmentsettings unless you specifically need to exclude roaming keys.
Server-side and integration errors
- ConstraintError. The authenticator cannot meet a required option, most often
residentKey: "required"on an older security key that does not support discoverable credentials. Fix: fall back toresidentKey: "preferred"if you need to support older hardware tokens. - AbortError. The operation was aborted, typically via an AbortController signal tied to a timeout or a page navigation mid-ceremony. Fix: make sure your abort logic isn’t firing prematurely, and don’t navigate away from the page while a ceremony is pending.
- Challenge-mismatch verification failure on the server. This almost always means the challenge was never stored, was stored under the wrong key, or expired before verification ran. Fix: confirm the challenge store key matches exactly between the options and verify endpoints, and check for premature deletion.
- RP ID hash validation failure after deployment. Works locally, fails in production. Fix: this is nearly always the reverse-proxy origin problem described earlier. Log the computed origin on every request during a staging deploy and compare it against what the browser actually sends.
Advanced Tips: Conditional UI, Usernameless Login, and Attestation Policy
Once the basic flow works, a few refinements take the experience from functional to genuinely frictionless:
- Conditional UI (autofill). Pair an
<input autocomplete="username webauthn">field withstartAuthentication({ useBrowserAutofill: true })so the browser surfaces saved passkeys directly in the username field’s autofill dropdown, without a separate sign-in-with-a-passkey button. - Usernameless, discoverable login. With
residentKey: "required"at registration, you can skip the username field entirely and callgenerateAuthenticationOptionswith an emptyallowCredentialsarray. The platform shows every passkey registered for your domain and lets the user choose. - Cross-device hybrid transport. When a user is on a desktop without a platform authenticator, or on Firefox where support is weaker, the browser can show a QR code that a paired phone scans to complete the ceremony over a secure Bluetooth-assisted channel. No extra code is required, since this is built into the browser’s WebAuthn implementation when a phone is nearby.
- Risk-based step-up. Combine the backed-up and device-type flags with IP and device-fingerprint signals to decide when to require a second passkey ceremony or a fallback factor for a sensitive action, rather than applying the same policy to every sign-in.
- Enterprise attestation. If you operate in a regulated environment that needs to verify the exact make and model of authenticator in use, set
attestationType: "direct"and validate the attestation certificate chain against an authenticator metadata service. Most consumer-facing products should not do this.
Migrating Existing Password Users to Passkeys
Almost no production application gets to start from zero. Most teams implementing WebAuthn are retrofitting it onto an existing user base that already has passwords, sessions, and years of account history. The rollout pattern that tends to work is incremental rather than a single cutover.
Start by offering passkey registration as an opt-in action inside account settings, behind an existing authenticated session, rather than forcing it at sign-up. This sidesteps a hard problem: you cannot safely run a full WebAuthn registration ceremony for a brand-new, unauthenticated user without some other way to first confirm they are who they claim to be, since the entire point of the ceremony is binding a credential to an already-trusted identity. Prompting an existing logged-in user to add a passkey, the same way you would prompt them to add a second email address, avoids that chicken-and-egg problem entirely.
Once a user has registered at least one passkey, update your login page so it checks for a discoverable credential first, using the conditional UI pattern covered in the advanced tips section, and only falls back to the password field if nothing is found or the user cancels. Track two numbers as this rolls out: the percentage of active users who have enrolled a passkey, and the percentage of logins actually completed with one rather than a password. The gap between those two numbers, which the FIDO Alliance’s own data shows is still wide industry-wide, tells you whether your UI is actually surfacing the passkey option or whether it is enrolled but effectively hidden behind habit. Only consider removing the password field entirely once that completion rate is high and your account-recovery flow has been tested independently of passwords.
Real-World Passkey Adoption in 2026
The FIDO Alliance’s 2025 Passkey Index, cited in PanicVault’s 2026 compilation, reported approximately 93% account eligibility for passkeys among participating services, but only around 36% actual enrollment and roughly 26% of sign-ins completed with a passkey rather than a password. That gap between eligibility and real usage is the current frontier: the technology works, but migrating existing password-based user bases takes sustained prompting, clear UX, and patience. Companies publicly supporting passkey sign-in in 2025 and 2026 include Google, Apple, Microsoft, Amazon, PayPal, GitHub, TikTok, LinkedIn, Coinbase, eBay, and X, though exact per-company adoption figures are self-reported and vary by measurement methodology, so treat vendor-specific numbers as directional rather than comparable across companies.
An industry estimate from Corbado’s 2026 benchmark put passkey support at roughly 50 to 60% of the top 100 global websites, a figure the report itself frames as an estimate rather than an audited count. Either way, the direction is clear: passkey support has gone from a novelty on a handful of sites in 2023 to a baseline expectation for any consumer login flow shipped in 2026.
The Complete Working Project
Put together, the project from this tutorial has four pieces: a Node.js and Express server, server.js, exposing four endpoints, /register/options, /register/verify, /login/options, and /login/verify, backed by an in-memory user and challenge store that you would replace with a real database in production; a static HTML page in the public folder with username and “Create Passkey” or “Sign In” controls; a client script wiring those controls to startRegistration and startAuthentication from @simplewebauthn/browser; and, for any real deployment, a production rpID and origin pointed at your actual domain behind valid HTTPS. Running node server.js and opening http://localhost:3000 in a current browser gives you a fully functional passwordless registration and login loop you can extend with session cookies, a real database layer, and the conditional UI and risk-policy refinements covered above.
Frequently Asked Questions
What is the difference between WebAuthn and a passkey?
WebAuthn is the W3C browser API and protocol. A passkey is the user-facing name for the credential it creates and manages, the same underlying technology described in consumer-friendly terms.
Do users need a physical security key to use passkeys?
No. Most passkeys in 2026 are platform credentials stored in a phone’s or laptop’s built-in authenticator and synced through iCloud Keychain, Google Password Manager, or Windows Hello. Physical FIDO2 security keys remain an option for higher-assurance scenarios or users without a compatible platform authenticator.
Can a WebAuthn passkey be phished?
Not in the way a password or OTP can. The browser cryptographically binds every signature to the exact origin that requested it, so a convincing visual clone of a login page still cannot obtain a valid signature from the user’s authenticator.
What happens if a user loses every device with their passkey?
This is the main open UX problem with passkey-only accounts. Most production implementations keep a monitored, rate-limited fallback path, such as email verification, support-assisted recovery, or a backup password, rather than relying on passkeys exclusively.
Do passkeys work for local development on localhost?
Yes. Browsers treat http://localhost as a secure context for WebAuthn purposes, so you can build and test the full registration and login flow without certificates until you need to test on a separate physical device.
Is implementing WebAuthn expensive or does it require a paid service?
No. WebAuthn is a free, open W3C standard, and the server libraries used in this tutorial, SimpleWebAuthn and its equivalents in other languages, are open source. Managed identity platforms exist if you would rather not run verification logic yourself, but they are optional.
Can I offer passkeys alongside existing passwords during a migration?
Yes, and this is the standard rollout pattern. Add passkey registration as an opt-in option for logged-in users, let both sign-in paths coexist, and track enrollment and completed-sign-in rates before deciding whether to make passkeys mandatory.
Do passkeys replace multi-factor authentication entirely?
For most threat models, yes. A passkey ceremony already combines something the user has, the device, with something the user is or knows, a biometric or PIN unlock, which satisfies the intent behind MFA in a single phishing-resistant step. Highly regulated environments may still layer additional controls on top.
Why does my registration fail with InvalidStateError only on the second attempt?
The authenticator already holds a credential for this relying party and user combination from the first attempt. Pass the user’s existing credential IDs through excludeCredentials so the platform authenticator and your UI both treat a repeat registration as an expected, user-friendly case rather than an error.
Should I store the raw attestation object after registration?
No, not beyond what verification needs. Persist the credential ID, the public key, the signature counter, the transports, and the backup flags. The full attestation object and any attestation certificate chain can be discarded once verification succeeds unless you have a specific enterprise-attestation requirement that needs them retained for audit.





