Passkeys and WebAuthn — Password-Free Authentication for Developers
How to implement passkey-based authentication using the WebAuthn API. Covers the registration and authentication flows, server-side verification, and practical integration with existing auth systems.
Passwords are a terrible authentication mechanism. We've known this for decades, and yet here we are -- still dealing with bcrypt rounds, password reset flows, credential stuffing attacks, and users who set their password to "password123" no matter how many rules you enforce.
Passkeys are the first viable replacement. Not "viable" in the academic sense -- viable as in Apple, Google, and Microsoft all ship passkey support in their operating systems and browsers. Your users' devices already support this. The question is whether your app does.
What Passkeys Actually Are
A passkey is a cryptographic key pair:
- Private key — stored on the user's device (phone, laptop, security key), never leaves the device
- Public key — stored on your server
The underlying protocol is WebAuthn (Web Authentication), a W3C standard. "Passkeys" is the consumer-friendly name that Apple coined and everyone adopted.
Key properties:
- Phishing-resistant — the credential is bound to your domain. A fake login page on
evil-site.comcan't trigger the passkey foryourapp.com - No shared secrets — the server stores a public key, not a password hash. Even if your database leaks, attackers get nothing useful
- Cross-device sync — passkeys sync through iCloud Keychain, Google Password Manager, or 1Password. Lose your phone, get a new one, passkeys are still there
- Biometric-gated — the user authenticates with Face ID, Touch ID, Windows Hello, or a PIN. The biometric never leaves the device
The Registration Flow
When a user creates a passkey for your app, this happens:
1. Browser calls navigator.credentials.create() with options from your server
- Device shows a prompt (biometric/PIN)
- Device generates a key pair
- Device sends the public key + attestation back to the browser
- Browser sends it to your server
- Server stores the public key for this user
Client-Side Registration
async function registerPasskey() {
// 1. Get registration options from your server
const optionsRes = await fetch("/api/auth/passkey/register-options", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const options = await optionsRes.json();
// 2. Convert base64 strings to ArrayBuffers (WebAuthn uses binary)
options.challenge = base64URLToBuffer(options.challenge);
options.user.id = base64URLToBuffer(options.user.id);
if (options.excludeCredentials) {
options.excludeCredentials = options.excludeCredentials.map((cred) => ({
...cred,
id: base64URLToBuffer(cred.id),
}));
}
// 3. Create the credential (triggers biometric prompt)
const credential = await navigator.credentials.create({
publicKey: options,
});
// 4. Send the result to the server
const attestationResponse = credential.response;
const result = await fetch("/api/auth/passkey/register-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: credential.id,
rawId: bufferToBase64URL(credential.rawId),
type: credential.type,
response: {
attestationObject: bufferToBase64URL(attestationResponse.attestationObject),
clientDataJSON: bufferToBase64URL(attestationResponse.clientDataJSON),
},
}),
});
return result.json();
}
// Helper functions
function base64URLToBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function bufferToBase64URL(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
Server-Side Registration (Node.js)
Use the @simplewebauthn/server library -- it handles the cryptographic verification:
npm install @simplewebauthn/server @simplewebauthn/types
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
const rpName = "Your App";
const rpID = "yourapp.com";
const origin = "https://yourapp.com";
// Step 1: Generate registration options
app.post("/api/auth/passkey/register-options", async (req, res) => {
const user = req.user; // From your existing auth middleware
// Get existing credentials to prevent re-registration
const existingCredentials = await db.getPasskeysByUserId(user.id);
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: user.id,
userName: user.email,
userDisplayName: user.name,
attestationType: "none", // Don't need hardware attestation for most apps
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
type: "public-key",
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
// Store challenge for verification
await db.storeChallenge(user.id, options.challenge);
res.json(options);
});
// Step 2: Verify registration
app.post("/api/auth/passkey/register-verify", async (req, res) => {
const user = req.user;
const expectedChallenge = await db.getChallenge(user.id);
try {
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
if (verification.verified && verification.registrationInfo) {
const { credentialPublicKey, credentialID, counter } =
verification.registrationInfo;
await db.storePasskey({
userId: user.id,
credentialId: Buffer.from(credentialID).toString("base64url"),
publicKey: Buffer.from(credentialPublicKey).toString("base64url"),
counter,
transports: req.body.response.transports || [],
});
res.json({ verified: true });
} else {
res.status(400).json({ error: "Verification failed" });
}
} catch (err) {
res.status(400).json({ error: err.message });
}
});
The Authentication Flow
Login is similar but uses navigator.credentials.get():
1. Browser calls navigator.credentials.get() with a challenge from the server
- Device shows a prompt (biometric/PIN)
- Device signs the challenge with the private key
- Browser sends the signature to the server
- Server verifies the signature against the stored public key
- Server issues a session/JWT
Client-Side Authentication
async function loginWithPasskey() {
// 1. Get authentication options
const optionsRes = await fetch("/api/auth/passkey/login-options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: emailInput.value }), // Optional: hint which user
});
const options = await optionsRes.json();
// 2. Convert challenge
options.challenge = base64URLToBuffer(options.challenge);
if (options.allowCredentials) {
options.allowCredentials = options.allowCredentials.map((cred) => ({
...cred,
id: base64URLToBuffer(cred.id),
}));
}
// 3. Get the assertion (triggers biometric prompt)
const credential = await navigator.credentials.get({
publicKey: options,
});
// 4. Send to server
const assertionResponse = credential.response;
const result = await fetch("/api/auth/passkey/login-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: credential.id,
rawId: bufferToBase64URL(credential.rawId),
type: credential.type,
response: {
authenticatorData: bufferToBase64URL(assertionResponse.authenticatorData),
clientDataJSON: bufferToBase64URL(assertionResponse.clientDataJSON),
signature: bufferToBase64URL(assertionResponse.signature),
userHandle: assertionResponse.userHandle
? bufferToBase64URL(assertionResponse.userHandle)
: null,
},
}),
});
const data = await result.json();
if (data.token) {
localStorage.setItem("token", data.token);
window.location.href = "/dashboard";
}
}
Server-Side Authentication
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
app.post("/api/auth/passkey/login-options", async (req, res) => {
const { email } = req.body;
// If email provided, scope to that user's credentials
let allowCredentials;
if (email) {
const user = await db.getUserByEmail(email);
if (user) {
const credentials = await db.getPasskeysByUserId(user.id);
allowCredentials = credentials.map((cred) => ({
id: cred.credentialId,
type: "public-key",
transports: cred.transports,
}));
}
}
const options = await generateAuthenticationOptions({
rpID,
allowCredentials, // If undefined, the browser shows all available passkeys
userVerification: "preferred",
});
// Store challenge (use a session ID or random token as key for unauthenticated users)
const challengeToken = crypto.randomUUID();
await db.storeChallenge(challengeToken, options.challenge);
res.json({ ...options, challengeToken });
});
app.post("/api/auth/passkey/login-verify", async (req, res) => {
const { challengeToken, ...body } = req.body;
const expectedChallenge = await db.getChallenge(challengeToken);
// Find the credential
const credential = await db.getPasskeyByCredentialId(body.id);
if (!credential) {
return res.status(400).json({ error: "Unknown credential" });
}
try {
const verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialPublicKey: Buffer.from(credential.publicKey, "base64url"),
credentialID: Buffer.from(credential.credentialId, "base64url"),
counter: credential.counter,
},
});
if (verification.verified) {
// Update counter (prevents replay attacks)
await db.updatePasskeyCounter(
credential.credentialId,
verification.authenticationInfo.newCounter
);
// Issue session
const user = await db.getUserById(credential.userId);
const token = generateJWT(user);
res.json({ verified: true, token });
} else {
res.status(400).json({ error: "Verification failed" });
}
} catch (err) {
res.status(400).json({ error: err.message });
}
});
Database Schema
You need a table to store credentials:
CREATE TABLE passkeys (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
credential_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
transports TEXT[], -- ['internal', 'hybrid', 'usb', 'ble', 'nfc']
created_at TIMESTAMP DEFAULT NOW(),
last_used_at TIMESTAMP
);
CREATE INDEX idx_passkeys_user_id ON passkeys(user_id);
CREATE INDEX idx_passkeys_credential_id ON passkeys(credential_id);
A user can have multiple passkeys (phone + laptop + security key), so this is a one-to-many relationship.
Integration Strategy
Don't rip out your existing password auth. Add passkeys alongside it:
Sign in options:
┌─────────────────────────────────┐
│ 🔑 Sign in with passkey │ ← Primary, emphasized
├─────────────────────────────────┤
│ ── or ── │
│ Email: [________________] │
│ Password: [______________] │
│ [Sign in] │
└─────────────────────────────────┘
After a user logs in with a password, prompt them to create a passkey: "Want to skip the password next time? Set up a passkey."
Browser support check:
function isPasskeySupported(): boolean {
return (
window.PublicKeyCredential !== undefined &&
typeof window.PublicKeyCredential === "function"
);
}
// Check for conditional UI support (autofill passkeys)
async function isConditionalUISupported(): boolean {
if (!isPasskeySupported()) return false;
return PublicKeyCredential.isConditionalMediationAvailable?.() ?? false;
}
As of 2026, passkey support covers:
- Chrome 108+ (December 2022)
- Safari 16+ (September 2022)
- Firefox 122+ (January 2024)
- Edge 108+ (December 2022)
- iOS 16+, Android 9+
That's well over 90% of active browsers.
Conditional UI — Passkeys in the Autofill
The best UX: passkeys appear in the browser's autofill dropdown alongside saved passwords. The user clicks their username in the dropdown, does a biometric check, and they're in.
// On page load (not on button click)
async function setupConditionalUI() {
if (!(await isConditionalUISupported())) return;
const optionsRes = await fetch("/api/auth/passkey/login-options", {
method: "POST",
});
const options = await optionsRes.json();
options.challenge = base64URLToBuffer(options.challenge);
try {
const credential = await navigator.credentials.get({
publicKey: options,
mediation: "conditional", // This is the key
});
// User selected a passkey from autofill
await verifyAndLogin(credential);
} catch (err) {
// User didn't select a passkey, that's fine
console.debug("Conditional UI dismissed or not used");
}
}
Add autocomplete="username webauthn" to your email/username input:
<input
type="email"
name="email"
autocomplete="username webauthn"
placeholder="Email address"
/>
Security Considerations
Challenge expiration: Challenges should expire after 5 minutes. Don't reuse them. Counter verification: The counter in the authenticator data increments with each use. If the server sees a counter value lower than what's stored, the credential may have been cloned. Flag it. Credential backup state: The attestation data includes a flag indicating whether the credential is backed up (synced) or device-bound. You can use this to enforce security policies -- e.g., require device-bound credentials for admin accounts. Account recovery: If a user loses all their devices and hasn't synced passkeys, they need a recovery path. Options: recovery codes (generated at setup), email magic link, or verified identity through support. Don't skip this -- it's the most common gap in passkey implementations.Common Mistakes
- Not storing the challenge server-side. If you send the challenge to the client and verify what the client sends back, an attacker can just make up their own challenge. The server must generate and store the challenge independently.
- Using
attestationType: "direct"without reason. Direct attestation tells you what hardware made the credential. Most apps don't need this. It adds complexity and can break on some authenticators. Use"none"unless you have a specific compliance requirement.
- Requiring passkeys as the only auth method. Always offer a fallback. Users switch devices, use shared computers, or encounter browser bugs. Passkeys should be the preferred option, not the only option.
- Forgetting localhost during development. WebAuthn only works on HTTPS or
localhost. During local development,http://localhost:3000works fine, buthttp://192.168.1.x:3000does not.
@simplewebauthn/server handle the cryptographic heavy lifting. The user experience -- tap your finger to log in, no password to remember or type -- is dramatically better. If you're building a new project on CodeUp or anywhere else, passkeys should be on your implementation roadmap.