- Published on
Passkeys in 2026 — Replacing Passwords in Your Production App
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Passwords are dead in 2026. Apple, Google, and Microsoft mandate passkey support in all platforms. Your users expect passwordless login. Passkeys replace passwords with cryptographic keys stored in iCloud Keychain, Google Password Manager, or Windows Hello.
This post covers building production-ready passkey authentication: WebAuthn flow, credential storage, cross-device sync, and fallback strategies for users without passkey support.
- State of Passkey Adoption in 2026
- @simplewebauthn/server + @simplewebauthn/browser
- Registration Flow
- Authentication Flow
- Storing Credential Data in PostgreSQL
- Cross-Device Passkeys via iCloud Keychain/Google Password Manager
- Passkey Fallback Strategy
- Adding Passkeys to Existing Password Auth
- Conditional UI (Auto-Fill Passkeys)
- Passkey Analytics
- Checklist
- Conclusion
State of Passkey Adoption in 2026
By 2026, passkey adoption is mainstream:
- iOS 15+: iCloud Keychain passkeys integrated into iOS
- Android: Google Password Manager and PasskeyKit support
- Windows: Windows Hello biometric passkeys
- Desktop Chrome/Edge: Passkey support via system authenticators
- Safari: Cross-device passkeys via iCloud
Adoption rates: 40% of users on Apple devices, 25% on Android, 15% on Windows. Fallback to passwords is still necessary for legacy systems and users without compatible devices.
@simplewebauthn/server + @simplewebauthn/browser
SimpleWebAuthn is the battle-tested library for WebAuthn. Use it.
import { generateRegistrationOptions } from '@simplewebauthn/server';
import { verifyRegistrationResponse } from '@simplewebauthn/server';
// Server: generate challenge for registration
const options = await generateRegistrationOptions({
rpID: 'example.com',
rpName: 'My App',
userID: userId,
userName: userEmail,
userDisplayName: userEmail,
});
// Store challenge in session
req.session.challenge = options.challenge;
return res.json(options);
On the client:
import { startRegistration } from '@simplewebauthn/browser';
const attResp = await startRegistration(options);
// Browser shows biometric prompt, user authenticates
// Send response back to server
const response = await fetch('/api/auth/register-verify', {
method: 'POST',
body: JSON.stringify(attResp),
});
SimpleWebAuthn handles browser quirks. Use it.
Registration Flow
Step 1: Challenge generation
Server generates a unique, random challenge (32 bytes). This prevents replay attacks.
Step 2: Browser cryptography
Browser (via WebAuthn API) generates a keypair. Private key stays on device. Public key goes to server.
Step 3: Attestation verification
Server verifies the public key is genuine (from a real authenticator, not a fake).
Step 4: Credential storage
Server stores the public key in the database.
// Complete registration flow
app.post('/api/auth/register-options', async (req, res) => {
const { email } = req.body;
const user = await db.users.findUnique({ where: { email } });
if (!user) return res.status(400).json({ error: 'User not found' });
const options = await generateRegistrationOptions({
rpID: 'example.com',
rpName: 'My App',
userID: user.id,
userName: user.email,
userDisplayName: user.email,
authenticatorSelection: {
authenticatorAttachment: 'platform', // Touch ID, Windows Hello, etc.
userVerification: 'preferred',
},
});
req.session.registrationChallenge = options.challenge;
req.session.userId = user.id;
return res.json(options);
});
app.post('/api/auth/register-verify', async (req, res) => {
const { response } = req.body;
const verified = await verifyRegistrationResponse({
response,
expectedChallenge: req.session.registrationChallenge,
expectedRPID: 'example.com',
expectedOrigin: 'https://example.com',
});
if (!verified.verified) {
return res.status(400).json({ error: 'Registration failed' });
}
// Store credential
await db.passkeyCredentials.create({
data: {
userId: req.session.userId,
credentialID: Buffer.from(verified.registrationInfo!.credentialID).toString(
'base64'
),
credentialPublicKey: Buffer.from(
verified.registrationInfo!.credentialPublicKey
).toString('base64'),
counter: verified.registrationInfo!.counter,
transports: verified.registrationInfo!.transports,
},
});
return res.json({ success: true });
});
Authentication Flow
Step 1: Challenge generation
Server generates a new challenge. User provides email.
Step 2: Browser authentication
Browser prompts for biometric. User authenticates with fingerprint or face.
Step 3: Signature verification
Browser signs the challenge with the private key. Signature is sent to server.
Step 4: Signature validation
Server verifies the signature using the stored public key.
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
app.post('/api/auth/login-options', async (req, res) => {
const { email } = req.body;
const user = await db.users.findUnique({
where: { email },
include: { passkeyCredentials: true },
});
if (!user || user.passkeyCredentials.length === 0) {
return res.status(400).json({ error: 'No passkey found' });
}
const options = await generateAuthenticationOptions({
rpID: 'example.com',
allowCredentials: user.passkeyCredentials.map((cred) => ({
id: Buffer.from(cred.credentialID, 'base64'),
type: 'public-key',
transports: cred.transports as AuthenticatorTransport[],
})),
});
req.session.authChallenge = options.challenge;
req.session.userId = user.id;
return res.json(options);
});
app.post('/api/auth/login-verify', async (req, res) => {
const { response } = req.body;
const user = await db.users.findUnique({
where: { id: req.session.userId },
include: { passkeyCredentials: true },
});
const credential = user!.passkeyCredentials.find(
(c) => c.credentialID === response.id
);
const verified = await verifyAuthenticationResponse({
response,
expectedChallenge: req.session.authChallenge,
expectedRPID: 'example.com',
expectedOrigin: 'https://example.com',
credential: {
credentialID: Buffer.from(credential!.credentialID, 'base64'),
credentialPublicKey: Buffer.from(credential!.credentialPublicKey, 'base64'),
counter: credential!.counter,
},
});
if (!verified.verified) {
return res.status(400).json({ error: 'Authentication failed' });
}
// Update counter to prevent replay attacks
await db.passkeyCredentials.update({
where: { id: credential!.id },
data: { counter: verified.authenticationInfo.newCounter },
});
// Create session
req.session.userId = user!.id;
return res.json({ success: true });
});
Storing Credential Data in PostgreSQL
// schema.ts (Drizzle)
import { pgTable, text, integer, timestamp, jsonb } from 'drizzle-orm/pg-core';
export const passkeyCredentials = pgTable('passkey_credentials', {
id: text('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
credentialID: text('credential_id').notNull(),
credentialPublicKey: text('credential_public_key').notNull(),
counter: integer('counter').notNull(),
transports: jsonb('transports'),
createdAt: timestamp('created_at').defaultNow(),
});
Store credentials as base64 strings for compatibility. Counters prevent replay attacks.
Cross-Device Passkeys via iCloud Keychain/Google Password Manager
Cross-device passkeys let users authenticate on a different device using their phone.
User scans QR code on desktop, authenticates on phone, and logs in. Works seamlessly.
// Enable conditional UI (auto-fill passkeys)
const options = await generateAuthenticationOptions({
rpID: 'example.com',
userVerification: 'preferred',
});
// Client-side
const attResp = await startAuthentication(options, true); // true = conditional UI
SimpleWebAuthn and the browser handle the rest. No extra code needed.
Passkey Fallback Strategy
Not all users have passkeys. Support password login as fallback.
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findUnique({ where: { email } });
if (!user) return res.status(400).json({ error: 'Invalid credentials' });
// If user has passkeys, offer passkey-first UX
const hasPasskeys =
await db.passkeyCredentials.count({ where: { userId: user.id } }) > 0;
if (hasPasskeys) {
return res.json({
method: 'passkey',
options: await generateAuthenticationOptions({ rpID: 'example.com' }),
});
}
// Fall back to password
const passwordValid = await verifyPassword(password, user.passwordHash);
if (!passwordValid) return res.status(400).json({ error: 'Invalid credentials' });
req.session.userId = user.id;
return res.json({ method: 'password', success: true });
});
Graceful degradation. Passkeys first, password fallback.
Adding Passkeys to Existing Password Auth
Migrate gradually. Users can add passkeys without removing passwords.
// Passkey settings page
app.post('/api/settings/passkey-add', async (req, res) => {
const { userId } = req.session;
const options = await generateRegistrationOptions({
rpID: 'example.com',
userID: userId,
userName: req.user.email,
});
req.session.registrationChallenge = options.challenge;
return res.json(options);
});
Users can register passkeys in settings. Once they have one, they can use passkey-first login.
Conditional UI (Auto-Fill Passkeys)
Conditional UI shows passkey auto-fill suggestions in password fields (modern browsers only).
// Client-side login form
const input = document.getElementById('email') as HTMLInputElement;
// Enable conditional UI
const options = await generateAuthenticationOptions({
rpID: 'example.com',
userVerification: 'preferred',
});
const response = await startAuthentication(options, true); // true = conditional UI
In browsers that support it, a passkey appears as an auto-fill option. Users tap it, authenticate biometrically, and are logged in. Frictionless.
Passkey Analytics
Track adoption and usage.
// Log passkey creation
await db.analytics.create({
data: {
event: 'passkey_registered',
userId,
deviceType: detectDeviceType(), // iOS, Android, Windows, etc.
timestamp: new Date(),
},
});
// Log authentication method
await db.analytics.create({
data: {
event: 'user_login',
userId,
method: 'passkey', // or 'password'
timestamp: new Date(),
},
});
Measure adoption. Track which devices register passkeys. Use data to prioritize passkey features.
Checklist
- Install @simplewebauthn/server and @simplewebauthn/browser
- Create passkey credentials table in database
- Implement registration flow (challenge generation, verification)
- Implement authentication flow (challenge, verification, counter update)
- Add passkey registration to user settings
- Enable conditional UI for auto-fill
- Test on iOS, Android, Windows, macOS
- Test cross-device passkeys (QR code flow)
- Implement password fallback
- Add passkey analytics
Conclusion
Passkeys are no longer experimental. They''re the future of authentication, and 2026 is the year they go mainstream. Implement them now, support password fallback, and measure adoption. Users will prefer passkeys; your support costs will drop; and your security posture will improve. Start with SimpleWebAuthn. It''s battle-tested, well-documented, and production-ready.