Cybersecurity · Authentication
Passkeys Are Ready: Implementing Passwordless Auth in Your Web App
Passkeys are no longer an experimental feature. Apple, Google, and Microsoft all support them natively. Here's what WebAuthn actually looks like in code and when passkeys make sense for your app.
Anurag Verma
8 min read
Sponsored
Passwords have a well-documented problem: people reuse them, forget them, and give them to phishing sites. Two-factor authentication helps but adds friction. The industry’s answer, passkeys, has been in development since 2019 and quietly became mainstream in 2023-2024. By mid-2026, every major platform supports them natively. The browser APIs are stable. The user experience works.
What’s still missing is a clear guide to what passkeys actually are in code, not in marketing.
What a Passkey Is
A passkey is a credential based on public-key cryptography. When a user registers a passkey, the device generates a key pair: the private key stays on the device, and the public key is sent to your server. When the user authenticates, the device signs a challenge from your server with the private key; your server verifies that signature with the stored public key.
No shared secret is ever transmitted. Phishing doesn’t work because the signed challenge is bound to the origin. A fake site can’t use a signature intended for your domain. There’s no database of password hashes to breach. Replay attacks don’t work because each challenge is single-use.
The protocol underneath this is WebAuthn (Web Authentication API), standardized by W3C. Passkeys are the consumer-facing name for synced WebAuthn credentials, backed up to iCloud Keychain, Google Password Manager, or 1Password, so they survive device loss.
The WebAuthn Flow in Two Steps
Registration
When a user creates a passkey, your server generates a challenge and sends it to the browser. The browser calls the WebAuthn API, which prompts the user to authenticate with their device (Face ID, fingerprint, Windows Hello, or a hardware key). The authenticator creates a key pair and returns a credential object with the public key.
// Server: generate registration options
// Using @simplewebauthn/server (recommended library)
import { generateRegistrationOptions } from '@simplewebauthn/server';
const options = await generateRegistrationOptions({
rpName: 'Your App',
rpID: 'yourapp.com',
userName: user.email,
userID: Buffer.from(user.id),
attestationType: 'none', // no attestation required for most apps
authenticatorSelection: {
residentKey: 'required', // store credential on device (passkey)
userVerification: 'preferred',
},
});
// Store options.challenge in the session — you'll need it to verify
req.session.currentChallenge = options.challenge;
res.json(options);
// Browser: call WebAuthn API
import { startRegistration } from '@simplewebauthn/browser';
async function registerPasskey() {
const optionsJSON = await fetch('/auth/register/options').then(r => r.json());
const credential = await startRegistration(optionsJSON);
const verification = await fetch('/auth/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
});
const result = await verification.json();
if (result.verified) {
// Passkey registered successfully
}
}
// Server: verify registration response
import { verifyRegistrationResponse } from '@simplewebauthn/server';
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: req.session.currentChallenge,
expectedOrigin: 'https://yourapp.com',
expectedRPID: 'yourapp.com',
});
if (verification.verified) {
// Save credential to database
await db.passkeys.create({
userId: req.session.userId,
credentialID: verification.registrationInfo.credentialID,
credentialPublicKey: verification.registrationInfo.credentialPublicKey,
counter: verification.registrationInfo.counter,
});
}
Authentication
At login, your server generates a challenge and sends a list of the user’s registered credential IDs (or an empty list for discoverable credentials). The device finds the matching key, signs the challenge, and returns the signed assertion.
// Server: generate authentication options
import { generateAuthenticationOptions } from '@simplewebauthn/server';
// For known users (they already entered email):
const passkeys = await db.passkeys.findMany({ where: { userId: user.id } });
const options = await generateAuthenticationOptions({
rpID: 'yourapp.com',
allowCredentials: passkeys.map(key => ({
id: key.credentialID,
type: 'public-key',
})),
userVerification: 'preferred',
});
req.session.currentChallenge = options.challenge;
res.json(options);
// Browser: prompt for authentication
import { startAuthentication } from '@simplewebauthn/browser';
async function loginWithPasskey() {
const optionsJSON = await fetch('/auth/login/options').then(r => r.json());
const credential = await startAuthentication(optionsJSON);
const result = await fetch('/auth/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
}).then(r => r.json());
if (result.verified) {
window.location.href = '/dashboard';
}
}
// Server: verify authentication response
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
const passkey = await db.passkeys.findUnique({
where: { credentialID: req.body.id },
});
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: req.session.currentChallenge,
expectedOrigin: 'https://yourapp.com',
expectedRPID: 'yourapp.com',
credential: {
id: passkey.credentialID,
publicKey: passkey.credentialPublicKey,
counter: passkey.counter,
},
});
if (verification.verified) {
// Update the counter — this detects cloned authenticators
await db.passkeys.update({
where: { credentialID: req.body.id },
data: { counter: verification.authenticationInfo.newCounter },
});
// Issue session token
}
Database Schema
Each user can have multiple passkeys (different devices, hardware keys). Store them separately:
CREATE TABLE passkeys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id BYTEA NOT NULL UNIQUE,
public_key BYTEA NOT NULL,
counter BIGINT NOT NULL DEFAULT 0,
device_type TEXT, -- 'platform' (phone/laptop) or 'cross-platform' (hardware key)
backed_up BOOLEAN NOT NULL DEFAULT false, -- is this synced passkey or device-bound?
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ
);
The counter field is critical. Every authentication increments the counter. If your server sees a counter lower than the stored value, someone may have cloned the authenticator. For synced passkeys (iCloud Keychain, Google), the counter is always 0. Synced credentials don’t use monotonic counters. Check the backed_up flag and handle accordingly.
Discoverable Credentials (Autofill)
Passkeys support a “conditional UI” mode where the browser shows available passkeys directly in the autofill dropdown when a user focuses the email field. No email entry required. The user just picks their passkey from the list.
<!-- Mark the input for passkey autofill -->
<input
type="email"
autocomplete="username webauthn"
placeholder="Email address"
/>
// Start authentication with mediation: 'conditional'
const optionsJSON = await fetch('/auth/login/options-conditional').then(r => r.json());
startAuthentication(optionsJSON, true); // second arg enables conditional UI
// This doesn't await — it runs in the background
// The promise resolves when the user picks a passkey from autofill
On the server side, use empty allowCredentials for the conditional options endpoint. You don’t know which user is logging in yet. After the authentication response comes back, look up the user by the credential ID.
When to Offer Passkeys vs When to Require Them
Passkeys work well as an additional authentication method, not a forced replacement. Some users won’t have compatible devices. Some platforms (older Android, some enterprise environments) have limited support.
A practical rollout for an existing app:
- Add passkeys as an optional auth method alongside passwords.
- Prompt users to register a passkey after a successful password login.
- Show a “Use passkey” option on the login page; fall back to password.
- Once a user has a registered passkey, offer to let them remove their password.
- Track adoption and increase the passkey nudge if it’s working.
For new apps targeting consumer audiences (mobile, fintech, healthcare), making passkeys the primary flow from the start is worth considering. The UX is genuinely better than passwords, and browser support in 2026 is good enough that most users will hit the happy path.
Libraries and Services
Building the full WebAuthn flow yourself is error-prone. The cryptographic verification has subtle edge cases. Use a library:
| Option | Type | Notes |
|---|---|---|
@simplewebauthn/server + @simplewebauthn/browser | Open source library | Most popular; framework-agnostic; TypeScript support |
| Hanko | Hosted service | Passkey-first, free tier for dev, embeddable UI component |
| Auth0 (with passkey add-on) | Hosted service | Enterprise-grade; passkeys as one factor |
| Passage by 1Password | Hosted service | Good developer experience; REST API |
For apps where auth is not the differentiator, a hosted service saves weeks. For apps that need full control (healthcare, financial services, self-hosted), @simplewebauthn with your own backend is the right call.
Common Mistakes
Not updating the counter. If you don’t update the stored counter after each authentication, the replay detection doesn’t work. The counter update and the session creation should be in a transaction.
Single-origin assumption. If your app runs on multiple origins (app.example.com and example.com), the expectedOrigin must be an array. Passkeys are bound to the exact origin used during registration.
No account recovery. Passkeys don’t have a “forgot password” equivalent. If a user loses all their devices, they need another way in. Email verification code, backup codes, or support-assisted recovery: pick one and build it before launching passkeys.
Missing iOS 16 / Android 9 cutoffs. WebAuthn support for synced passkeys requires iOS 16+ and Android 9+ (Chrome 108+). Older devices get hardware key support only. Know your user base before removing passwords.
Passkeys have been “coming soon” long enough that it’s easy to treat them as still experimental. They’re not. The browser APIs are stable, the platform support is broad, and the security properties are better than anything achievable with passwords and TOTP. The implementation complexity, using a library, is about the same as any other auth method.
Sponsored
More from this category
More from Cybersecurity
Secrets Management in 2026: Vault, Doppler, AWS Secrets Manager, and When .env Is Fine
Container Security in 2026: Image Scanning, SBOMs, and What Teams Actually Do
Prompt Injection in 2026: The Attack Your AI App Probably Isn't Defending Against
Sponsored
The dispatch
Working notes from
the studio.
A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored