Passkeys are replacing passwords. They use cryptographic credentials backed by device biometrics or security keys—no password to remember, type, or steal. WebAuthn is the W3C standard that makes this possible. Major platforms (Apple, Google, Microsoft) now support passkeys, making passwordless authentication mainstream.
Why passkeys?
| Password problems | Passkey solutions |
|---|---|
| Easily guessed | Cryptographic, not guessable |
| Reused across sites | Unique per site |
| Phishable | Bound to origin, can't be phished |
| Stored on servers | Only public key stored |
| Forgotten | Device handles authentication |
Passkeys eliminate entire categories of attacks.
How passkeys work
Registration
- User initiates registration
- Server generates a challenge and options
- Browser prompts for biometric/PIN
- Device creates a key pair
- Public key sent to server, private key stays on device
- Server stores public key + credential ID
Authentication
- User initiates login
- Server generates a challenge
- Browser prompts for biometric/PIN
- Device signs challenge with private key
- Server verifies signature with stored public key
- User authenticated
The private key never leaves the device. There's nothing to steal from the server.
Implementation
Server setup
Install SimpleWebAuthn:
npm install @simplewebauthn/server @simplewebauthn/browser
Registration (server)
// Generate registration options
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
const rpName = 'My App';
const rpID = 'example.com';
const origin = `https://${rpID}`;
app.post('/api/auth/register/options', async (req, res) => {
const user = await getUser(req.session.userId);
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: user.id,
userName: user.email,
userDisplayName: user.name,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
// Store challenge for verification
await saveChallenge(user.id, options.challenge);
res.json(options);
});
app.post('/api/auth/register/verify', async (req, res) => {
const user = await getUser(req.session.userId);
const expectedChallenge = await getChallenge(user.id);
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
if (verification.verified) {
await saveCredential(user.id, {
credentialID: verification.registrationInfo.credentialID,
credentialPublicKey: verification.registrationInfo.credentialPublicKey,
counter: verification.registrationInfo.counter,
});
res.json({ verified: true });
} else {
res.status(400).json({ error: 'Verification failed' });
}
});
Registration (client)
import { startRegistration } from '@simplewebauthn/browser';
async function registerPasskey() {
// Get options from server
const optionsRes = await fetch('/api/auth/register/options', {
method: 'POST',
});
const options = await optionsRes.json();
// Start WebAuthn registration
const credential = await startRegistration(options);
// Send credential to server for verification
const verifyRes = await fetch('/api/auth/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
});
const result = await verifyRes.json();
if (result.verified) {
console.log('Passkey registered!');
}
}
Authentication (server)
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
app.post('/api/auth/login/options', async (req, res) => {
const { email } = req.body;
const user = await getUserByEmail(email);
const credentials = await getUserCredentials(user.id);
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: credentials.map((cred) => ({
id: cred.credentialID,
type: 'public-key',
})),
userVerification: 'preferred',
});
await saveChallenge(user.id, options.challenge);
res.json(options);
});
app.post('/api/auth/login/verify', async (req, res) => {
const user = await getUserByCredentialID(req.body.id);
const credential = await getCredential(req.body.id);
const expectedChallenge = await getChallenge(user.id);
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialID: credential.credentialID,
credentialPublicKey: credential.credentialPublicKey,
counter: credential.counter,
},
});
if (verification.verified) {
// Update counter for replay protection
await updateCounter(credential.id, verification.authenticationInfo.newCounter);
// Create session
req.session.userId = user.id;
res.json({ verified: true });
} else {
res.status(400).json({ error: 'Verification failed' });
}
});
Authentication (client)
import { startAuthentication } from '@simplewebauthn/browser';
async function loginWithPasskey(email: string) {
// Get options
const optionsRes = await fetch('/api/auth/login/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const options = await optionsRes.json();
// Authenticate
const credential = await startAuthentication(options);
// Verify
const verifyRes = await fetch('/api/auth/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
});
const result = await verifyRes.json();
if (result.verified) {
window.location.href = '/dashboard';
}
}
Conditional UI (autofill)
Show passkeys in the password field:
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: [], // Empty for discoverable credentials
userVerification: 'preferred',
});
const credential = await startAuthentication(options, {
conditionalMediation: true, // Enable autofill
});
<input
type="text"
name="email"
autocomplete="username webauthn"
/>
Users see passkeys as an autofill option.
Security considerations
| Requirement | Implementation |
|---|---|
| HTTPS only | WebAuthn requires secure context |
| Origin validation | Verify expectedOrigin matches your domain |
| Challenge entropy | Use crypto-random 32+ bytes |
| Counter validation | Check counter increases to detect cloning |
| Credential storage | Store only public key, credential ID, counter |
Cross-device authentication
Passkeys can authenticate across devices:
- User starts login on laptop
- Browser shows QR code
- User scans with phone
- Phone authenticates with biometric
- Laptop receives confirmation
This works automatically with synced passkeys (iCloud Keychain, Google Password Manager).
Fallback strategies
During transition, support both methods:
// Check if passkeys are available
const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
if (available) {
showPasskeyOption();
}
// Always show password option during transition
showPasswordOption();
Summary
Passkeys use WebAuthn to provide phishing-resistant, passwordless authentication. Device biometrics or security keys replace passwords. Use SimpleWebAuthn for implementation. Generate challenges on the server, verify responses server-side. Support conditional UI for seamless autofill. During transition, offer both passkeys and passwords. Passkeys are more secure and easier to use than passwords.
