Passkeys represent the future of authentication, replacing passwords with cryptographic credentials stored on user devices. Built on the WebAuthn standard, passkeys provide superior security while improving user experience.
What Are Passkeys?
Passkeys are cryptographic credentials that replace passwords. They consist of:
- 1Private Key: Stored securely on user's device (TPM, Secure Enclave)
- 2Public Key: Stored on your server
- 3Biometric/PIN: Local authentication (fingerprint, Face ID)
Passkeys are phishing-resistant, never leave the user's device, and work across all platforms.
WebAuthn Fundamentals
Registration Flow
// Server generates registration options
async function registerStart(email) {
const user = await db.users.findByEmail(email);
const options = {
challenge: base64url.encode(randomBuffer(32)),
rp: {
name: 'My App',
id: 'example.com'
},
user: {
id: base64url.encode(user.id),
name: email,
displayName: user.name
},
pubKeyCredParams: [
{ type: 'public-key', alg: -7 }, // ES256
{ type: 'public-key', alg: -257 } // RS256
],
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'preferred'
},
timeout: 60000,
attestation: 'none'
};
// Save challenge to session
await saveChallenge(user.id, options.challenge);
return options;
}
// Client registers credential
async function registerFinish(email, credential) {
const user = await db.users.findByEmail(email);
const challenge = await getChallenge(user.id);
// Verify challenge
const expectedChallenge = challenge;
const verification = await verifyRegistrationResponse({
credential,
expectedChallenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com'
});
if (verification.verified) {
// Save credential to database
await db.credentials.create({
userId: user.id,
credentialId: verification.registrationInfo.credentialID,
publicKey: verification.registrationInfo.credentialPublicKey,
counter: verification.registrationInfo.counter,
transports: credential.response.transports
});
}
return verification.verified;
}Authentication Flow
// Server generates authentication options
async function loginStart(email) {
const user = await db.users.findByEmail(email);
const credentials = await db.credentials.findByUserId(user.id);
const options = {
challenge: base64url.encode(randomBuffer(32)),
allowCredentials: credentials.map(cred => ({
id: cred.credentialId,
type: 'public-key'
})),
userVerification: 'preferred',
timeout: 60000
};
// Save challenge
await saveChallenge(email, options.challenge);
return options;
}
// Client authenticates
async function loginFinish(email, credential) {
const challenge = await getChallenge(email);
const credRecord = await db.credentials.findById(credential.id);
const verification = await verifyAuthenticationResponse({
credential,
expectedChallenge: challenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
authenticator: {
credentialID: credRecord.credentialId,
credentialPublicKey: credRecord.publicKey,
counter: credRecord.counter
}
});
if (verification.verified) {
// Update counter
await db.credentials.update(credRecord.id, {
counter: verification.authenticationInfo.newCounter
});
// Create session
const session = await createSession(credRecord.userId);
return session;
}
throw new Error('Authentication failed');
}Client-Side Implementation
Registration with SimpleWebAuthn
import { startRegistration, finishRegistration } from '@simplewebauthn/browser';
async function registerPasskey() {
// Get registration options from server
const optionsResp = await fetch('/auth/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'user@example.com' })
});
const options = await optionsResp.json();
// Start registration (shows browser UI)
let registration;
try {
registration = await startRegistration(options);
} catch (error) {
console.error('Registration failed:', error);
return;
}
// Send registration to server
const verificationResp = await fetch('/auth/register/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'user@example.com',
credential: registration
})
});
const verification = await verificationResp.json();
if (verification.verified) {
alert('Passkey registered successfully!');
}
}Authentication with SimpleWebAuthn
import { startAuthentication, finishAuthentication } from '@simplewebauthn/browser';
async function signInWithPasskey() {
// Get authentication options
const optionsResp = await fetch('/auth/login/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'user@example.com' })
});
const options = await optionsResp.json();
// Start authentication
let authentication;
try {
authentication = await startAuthentication(options);
} catch (error) {
console.error('Authentication failed:', error);
return;
}
// Send authentication to server
const verificationResp = await fetch('/auth/login/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'user@example.com',
credential: authentication
})
});
const verification = await verificationResp.json();
if (verification.verified) {
// Store session token
localStorage.setItem('token', verification.token);
window.location.href = '/dashboard';
}
}Server-Side Implementation
Express.js Backend
import {
generateRegistrationOptions,
generateAuthenticationOptions,
verifyRegistrationResponse,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
// Registration endpoint
app.post('/auth/register/start', async (req, res) => {
const { email } = req.body;
const user = await db.users.findOrCreate({ email });
const options = await generateRegistrationOptions({
rpName: 'My App',
rpID: 'example.com',
userID: user.id,
userName: email,
userDisplayName: user.name,
attestationType: 'none',
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'preferred'
}
});
// Save challenge
await redis.set(
`registration:${user.id}`,
options.challenge,
'EX',
60
);
res.json(options);
});
app.post('/auth/register/finish', async (req, res) => {
const { email, credential } = req.body;
const user = await db.users.findByEmail(email);
const challenge = await redis.get(`registration:${user.id}`);
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: challenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com'
});
if (verification.verified) {
await db.credentials.create({
userId: user.id,
credentialId: verification.registrationInfo.credentialID,
publicKey: verification.registrationInfo.credentialPublicKey,
counter: verification.registrationInfo.counter
});
res.json({ verified: true });
} else {
res.status(400).json({ verified: false });
}
});
// Authentication endpoints
app.post('/auth/login/start', async (req, res) => {
const { email } = req.body;
const user = await db.users.findByEmail(email);
const credentials = await db.credentials.findByUserId(user.id);
const options = await generateAuthenticationOptions({
allowCredentials: credentials.map(c => ({
id: c.credentialId,
type: 'public-key'
})),
userVerification: 'preferred'
});
await redis.set(
`authentication:${email}`,
options.challenge,
'EX',
60
);
res.json(options);
});
app.post('/auth/login/finish', async (req, res) => {
const { email, credential } = req.body;
const challenge = await redis.get(`authentication:${email}`);
const credRecord = await db.credentials.findById(credential.id);
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: challenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
authenticator: {
credentialID: credRecord.credentialId,
credentialPublicKey: credRecord.publicKey,
counter: credRecord.counter
}
});
if (verification.verified) {
await db.credentials.update(credRecord.id, {
counter: verification.authenticationInfo.newCounter
});
const token = jwt.sign({ userId: credRecord.userId }, SECRET);
res.json({ verified: true, token });
} else {
res.status(401).json({ verified: false });
}
});Fallback and Recovery
Backup Codes
async function generateBackupCodes(userId) {
const codes = [];
for (let i = 0; i < 10; i++) {
codes.push(crypto.randomBytes(4).toString('hex').toUpperCase());
}
await db.backupCodes.create({
userId,
codes: codes.map(code => ({
code: await bcrypt.hash(code, 10),
used: false
}))
});
return codes;
}
async function verifyBackupCode(userId, code) {
const backupCodes = await db.backupCodes.findByUserId(userId);
for (const record of backupCodes) {
if (await bcrypt.compare(code, record.code) && !record.used) {
await db.backupCodes.markUsed(record.id);
return true;
}
}
return false;
}Hybrid Authentication
async function signIn(email) {
const user = await db.users.findByEmail(email);
if (user.hasPasskey) {
// Try passkey first
return renderPasskeyAuth();
} else {
// Fallback to password
return renderPasswordAuth();
}
}
async function linkPasskeyToAccount(userId, credential) {
// Verify credential
const verification = await verifyRegistrationResponse(...);
if (verification.verified) {
await db.credentials.create({
userId,
credentialId: verification.registrationInfo.credentialID,
publicKey: verification.registrationInfo.credentialPublicKey
});
await db.users.update(userId, { hasPasskey: true });
}
}Best Practices
- 1Always Verify Challenges: Prevent replay attacks
- 2Set Time Limits: Challenges should expire (60 seconds)
- 3Support Multiple Credentials: Allow users to register multiple devices
- 4Provide Fallback: Backup codes or password for account recovery
- 5User Verification: Require biometrics when possible
- 6HTTPS Only: WebAuthn requires secure context
- 7Clear UX: Guide users through the process
- 8Test on Devices: Passkeys work differently across platforms
Conclusion
Passkeys provide superior security and user experience compared to passwords. With proper implementation using WebAuthn and SimpleWebAuthn, you can offer passwordless authentication that's both secure and user-friendly. Start by offering passkeys as an option, then gradually make it the default.