Passkeys: The Future of Passwordless Authentication
Introduction
Passwords have been the primary method of authentication for decades, but they are increasingly showing their age. Users struggle with password fatigue, weak passwords, and phishing attacks. Meanwhile, organizations face the cost of password resets, breach notifications, and compliance issues.
Passkeys represent a paradigm shift in authentication. Built on the WebAuthn (Web Authentication) API and FIDO2 (Fast Identity Online) standards, passkeys offer a passwordless, phishing-resistant authentication method that is both more secure and more user-friendly.
This guide explains how passkeys work, how they compare to passwords, and how to implement them in your applications.
What Are Passkeys?
A passkey is a cryptographic credential that replaces passwords. Instead of typing a memorable string, the user authenticates using:
- Biometric sensors (fingerprint, face recognition)
- Device PIN or pattern
- Security keys (YubiKey, Titan Key)
The passkey itself is stored on the user's device and never leaves it. When authenticating, the device uses the passkey to cryptographically sign a challenge from the server.
Key Characteristics
| Feature | Passwords | Passkeys |
|---|---|---|
| Phishing resistant | No | Yes |
| Reusable across sites | Often (bad practice) | Never (unique per site) |
| Stored on server | Yes (hashed) | No (only public key) |
| User remembers | Yes (burden) | No (device handles it) |
| Works cross-device | Yes (with sync) | Yes (with sync) |
How Passkeys Work
Passkeys use public-key cryptography. When a user creates a passkey, a key pair is generated:
- Private key: Stays on the user's device (never transmitted)
- Public key: Sent to the server and stored in the user's account
Registration Flow
// Step 1: Server generates a challenge and sends to client
async function startRegistration(userId, username) {
const challenge = crypto.getRandomValues(new Uint8Array(32));
// Send challenge to server, receive registration options
const options = await fetch('/api/passkey/register-options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, username, challenge })
}).then(r => r.json());
// Step 2: Create the passkey on the client
const credential = await navigator.credentials.create({
publicKey: {
challenge: base64urlToBuffer(options.challenge),
rp: {
name: "Example App",
id: "example.com"
},
user: {
id: base64urlToBuffer(options.userId),
name: username,
displayName: username
},
pubKeyCredParams: [
{ alg: -7, type: "public-key" }, // ES256
{ alg: -257, type: "public-key" } // RS256
],
authenticatorSelection: {
authenticatorAttachment: "platform", // or "cross-platform" for security keys
userVerification: "required"
},
timeout: 60000
}
});
// Step 3: Send the credential to server for storage
await fetch('/api/passkey/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credentialId: bufferToBase64url(credential.rawId),
publicKey: bufferToBase64url(credential.response.publicKey),
attestation: bufferToBase64url(credential.response.attestationObject)
})
});
}
Authentication Flow
async function startAuthentication() {
// Step 1: Get authentication options from server
const options = await fetch('/api/passkey/auth-options').then(r => r.json());
// Step 2: Get the passkey assertion from the client
const assertion = await navigator.credentials.get({
publicKey: {
challenge: base64urlToBuffer(options.challenge),
allowCredentials: options.allowedCredentials?.map(c => ({
id: base64urlToBuffer(c.id),
type: 'public-key'
})) || [],
userVerification: "required",
timeout: 60000
}
});
// Step 3: Send assertion to server for verification
const result = await fetch('/api/passkey/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credentialId: bufferToBase64url(assertion.rawId),
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
signature: bufferToBase64url(assertion.response.signature)
})
}).then(r => r.json());
if (result.success) {
console.log("Authenticated successfully!");
}
}
Utility Functions
// Base64URL encoding/decoding for WebAuthn
function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
function base64urlToBuffer(base64url) {
const base64 = base64url
.replace(/-/g, '+')
.replace(/_/g, '/');
const padded = base64 + '==='.slice((base64.length + 3) % 4);
const binary = atob(padded);
const buffer = new ArrayBuffer(binary.length);
const bytes = new Uint8Array(buffer);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return buffer;
}
Passkeys vs Passwords: Detailed Comparison
Security
| Threat | Passwords | Passkeys |
|---|---|---|
| Phishing | Vulnerable (user types password on fake site) | Immune (passkey bound to domain) |
| Credential stuffing | High risk (reused passwords) | Impossible (unique per site) |
| Server breach | Hashed passwords can be cracked | Only public keys exposed (useless to attacker) |
| Brute force | Possible with weak passwords | Not applicable (private key never leaves device) |
| Man-in-the-middle | Vulnerable (if not using HTTPS) | Resistant (origin binding) |
User Experience
| Aspect | Passwords | Passkeys |
|---|---|---|
| Creation | User must invent/remember | Automatic (one tap) |
| Login speed | Type + submit (10-30 seconds) | Biometric/PIN (2-5 seconds) |
| Recovery | Email reset (can be phished) | New device sync or backup |
| Cross-device | Works everywhere | Works with sync or QR code |
Implementation Complexity
| Aspect | Passwords | Passkeys |
|---|---|---|
| Backend | Simple (store hash) | Moderate (WebAuthn library needed) |
| Frontend | Simple (form POST) | Moderate (WebAuthn API) |
| Recovery | Email-based | Device sync or fallback methods |
| Legacy support | Universal | Requires modern browser |
Browser and Platform Support
Current Support (2026)
| Platform | Browser | Supported | Notes |
|---|---|---|---|
| Desktop | Chrome 108+ | ✅ | Full support |
| Firefox 119+ | ✅ | Full support | |
| Safari 16+ | ✅ | Full support | |
| Edge 108+ | ✅ | Full support | |
| Mobile | iOS 16+ Safari | ✅ | Face ID / Touch ID |
| Android Chrome 108+ | ✅ | Fingerprint / Screen lock | |
| Samsung Internet 23+ | ✅ | Fingerprint |
Checking for Support
function isPasskeySupported() {
return window.PublicKeyCredential !== undefined &&
typeof window.PublicKeyCredential === 'function' &&
// Check if platform authenticator is available
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
}
isPasskeySupported().then(supported => {
if (supported) {
console.log("Passkeys are supported!");
} else {
console.log("Passkeys not available, show password login");
}
});
Server-Side Implementation
Node.js Example
const express = require('express');
const crypto = require('crypto');
const app = express();
// In production, use a WebAuthn library like @simplewebauthn/server
// This is a simplified example
const userChallenges = new Map(); // Store challenges temporarily
const userCredentials = new Map(); // Store registered credentials
// Step 1: Generate registration options
app.post('/api/passkey/register-options', (req, res) => {
const { userId, username } = req.body;
const challenge = crypto.randomBytes(32).toString('base64url');
// Store challenge for this user
userChallenges.set(userId, challenge);
res.json({
challenge,
rp: { name: "Example App", id: "localhost" },
user: { id: userId, name: username, displayName: username },
pubKeyCredParams: [
{ alg: -7, type: "public-key" }, // ES256
{ alg: -257, type: "public-key" } // RS256
]
});
});
// Step 2: Verify registration
app.post('/api/passkey/register', (req, res) => {
const { credentialId, publicKey, userId } = req.body;
// In production: verify the attestation using a library
// Store the public key for this user
userCredentials.set(userId, { credentialId, publicKey });
res.json({ success: true });
});
// Step 3: Generate authentication options
app.post('/api/passkey/auth-options', (req, res) => {
const challenge = crypto.randomBytes(32).toString('base64url');
// In production: associate challenge with session
res.json({
challenge,
allowCredentials: [] // or list specific credential IDs
});
});
// Step 4: Verify authentication
app.post('/api/passkey/authenticate', (req, res) => {
const { credentialId, signature, authenticatorData, clientDataJSON } = req.body;
// In production: verify the signature using the stored public key
// If valid, issue a session token
res.json({ success: true, token: "session-token-here" });
});
Using a Library (Recommended)
For production, use a well-tested library like @simplewebauthn/server:
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
const rpName = 'Example App';
const rpID = 'example.com';
const origin = `https://${rpID}`;
// Registration
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: userId,
userName: username,
attestationType: 'none',
excludeCredentials: existingCredentials,
});
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: options.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
Cross-Device Authentication
One of the challenges with passkeys is using them on a new device. The FIDO standard supports this via:
1. Cloud Sync (Most Common)
Passkeys synced via:
- iCloud Keychain (Apple devices)
- Google Password Manager (Android/Chrome)
- Microsoft Account (Windows/Edge)
When the user signs into their account on a new device, their passkeys sync automatically.
2. QR Code Flow (Hybrid)
For using a passkey from a phone on a desktop:
// Desktop browser shows QR code
// User scans with phone (which has the passkey)
// Phone signs the challenge and sends it back
const assertion = await navigator.credentials.get({
publicKey: {
challenge: ...,
extensions: {
appid: true // For backwards compatibility
}
}
});
Fallback Strategies
Not all users will have passkey-capable devices. Implement a graceful fallback:
async function authenticate() {
// Try passkey first
if (await isPasskeySupported()) {
try {
return await startAuthentication();
} catch (error) {
console.log("Passkey failed, falling back to password");
}
}
// Fallback to password
showPasswordLogin();
}
Common fallback methods:
- Password + MFA (traditional)
- Magic link email
- SMS verification (less secure)
- Backup codes
Summary
Passkeys represent the future of authentication:
- More secure: Phishing-resistant, unique per site, private key never leaves device
- Better UX: No passwords to remember, instant biometric login
- Industry backed: Supported by Apple, Google, Microsoft, and the FIDO Alliance
While passwords won't disappear overnight, passkeys are quickly becoming the preferred authentication method for modern applications. Start implementing passkey support today to give your users the best possible authentication experience.
Related Tools
- Generate strong passwords with our Password Generator