Passkeys have been a game-changer in the world of identity and access management (IAM). They offer a secure, passwordless method of authentication using FIDO2 standards and WebAuthn APIs. However, implementing them in a production environment can be tricky. This guide will walk you through the process, sharing insights and tips based on real-world experience.
The Problem
Traditional password-based authentication is fraught with issues: weak passwords, phishing attacks, and credential stuffing. Passkeys aim to solve these problems by leveraging public-key cryptography and biometric verification, providing a seamless and secure login experience.
Setting Up Your Environment
Before diving into implementation, ensure your environment is ready.
Prerequisites
- Node.js v18+ installed
- npm or yarn installed
- Basic understanding of JavaScript and Web APIs
- HTTPS server setup (required for WebAuthn)
Choosing a Library
Several libraries simplify WebAuthn implementation. I recommend using simplewebauthn due to its comprehensive documentation and ease of use.
📋 Quick Reference
npm install @simplewebauthn/server @simplewebauthn/browser- Install the necessary packages
Server-Side Implementation
The server handles passkey registration and authentication requests.
Registering a New Passkey
Start by creating an endpoint to initiate passkey registration.
// server.js
const express = require('express');
const { generateRegistrationOptions, verifyRegistrationResponse } = require('@simplewebauthn/server');
const app = express();
app.use(express.json());
let user = {
id: 'user-id',
username: 'johndoe',
displayName: 'John Doe',
};
let registeredCredentials = [];
app.post('/register/start', async (req, res) => {
const options = await generateRegistrationOptions({
rpName: 'Example Corp',
rpID: 'example.com',
userID: user.id,
userName: user.username,
userDisplayName: user.displayName,
attestationType: 'none', // or 'indirect'/'direct'
supportedAlgorithmIDs: [-7, -257], // ES256 and RS256
});
res.json(options);
});
Verifying Registration Response
Handle the client’s registration response to validate and store the passkey.
app.post('/register/finish', async (req, res) => {
const { clientDataJSON, attestationObject, transports } = req.body;
let verification;
try {
verification = await verifyRegistrationResponse({
response: {
clientDataJSON,
attestationObject,
},
expectedChallenge: user.currentChallenge, // Store this during /register/start
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
requireUserVerification: true,
});
} catch (error) {
return res.status(400).json({ error: error.message });
}
const { verified, registrationInfo } = verification;
if (verified && registrationInfo) {
const { credentialPublicKey, credentialID, counter } = registrationInfo;
registeredCredentials.push({
id: credentialID,
publicKey: credentialPublicKey,
counter,
transports: transports || [],
});
res.status(200).json({ status: 'success' });
} else {
res.status(400).json({ error: 'Registration failed' });
}
});
Client-Side Implementation
The client initiates the registration and authentication processes.
Initiating Registration
Create a function to start the registration process.
// client.js
async function startRegistration() {
const response = await fetch('/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const options = await response.json();
try {
const credential = await navigator.credentials.create({ publicKey: options });
const data = {
clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
attestationObject: Array.from(new Uint8Array(credential.response.attestationObject)),
transports: credential.response.getTransports ? credential.response.getTransports() : [],
};
const finishResponse = await fetch('/register/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await finishResponse.json();
console.log(result);
} catch (error) {
console.error(error);
}
}
document.getElementById('registerButton').addEventListener('click', startRegistration);
Authenticating with a Passkey
Implement the authentication flow.
async function startAuthentication() {
const response = await fetch('/login/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const options = await response.json();
try {
const credential = await navigator.credentials.get({ publicKey: options });
const data = {
id: credential.id,
rawId: Array.from(new Uint8Array(credential.rawId)),
type: credential.type,
response: {
authenticatorData: Array.from(new Uint8Array(credential.response.authenticatorData)),
clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
signature: Array.from(new Uint8Array(credential.response.signature)),
userHandle: Array.from(new Uint8Array(credential.response.userHandle || [])),
},
};
const finishResponse = await fetch('/login/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await finishResponse.json();
console.log(result);
} catch (error) {
console.error(error);
}
}
document.getElementById('loginButton').addEventListener('click', startAuthentication);
🎯 Key Takeaways
- Use `generateRegistrationOptions` and `verifyRegistrationResponse` for registration.
- Use `navigator.credentials.create` and `navigator.credentials.get` for client-side operations.
- Always validate origins and RPID to enhance security.
Handling Errors
Common issues arise during registration and authentication. Here’s how to handle them.
Registration Errors
Registration might fail due to various reasons, such as unsupported algorithms or invalid origins.
try {
verification = await verifyRegistrationResponse({
response: {
clientDataJSON,
attestationObject,
},
expectedChallenge: user.currentChallenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
requireUserVerification: true,
});
} catch (error) {
console.error('Registration error:', error.message);
res.status(400).json({ error: error.message });
}
Authentication Errors
Authentication errors could be due to incorrect credentials or mismatched challenges.
try {
verification = await verifyAuthenticationResponse({
credential: {
id: credentialID,
rawId: rawIdBuffer,
type: 'public-key',
response: {
authenticatorData: authenticatorDataBuffer,
clientDataJSON: clientDataJSONBuffer,
signature: signatureBuffer,
userHandle: userHandleBuffer,
},
},
expectedChallenge: user.currentChallenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
signingAlgorithm: -7, // ES256
credentialPublicKey: publicKeyBuffer,
prevCounter: userCredential.counter,
requireUserVerification: true,
});
} catch (error) {
console.error('Authentication error:', error.message);
res.status(400).json({ error: error.message });
}
Integrating with Existing Systems
Passkeys can coexist with traditional authentication methods. Here’s how to integrate them.
Dual-Stack Authentication
Allow users to choose between passkeys and passwords.
app.post('/login/start', async (req, res) => {
const options = await generateAuthenticationOptions({
allowCredentials: registeredCredentials.map((cred) => ({
id: cred.id,
type: 'public-key',
transports: cred.transports,
})),
userVerification: 'preferred',
rpID: 'example.com',
});
res.json(options);
});
Migration Strategy
Gradually migrate users to passkeys by offering a seamless transition path.
Browser Compatibility
Not all browsers support WebAuthn equally. Here’s a quick overview.
| Browser | Support | Notes |
|---|---|---|
| Chrome | Full | Available since version 67 |
| Firefox | Full | Available since version 60 |
| Safari | Limited | Partial support in version 13.1+ |
| Edge | Full | Available since version 79 |
| Opera | Full | Available since version 54 |
Performance Considerations
Optimize your implementation for speed and reliability.
Minimizing Latency
Reduce latency by optimizing network requests and server responses.
app.use(express.json({ limit: '1mb' })); // Increase limit if needed
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
Efficient Credential Storage
Store credentials efficiently to avoid performance bottlenecks.
Security Best Practices
Follow these guidelines to ensure a secure implementation.
Secure Key Storage
Never store private keys in plaintext. Use secure vaults or hardware security modules (HSMs).
User Verification
Enable user verification to prevent unauthorized access.
const options = await generateRegistrationOptions({
// ...
userVerification: 'required',
});
Challenge Management
Generate unique challenges for each request to prevent replay attacks.
function generateChallenge() {
return Buffer.from(crypto.randomBytes(32)).toString('base64url');
}
🎯 Key Takeaways
- Test across different browsers to ensure compatibility.
- Optimize for performance to minimize latency.
- Follow best practices for secure key storage and user verification.
Conclusion
Implementing FIDO2 WebAuthn passkeys in production requires careful planning and execution. By following this guide, you can provide a secure, passwordless authentication experience for your users. Remember to test thoroughly and stay updated with the latest security practices.
That’s it. Simple, secure, works. Happy coding!