Passkeys are a modern approach to authentication that leverages FIDO2 WebAuthn standards to provide secure, passwordless login experiences. By using public key cryptography and biometric verification, passkeys offer a robust alternative to traditional passwords, enhancing both security and user convenience.
What is FIDO2 WebAuthn?
FIDO2 WebAuthn is a standard for strong, passwordless authentication that uses public key cryptography. It allows users to authenticate to online services using biometrics (like fingerprints or facial recognition), security keys, or built-in authenticators (such as TPM chips). The WebAuthn API provides a way for websites to interact with these authenticators, enabling secure and seamless authentication processes.
Why adopt passkeys?
Adopting passkeys brings several benefits:
- Security: Passkeys eliminate the risks associated with password reuse and phishing attacks.
- Convenience: Users can log in without remembering passwords, improving the overall user experience.
- Scalability: WebAuthn supports a wide range of devices and authenticators, making it easy to scale across different platforms.
What are the prerequisites for implementing FIDO2 WebAuthn?
Before diving into implementation, ensure you have the following:
- A server capable of handling WebAuthn operations (relying party server)
- Frontend support for the WebAuthn API
- Understanding of public key cryptography
- Compliance with FIDO2 standards
How do I set up a relying party server?
The relying party server is responsible for generating authentication challenges, verifying responses, and managing user credentials. Here’s a basic setup using Node.js and the webauthn library.
Install dependencies
First, install the necessary packages:
npm install @simplewebauthn/server
Initialize the server
Create a file named server.js and initialize the server:
const express = require('express');
const { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } = require('@simplewebauthn/server');
const app = express();
app.use(express.json());
// In-memory user store
const users = {};
const userAuthenticators = {};
// Register a new user
app.post('/register', async (req, res) => {
const { username, displayName } = req.body;
// Check if user already exists
if (users[username]) {
return res.status(400).json({ error: 'User already exists' });
}
const userId = username; // Use a unique identifier for the user
users[username] = { id: userId, username, displayName };
const options = generateRegistrationOptions({
rpName: 'My Website',
rpID: 'localhost',
userID: userId,
userName: username,
userDisplayName: displayName,
attestationType: 'none',
supportedAlgorithmIDs: [-7, -257],
});
res.json(options);
});
// Verify registration response
app.post('/verify-registration', async (req, res) => {
const { username, response } = req.body;
const user = users[username];
let verification;
try {
verification = await verifyRegistrationResponse({
credential: response,
expectedChallenge: user.currentChallenge,
expectedOrigin: 'http://localhost:3000',
expectedRPID: 'localhost',
});
} catch (error) {
return res.status(400).json({ error: error.message });
}
const { verified, registrationInfo } = verification;
if (verified && registrationInfo) {
const { credentialPublicKey, credentialID, counter } = registrationInfo;
userAuthenticators[user.id] = {
credentialID,
credentialPublicKey,
counter,
};
delete user.currentChallenge;
res.json({ status: 'success' });
} else {
res.status(400).json({ error: 'Verification failed' });
}
});
// Generate authentication options
app.post('/authenticate', async (req, res) => {
const { username } = req.body;
const user = users[username];
if (!user) {
return res.status(400).json({ error: 'User not found' });
}
const options = generateAuthenticationOptions({
timeout: 60000,
allowCredentials: userAuthenticators[user.id].map(authenticator => ({
id: authenticator.credentialID,
type: 'public-key',
transports: ['usb', 'nfc', 'ble', 'internal'],
})),
userVerification: 'preferred',
});
user.currentChallenge = options.challenge;
res.json(options);
});
// Verify authentication response
app.post('/verify-authentication', async (req, res) => {
const { username, response } = req.body;
const user = users[username];
if (!user) {
return res.status(400).json({ error: 'User not found' });
}
let verification;
try {
verification = await verifyAuthenticationResponse({
credential: response,
expectedChallenge: user.currentChallenge,
expectedOrigin: 'http://localhost:3000',
expectedRPID: 'localhost',
authenticator: userAuthenticators[user.id],
});
} catch (error) {
return res.status(400).json({ error: error.message });
}
const { verified, authenticationInfo } = verification;
if (verified) {
userAuthenticators[user.id].counter = authenticationInfo.newCounter;
delete user.currentChallenge;
res.json({ status: 'success' });
} else {
res.status(400).json({ error: 'Verification failed' });
}
});
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
Test the server
Run the server using:
node server.js
You can test the endpoints using tools like Postman or curl.
How do I integrate the WebAuthn API in the frontend?
The frontend interacts with the WebAuthn API to register and authenticate users. Here’s how you can do it using JavaScript.
Register a new user
async function registerUser(username, displayName) {
const response = await fetch('/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, displayName }),
});
const options = await response.json();
const publicKey = Object.assign({}, options, {
challenge: Uint8Array.from(
atob(options.challenge.replace(/_/g, '/').replace(/-/g, '+')),
c => c.charCodeAt(0)
),
user: {
...options.user,
id: Uint8Array.from(
atob(options.user.id.replace(/_/g, '/').replace(/-/g, '+')),
c => c.charCodeAt(0)
)
},
excludeCredentials: options.excludeCredentials.map((cred) => ({
...cred,
id: Uint8Array.from(
atob(cred.id.replace(/_/g, '/').replace(/-/g, '+')),
c => c.charCodeAt(0)
)
}))
});
const credential = await navigator.credentials.create({ publicKey });
const attestationObject = new Uint8Array(credential.response.attestationObject);
const clientDataJSON = new Uint8Array(credential.response.clientDataJSON);
const data = {
username,
response: {
id: credential.id,
rawId: credential.rawId,
type: credential.type,
response: {
attestationObject: String.fromCharCode(...attestationObject),
clientDataJSON: String.fromCharCode(...clientDataJSON),
},
},
};
const verificationResponse = await fetch('/verify-registration', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
const result = await verificationResponse.json();
console.log(result);
}
// Usage
registerUser('john_doe', 'John Doe');
Authenticate a user
async function authenticateUser(username) {
const response = await fetch('/authenticate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username }),
});
const options = await response.json();
const publicKey = Object.assign({}, options, {
challenge: Uint8Array.from(
atob(options.challenge.replace(/_/g, '/').replace(/-/g, '+')),
c => c.charCodeAt(0)
),
allowCredentials: options.allowCredentials.map((cred) => ({
...cred,
id: Uint8Array.from(
atob(cred.id.replace(/_/g, '/').replace(/-/g, '+')),
c => c.charCodeAt(0)
)
}))
});
const assertion = await navigator.credentials.get({ publicKey });
const authenticatorData = new Uint8Array(assertion.response.authenticatorData);
const clientDataJSON = new Uint8Array(assertion.response.clientDataJSON);
const signature = new Uint8Array(assertion.response.signature);
const userHandle = new Uint8Array(assertion.response.userHandle || []);
const data = {
username,
response: {
id: assertion.id,
rawId: assertion.rawId,
type: assertion.type,
response: {
authenticatorData: String.fromCharCode(...authenticatorData),
clientDataJSON: String.fromCharCode(...clientDataJSON),
signature: String.fromCharCode(...signature),
userHandle: String.fromCharCode(...userHandle),
},
},
};
const verificationResponse = await fetch('/verify-authentication', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
const result = await verificationResponse.json();
console.log(result);
}
// Usage
authenticateUser('john_doe');
What are the common pitfalls to avoid?
Avoid these common mistakes during implementation:
- Not validating challenges: Always verify that the challenge sent by the server matches the one received in the response.
- Ignoring user verification: Enable user verification to prevent unauthorized access.
- Storing sensitive data insecurely: Securely store private keys and other sensitive information.
How do I handle errors during registration and authentication?
Errors are inevitable. Here’s how to handle them gracefully.
Registration errors
Common registration errors include:
- Invalid state: The user is already registered.
- Network issues: The server is unreachable.
Example error handling:
try {
await registerUser('john_doe', 'John Doe');
} catch (error) {
console.error('Registration failed:', error);
}
Authentication errors
Common authentication errors include:
- Credential not found: The user doesn’t have a registered credential.
- Signature verification failed: The response couldn’t be verified.
Example error handling:
try {
await authenticateUser('john_doe');
} catch (error) {
console.error('Authentication failed:', error);
}
What are the security considerations for FIDO2 WebAuthn?
Security is paramount when implementing WebAuthn. Consider the following best practices:
- Secure key storage: Use secure methods to store private keys and other sensitive information.
- Validate all responses: Ensure that all responses from the authenticator are valid and match expected values.
- Protect against phishing attacks: Implement measures to prevent phishing attacks, such as requiring user verification.
How do I test my implementation?
Testing is crucial to ensure that your implementation works correctly. Here are some steps to follow:
- Unit tests: Write unit tests for your server-side logic.
- Integration tests: Test the entire authentication flow from registration to authentication.
- User testing: Conduct user testing to ensure that the user experience is smooth and intuitive.
Example integration test:
const request = require('supertest');
const app = require('./server');
describe('WebAuthn Integration Tests', () => {
it('should register a new user', async () => {
const response = await request(app)
.post('/register')
.send({ username: 'test_user', displayName: 'Test User' })
.expect(200);
expect(response.body).toHaveProperty('challenge');
});
it('should authenticate a registered user', async () => {
// Register a user first
await request(app)
.post('/register')
.send({ username: 'test_user', displayName: 'Test User' })
.expect(200);
// Authenticate the user
const response = await request(app)
.post('/authenticate')
.send({ username: 'test_user' })
.expect(200);
expect(response.body).toHaveProperty('challenge');
});
});
What are the performance implications of using WebAuthn?
Performance is generally good with WebAuthn, but there are a few considerations:
- Initial setup: Registration may take longer due to the need to generate and store cryptographic keys.
- Device compatibility: Not all devices support WebAuthn, which can affect adoption rates.
How do I monitor and maintain my implementation?
Monitoring and maintenance are essential to keep your implementation secure and efficient. Here are some tips:
- Logging: Implement comprehensive logging to track authentication attempts and errors.
- Regular updates: Keep your dependencies up to date to protect against vulnerabilities.
- Audit trails: Maintain audit trails for all authentication activities.
Example logging setup:
const morgan = require('morgan');
app.use(morgan('combined'));
How do I migrate existing users to passkeys?
Migrating existing users to passkeys requires a strategy to handle both password and passkey authentication. Here’s a basic approach:
- Dual authentication: Allow users to authenticate using either passwords or passkeys.
- Promote passkeys: Encourage users to register passkeys by providing incentives or simplifying the process.
- Deprecate passwords: Gradually phase out password authentication as more users adopt passkeys.
Example migration flow:
What are the future trends in passkeys and WebAuthn?
The future of passkeys and WebAuthn looks promising:
- Wider adoption: More browsers and devices are supporting WebAuthn, increasing its reach.
- Enhanced security: Ongoing improvements in security protocols and standards.
- User experience: Continued focus on improving the user experience for passwordless authentication.
🎯 Key Takeaways
- Passkeys provide secure, passwordless authentication using FIDO2 WebAuthn standards.
- Set up a relying party server to handle authentication requests and responses.
- Integrate the WebAuthn API in your frontend for seamless user interaction.
- Consider security best practices, including secure key storage and validation.
- Monitor and maintain your implementation to ensure continued security and efficiency.
Implementing FIDO2 WebAuthn in production requires careful planning and execution. By following this guide, you can provide your users with a secure and convenient authentication experience. That’s it. Simple, secure, works.

