Passkeys for Advanced Developers: Deep Dive into Implementation, Enterprise, and Full-Stack Integration
Welcome to the advanced guide on Passkeys. This document is tailored for developers who have a solid understanding of fundamental passkey concepts, public-key cryptography, and the basic WebAuthn workflow. We will now explore the deeper technical aspects of passkey implementation, advanced use cases, enterprise considerations, and a hands-on full-stack project integrating React and Node.js.
1. Introduction to Advanced Passkeys
What are Advanced Passkey Concepts?
Beyond the basics of registration and authentication, advanced passkey concepts involve:
- Optimizing Server-Side Logic: Leveraging the full potential of server-side WebAuthn libraries for robust, secure, and efficient credential management.
- Seamless Cross-Device Experiences: Implementing advanced flows like QR code-based authentication to bridge different devices and operating systems.
- Platform-Specific Integrations: Utilizing native APIs for the deepest integration within iOS, Android, and desktop environments.
- Enterprise-Grade Features: Handling advanced attestation, provisioning, and deprovisioning for organizational deployments.
- Expanding WebAuthn Capabilities: Exploring extensions to enhance functionality and user experience.
- Full-Stack Implementation: Integrating passkeys into modern web architectures using frameworks like React and Node.js.
Why Delve Deeper into Passkeys?
As passkeys mature, a deeper understanding becomes critical for:
- Building Production-Ready Systems: Ensuring your passkey implementation is secure, scalable, and resilient.
- Addressing Complex Use Cases: Supporting diverse user scenarios, including enterprise deployments, identity verification, and multi-device management.
- Optimizing User Experience: Delivering truly seamless and intuitive passwordless journeys across all platforms.
- Staying Ahead of the Curve: As platforms continuously enhance passkey features (e.g., secure import/export, account creation APIs, enterprise controls), mastering these advanced topics will enable you to leverage the latest advancements.
- Becoming an Authentication Expert: Distinguish yourself by mastering the intricacies of next-generation authentication.
Setting Up Your Advanced Development Environment
For advanced topics and the full-stack project, your development environment will be more comprehensive:
- Node.js (LTS version): For the backend server.
- React Development Setup: Create React App or Vite for the frontend.
- HTTPS for Local Development:
mkcert: Recommended for generating locally trusted SSL certificates.ngrok: For exposing your local HTTPS server to the internet for testing on real mobile devices or cross-device flows.
- Database: A simple database (e.g., SQLite with
better-sqlite3, or PostgreSQL/MongoDB if you prefer) to persist user and credential data. Our project will use a simple in-memory store for demonstration, but you should be ready to integrate a real DB. - FIDO2-compatible Authenticators:
- Your primary mobile device (iOS 26+, Android 16+) as a platform authenticator.
- (Optional) Hardware security keys (e.g., YubiKey) for testing cross-platform and device-bound scenarios.
- Code Editor: VS Code or your preferred IDE with relevant extensions for React, Node.js, and TypeScript (if using).
Example mkcert setup:
# 1. Install mkcert (one-time setup)
brew install mkcert # macOS Homebrew
sudo apt install mkcert # Debian/Ubuntu
choco install mkcert # Windows Chocolatey
mkcert -install # Install local CA
# 2. Generate certificates for your local domain
# This will create files like localhost+1.pem and localhost+1-key.pem
mkcert localhost 127.0.0.1 0.0.0.0
Store these certificates in a designated ssl folder for your Node.js server.
2. Server-Side Library Deep Dive
Server-side WebAuthn libraries are crucial for securely handling the cryptographic heavy lifting, parsing, and verification of WebAuthn responses. They abstract away the complex CBOR encoding/decoding, cryptographic signature verification, and compliance checks.
We’ll continue using @simplewebauthn/server as it’s a popular and well-maintained library for Node.js. Similar libraries exist for other languages (e.g., webauthn-lib for Python, WebAuthn.Net for .NET, webauthn-ruby for Ruby).
2.1 Understanding generateRegistrationOptions
This function generates the parameters that the client-side WebAuthn API (navigator.credentials.create()) needs to initiate a new passkey registration.
Key Parameters and Advanced Usage:
rpName,rpID: Your Relying Party’s (RP) human-readable name and domain.rpID: Crucial. Must be the domain of your website/application (e.g.,example.com). All subdomains (e.g.,login.example.com) are implicitly allowed. Must match the origin during verification.
userID,userName,userDisplayName: Identifiers for the user.userID: A stable, unique, URL-safe identifier for the user in your database (e.g., a UUID). Never use email or a predictable ID. This ID is stored in the authenticator and is used during discoverable authentication.userName: The user’s preferred login name, typically email.userDisplayName: A friendly name for the user.
attestationType:'none'(recommended for most consumer apps): Provides no attestation, preserving user privacy.'direct','indirect','enterprise': For specific, high-security use cases where proof of authenticator authenticity is needed. Requires careful verification logic.
authenticatorSelection:authenticatorAttachment:'platform': (Recommended) Built-in authenticators like Face ID, Touch ID, Windows Hello. These often support syncable passkeys.'cross-platform': External authenticators like YubiKeys. These are typically device-bound.undefined: Allows either. Good for flexibility.
residentKey(Discoverable Credentials):'required': Enforces the creation of a discoverable passkey. This means the authenticator can find the credential without the user first entering their username. Essential for a truly passwordless login experience.'preferred': The authenticator should try to create a discoverable passkey but can fall back if not supported.'discouraged': Discourages discoverable passkeys (e.g., for device-bound security keys where only one user will use it on that device).
userVerification:'required': The user must perform a biometric or PIN verification.'preferred'(recommended): The authenticator should try user verification but can proceed without it (e.g., if the user configured it not to).'discouraged': Skip user verification if possible.
excludeCredentials: An array of existing credential IDs for the user. This prevents the creation of duplicate passkeys on the same authenticator.timeout: How long the user has to complete the registration.
Example: Advanced Registration Options on Server
// server.js snippet for generateRegistrationOptions
const { generateRegistrationOptions } = require('@simplewebauthn/server');
// ... (user and credential storage setup)
app.post('/generate-registration-options', async (req, res) => {
const { email } = req.body;
// ... basic validation and user lookup ...
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: user.id,
userName: email,
userDisplayName: email, // Can be more descriptive if needed
attestationType: 'none', // For most consumer applications
authenticatorSelection: {
authenticatorAttachment: 'platform', // Prefer built-in authenticators for best UX and syncability
residentKey: 'required', // Essential for passwordless "tap to sign in" experience
userVerification: 'preferred', // User's choice, but prompt if available
},
excludeCredentials: credentials.get(user.id).map(cred => ({
id: Buffer.from(cred.id, 'base64url'), // Convert stored ID back to Buffer
type: 'public-key',
// It's good practice to also include `transports` if you stored them
transports: cred.transports,
})),
timeout: 60000,
// extensions: {
// // Example of using an extension (covered later)
// 'credProps': true, // Request credential properties (e.g., `uvm`)
// },
});
user.currentChallenge = options.challenge;
users.set(email, user);
return res.json({ options });
});
2.2 Understanding verifyRegistrationResponse
This function takes the credential received from the client-side and performs critical security checks.
Key Parameters and Advanced Usage:
response: TheAuthenticationCredentialobject received from the client.expectedChallenge: The exact challenge string generated earlier by your server for this specific registration. Crucial for replay attack prevention.expectedOrigin: The expectedwindow.location.origin(e.g.,https://localhost:3000,https://yourdomain.com). Prevents cross-site attacks.expectedRPID: TherpIDconfigured when generating options.requireUserVerification: Should betrueifuserVerification: 'required'was set duringgenerateRegistrationOptions.requireUserPresence: Should almost always betrue. Ensures the user was physically present and interacted with the authenticator.attestationOptions: If using attestation (e.g.,attestationType: 'direct'), you’d provide options here to specify trusted root certificates for verifying the attestation statement.rpIDHash: (Advanced) If you need to manually verify the RP ID hash within the attestation.
Output (registrationInfo):
credentialPublicKey: The public key generated by the authenticator, which you must store.credentialID: A unique identifier for this specific passkey, also must be stored.counter: The initial signature counter value. Store this to prevent replay attacks during authentication.authenticatorInfo: Contains details likeaaguid,fmt,attestationObject, etc., useful if you are processing attestation.
Example: Advanced Registration Verification on Server
// server.js snippet for verifyRegistrationResponse
const { verifyRegistrationResponse } = require('@simplewebauthn/server');
// ... (user and credential storage setup)
app.post('/verify-registration', async (req, res) => {
const { email, credential } = req.body;
// ... basic validation and user lookup ...
try {
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: user.currentChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
requireUserVerification: false, // Match your `authenticatorSelection`
requireUserPresence: true, // Always required for FIDO2
// For attestation:
// attestationOptions: {
// // Example: if using 'direct' attestation and have specific trusted CAs
// // trustedCertificates: [yourTrustedCA],
// // trustedAttestationTypes: ['packed', 'fido-u2f'],
// },
});
const { verified, registrationInfo } = verification;
if (verified && registrationInfo) {
const { credentialPublicKey, credentialID, counter, transports, aaguid } = registrationInfo;
const newCredential = {
id: Buffer.from(credentialID).toString('base64url'),
publicKey: Buffer.from(credentialPublicKey).toString('base64url'),
algorithm: -7, // ES256, common default
counter: counter,
// Add transports for potential future filtering
transports: transports || [],
aaguid: aaguid ? Buffer.from(aaguid).toString('hex') : null, // Store AAGUID if available
// Store registration time, last used, etc.
registeredAt: new Date().toISOString(),
};
const userCredentials = credentials.get(user.id);
userCredentials.push(newCredential);
credentials.set(user.id, userCredentials);
user.currentChallenge = ''; // Clear challenge after use
return res.json({ message: 'Passkey registered successfully', verified });
} else {
return res.status(400).json({ error: 'Passkey registration failed verification' });
}
} catch (error) {
console.error('Error verifying registration:', error);
return res.status(500).json({ error: error.message });
}
});
2.3 Understanding generateAuthenticationOptions
This generates the parameters for client-side authentication (navigator.credentials.get()).
Key Parameters and Advanced Usage:
rpID: Same as registration.allowCredentials: A critical array that tells the authenticator which credentials (by ID) it’s allowed to use for this authentication.- For discoverable credentials (passwordless login), this array can be empty or omitted. The authenticator will present available passkeys for the
rpID. - For non-discoverable credentials (username-first login), this array must contain the credential ID(s) associated with the provided username. The server needs to retrieve these IDs from its database.
- For discoverable credentials (passwordless login), this array can be empty or omitted. The authenticator will present available passkeys for the
userVerification: Similar to registration, but often'preferred'or'required'for authentication.timeout: How long the user has to complete authentication.
Example: Advanced Authentication Options on Server
// server.js snippet for generateAuthenticationOptions
const { generateAuthenticationOptions } = require('@simplewebauthn/server');
// ... (user and credential storage setup)
app.post('/generate-authentication-options', async (req, res) => {
const { email } = req.body;
let user;
let userCredentials = [];
// Prioritize discoverable credentials (no email needed initially)
// If email is provided, we can pre-filter allowedCredentials to only show
// passkeys for that user. If not, the authenticator handles discovery.
if (email) {
user = users.get(email);
if (user) {
userCredentials = credentials.get(user.id) || [];
} else {
// For email-first, if user not found, don't generate options
return res.status(404).json({ error: 'User not found.' });
}
}
try {
const options = await generateAuthenticationOptions({
rpID: RP_ID,
// If email is provided, we can restrict which credentials the authenticator
// should consider. For full discoverable (username-less), leave this empty.
allowCredentials: userCredentials.map(cred => ({
id: Buffer.from(cred.id, 'base64url'),
type: 'public-key',
transports: cred.transports, // Helps the authenticator narrow down options
})),
userVerification: 'preferred',
timeout: 60000,
// extensions: {
// // Example: if requesting a specific extension from the authenticator
// 'uvm': true, // User Verification Method extension
// },
});
// If email was provided, store challenge for that user.
// If not, we might store a generic challenge or assume a subsequent step
// will identify the user. For this example, let's just make sure a user exists.
if (!user && users.size > 0) {
user = users.values().next().value; // Pick a random user for generic testing
console.warn("No email provided for authentication, using first registered user for challenge.");
} else if (!user) {
return res.status(400).json({ error: 'No users registered for authentication.' });
}
user.currentChallenge = options.challenge;
users.set(user.email, user);
return res.json({ options });
} catch (error) {
console.error('Error generating authentication options:', error);
return res.status(500).json({ error: error.message });
}
});
2.4 Understanding verifyAuthenticationResponse
This verifies the signature from the authenticator and updates the counter.
Key Parameters and Advanced Usage:
response: TheAuthenticationCredentialobject from the client.expectedChallenge,expectedOrigin,expectedRPID: Same as verification.authenticator: This object must contain thecredentialID,credentialPublicKey, and the last seencounterfor the specific passkey used in authentication. This is how the server knows which public key to use for verification and how to check the counter.requireUserVerification: Should betrueifuserVerification: 'required'was used.
Output (authenticationInfo):
newCounter: The incremented counter value from the authenticator. You must update your stored counter for this credential tonewCounterin your database. Failure to do so enables replay attacks!userHandle: For discoverable credentials, this is theuserIDthat the authenticator provides. This allows the server to identify the user without an email/username.
Example: Advanced Authentication Verification on Server
// server.js snippet for verifyAuthenticationResponse
const { verifyAuthenticationResponse } = require('@simplewebauthn/server');
// ... (user and credential storage setup)
app.post('/verify-authentication', async (req, res) => {
const { email, credential } = req.body; // email might be null for discoverable login
// IMPORTANT: Identify the user and credential first.
// For discoverable credentials, 'credential.rawId' and 'authenticationInfo.userHandle' are key.
let user = null;
let storedCredential = null;
const credentialIdFromClient = Buffer.from(credential.rawId).toString('base64url');
// Scenario 1: Email was provided (username-first or explicit login)
if (email) {
user = users.get(email);
if (user) {
const userCredentials = credentials.get(user.id) || [];
storedCredential = userCredentials.find(cred => cred.id === credentialIdFromClient);
}
} else {
// Scenario 2: No email provided (discoverable credential flow)
// Iterate through all users' credentials to find a match by credential ID.
// In a real system, you'd efficiently query your database for a credential by ID.
for (const [userId, userCreds] of credentials.entries()) {
storedCredential = userCreds.find(cred => cred.id === credentialIdFromClient);
if (storedCredential) {
// Found the credential, now find its owner (user)
for (const u of users.values()) {
if (u.id === userId) {
user = u;
break;
}
}
break;
}
}
}
if (!user || !storedCredential) {
return res.status(400).json({ error: 'Credential not found or user not identified.' });
}
try {
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: user.currentChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
authenticator: {
credentialID: Buffer.from(storedCredential.id, 'base64url'),
credentialPublicKey: Buffer.from(storedCredential.publicKey, 'base64url'),
counter: storedCredential.counter, // The *last stored* counter value
transports: storedCredential.transports,
},
requireUserVerification: false,
requireUserPresence: true,
// // For extensions:
// expectedExtensions: {
// uvm: true, // If we requested the 'uvm' extension
// },
});
const { verified, authenticationInfo } = verification;
if (verified && authenticationInfo) {
const { newCounter, userHandle } = authenticationInfo;
// IMPORTANT: Update the stored counter for this credential
storedCredential.counter = newCounter;
// (In a real app, save this 'storedCredential' object back to your database)
credentials.set(user.id, credentials.get(user.id).map(c => c.id === storedCredential.id ? storedCredential : c));
// For discoverable credentials, 'userHandle' will contain the `userID` that was
// passed during registration. You can use this to confirm the user.
if (userHandle && user.id !== Buffer.from(userHandle).toString('utf8')) {
// Additional check if userHandle is present and doesn't match expected user.
// This should ideally not happen if the credential was correctly matched.
console.warn("UserHandle from authenticator does not match stored userID.");
}
user.currentChallenge = ''; // Clear challenge after use
return res.json({ message: 'Login successful', verified, user: { email: user.email, id: user.id } });
} else {
return res.status(400).json({ error: 'Passkey authentication failed verification' });
}
} catch (error) {
console.error('Error verifying authentication:', error);
return res.status(500).json({ error: error.message });
}
});
Exercises/Mini-Challenges
- Attestation Deep Dive: Research one of the
attestationTypevalues ('direct','indirect'). What information does it provide, and how would you go about verifying it using@simplewebauthn/server(hint: exploretrustedCertificatesoptions inverifyRegistrationResponse)? Why is it generally avoided for consumer applications? excludeCredentialsEffectiveness: Explain howexcludeCredentialsimproves the user experience during passkey registration. Simulate a scenario whereexcludeCredentialsis not used, and a user tries to register the same passkey twice on the same device. What would happen?- Authentication Flow with
allowCredentials: Consider a scenario where your user has 5 passkeys registered. If you callgenerateAuthenticationOptionswithallowCredentialscontaining only 2 of those 5 IDs, how will the authenticator respond? What are the UX implications?
3. Cross-Device Authentication (QR Code Flows)
Cross-Device Authentication (CDA) allows a user to authenticate on a desktop or laptop by leveraging a passkey stored on their mobile device. This is crucial for bridging the gap between desktop browsers and mobile platform authenticators, providing a seamless experience.
3.1 How QR Code Flows Work (Conceptual)
Initiation on Desktop (RP-side):
- The user on a desktop browser clicks a “Login with Passkey” button.
- The desktop RP client sends a request to the RP server to initiate CDA.
- The RP server generates a unique “transaction ID” or “session ID” and associated WebAuthn authentication options.
- The RP server then generates a QR code URL that embeds this transaction ID and a link to a mobile-friendly endpoint on the RP.
- The RP server sends this QR code URL back to the desktop client.
- The desktop client displays the QR code to the user.
- The RP server also starts polling (or uses WebSockets) to wait for the mobile device’s authentication status for this transaction ID.
User Scans QR Code on Mobile:
- The user uses their mobile device’s camera to scan the QR code displayed on the desktop.
- The mobile browser navigates to the mobile-friendly endpoint (e.g.,
https://yourdomain.com/authenticate-mobile?txnId=XYZ) embedded in the QR code.
Authentication on Mobile (Authenticator-side):
- The mobile browser, upon hitting the endpoint, sees the
txnId. - The mobile RP client on the mobile device retrieves the WebAuthn authentication options from the RP server using the
txnId. - The mobile device (authenticator) prompts the user for biometric verification and performs the passkey authentication.
- The mobile RP client sends the WebAuthn assertion (signed challenge) back to the RP server, referencing the
txnId.
- The mobile browser, upon hitting the endpoint, sees the
Verification and Login on Desktop (RP-side):
- The RP server receives the assertion from the mobile device.
- The RP server verifies the assertion using the stored public key associated with the
txnId. - Once verified, the RP server notifies the waiting desktop client (via polling or WebSocket) that authentication was successful.
- The desktop client then logs the user in.
3.2 Implementing CDA with @simplewebauthn (Node.js & React)
This requires a more intricate setup, involving a transient state for the QR code session and a polling mechanism.
Server-Side (server.js) - Extensions:
// server.js (additional code for QR code flow)
// In-memory store for QR code sessions (transient state)
const qrSessions = new Map(); // Map<sessionId, { challenge, userId, expectedCredentialId (optional) }>
// Endpoint to initiate QR code login
app.post('/initiate-qr-login', async (req, res) => {
// Generate a unique session ID for this QR code flow
const sessionId = String(Date.now()) + Math.random().toString(36).substring(2, 15);
// Generate authentication options (can be for a specific user if username-first, or general)
const options = await generateAuthenticationOptions({
rpID: RP_ID,
// For passwordless QR code flow, you might leave allowCredentials empty initially
// to let the authenticator discover.
allowCredentials: [],
userVerification: 'preferred',
timeout: 120000, // Longer timeout for QR flow
});
// Store the challenge associated with this session ID
qrSessions.set(sessionId, {
challenge: options.challenge,
status: 'pending', // 'pending', 'authenticated', 'failed'
userId: null, // Will be set after successful auth
credentialId: null // Will be set after successful auth
});
// Generate a mobile URL for the QR code
const mobileAuthUrl = `${ORIGIN}/mobile-auth?sessionId=${sessionId}`;
return res.json({ qrCodeUrl: mobileAuthUrl, sessionId, options });
});
// Endpoint for mobile device to complete authentication
app.post('/complete-qr-authentication', async (req, res) => {
const { sessionId, credential } = req.body;
const session = qrSessions.get(sessionId);
if (!session || session.status !== 'pending') {
return res.status(400).json({ error: 'Invalid or expired session.' });
}
// Now verify the authentication response using the challenge stored in the session
// This part is similar to a standard verifyAuthenticationResponse, but we need
// to identify the user and credential using the 'userHandle' from the response.
const credentialIdFromClient = Buffer.from(credential.rawId).toString('base64url');
let user = null;
let storedCredential = null;
// Iterate through all stored credentials to find a match for the rawId
for (const [userId, userCreds] of credentials.entries()) {
storedCredential = userCreds.find(cred => cred.id === credentialIdFromClient);
if (storedCredential) {
// If found, identify the user who owns this credential
for (const u of users.values()) {
if (u.id === userId) {
user = u;
break;
}
}
break;
}
}
if (!user || !storedCredential) {
session.status = 'failed';
qrSessions.set(sessionId, session);
return res.status(400).json({ error: 'Credential not found for user.' });
}
try {
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: session.challenge,
expectedOrigin: ORIGIN, // Mobile origin should match desktop origin
expectedRPID: RP_ID,
authenticator: {
credentialID: Buffer.from(storedCredential.id, 'base64url'),
credentialPublicKey: Buffer.from(storedCredential.publicKey, 'base64url'),
counter: storedCredential.counter,
transports: storedCredential.transports,
},
requireUserVerification: false, // Match options
requireUserPresence: true,
});
const { verified, authenticationInfo } = verification;
if (verified && authenticationInfo) {
const { newCounter, userHandle } = authenticationInfo;
// Update the stored counter
storedCredential.counter = newCounter;
credentials.set(user.id, credentials.get(user.id).map(c => c.id === storedCredential.id ? storedCredential : c));
// Update QR session status and user info
session.status = 'authenticated';
session.userId = user.id;
session.credentialId = storedCredential.id;
qrSessions.set(sessionId, session);
return res.json({ success: true, message: 'Mobile authentication complete.' });
} else {
session.status = 'failed';
qrSessions.set(sessionId, session);
return res.status(400).json({ error: 'Mobile authentication failed verification.' });
}
} catch (error) {
console.error('Error verifying mobile QR auth:', error);
session.status = 'failed';
qrSessions.set(sessionId, session);
return res.status(500).json({ error: error.message });
}
});
// Endpoint for desktop to poll for status
app.get('/qr-status/:sessionId', (req, res) => {
const { sessionId } = req.params;
const session = qrSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found.' });
}
// Return current status and user info if authenticated
if (session.status === 'authenticated') {
const user = users.get(Array.from(users.values()).find(u => u.id === session.userId).email); // Find user by id
return res.json({
status: session.status,
message: 'Authentication successful',
user: { email: user.email, id: user.id }
});
} else {
return res.json({ status: session.status, message: 'Waiting for mobile authentication...' });
}
});
// Mobile-friendly page to initiate client-side WebAuthn on mobile device
app.get('/mobile-auth', (req, res) => {
const { sessionId } = req.query;
if (!sessionId) {
return res.status(400).send('Session ID is missing.');
}
// Render a simple HTML page that contains JavaScript to call startAuthentication
// and then posts the result back to /complete-qr-authentication
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Mobile Passkey Authentication</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<h1>Complete Authentication on this device</h1>
<p id="status">Waiting for authentication...</p>
<script type="module">
import { startAuthentication } from '@simplewebauthn/browser';
const sessionId = "${sessionId}";
const statusElem = document.getElementById('status');
async function authenticateOnMobile() {
try {
// 1. Get authentication options from the server using the sessionId
statusElem.textContent = 'Fetching authentication options...';
const resp = await fetch('/initiate-qr-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: sessionId }) // Resend session ID to get options for mobile flow
});
const { options } = await resp.json();
// 2. Start WebAuthn authentication
statusElem.textContent = 'Confirming identity (Face ID/Touch ID)...';
const asseResp = await startAuthentication(options);
// 3. Send the assertion back to the server
statusElem.textContent = 'Sending result to server...';
const completeResp = await fetch('/complete-qr-authentication', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, credential: asseResp }),
});
const result = await completeResp.json();
if (result.success) {
statusElem.textContent = 'Authentication successful! You can now close this page.';
window.close(); // Close the mobile tab
} else {
statusElem.textContent = 'Authentication failed: ' + (result.error || 'Unknown error');
}
} catch (error) {
console.error('Mobile authentication error:', error);
statusElem.textContent = 'Authentication failed: ' + error.message;
}
}
// Initial request to get options and start the flow
authenticateOnMobile();
</script>
</body>
</html>
`);
});
Frontend (React/Desktop) - Extensions:
- Use a library like
qrcode.reactto display the QR code. - Implement a polling mechanism (e.g.,
setIntervalor a customusePollinghook in React) to query the/qr-status/:sessionIdendpoint.
// Example React component (conceptual)
import React, { useState, useEffect } from 'react';
import QRCode from 'qrcode.react'; // npm install qrcode.react
function QrLoginComponent() {
const [qrCodeUrl, setQrCodeUrl] = useState('');
const [sessionId, setSessionId] = useState('');
const [status, setStatus] = useState('idle'); // idle, pending, authenticated, failed
const [loggedInUser, setLoggedInUser] = useState(null);
const [error, setError] = useState('');
const initiateQrLogin = async () => {
setStatus('pending');
setError('');
try {
const response = await fetch('/api/initiate-qr-login', { method: 'POST' });
const data = await response.json();
setQrCodeUrl(data.qrCodeUrl);
setSessionId(data.sessionId);
} catch (err) {
setError('Failed to initiate QR login.');
setStatus('failed');
console.error(err);
}
};
useEffect(() => {
let intervalId;
if (sessionId && status === 'pending') {
intervalId = setInterval(async () => {
try {
const response = await fetch(`/api/qr-status/${sessionId}`);
const data = await response.json();
if (data.status === 'authenticated') {
setStatus('authenticated');
setLoggedInUser(data.user);
clearInterval(intervalId);
} else if (data.status === 'failed') {
setStatus('failed');
setError('Mobile authentication failed.');
clearInterval(intervalId);
}
} catch (err) {
setError('Error polling QR status.');
setStatus('failed');
clearInterval(intervalId);
console.error(err);
}
}, 3000); // Poll every 3 seconds
}
return () => clearInterval(intervalId); // Cleanup on unmount or status change
}, [sessionId, status]);
return (
<div>
{status === 'idle' && (
<button onClick={initiateQrLogin}>Login with QR Code</button>
)}
{status === 'pending' && qrCodeUrl && (
<div>
<p>Scan this QR code with your mobile device to log in:</p>
<QRCode value={qrCodeUrl} size={256} level="H" />
<p>Status: Waiting for mobile authentication...</p>
</div>
)}
{status === 'authenticated' && loggedInUser && (
<p>Welcome, {loggedInUser.email}! You are logged in.</p>
)}
{status === 'failed' && <p style={{ color: 'red' }}>Error: {error}</p>}
</div>
);
}
export default QrLoginComponent;
Exercises/Mini-Challenges
- QR Code URL Security: How would you protect the
sessionIdembedded in the QR code from being tampered with or used by an unauthorized party? (Hint: consider signing the session ID or using short-lived, single-use tokens). - WebSocket Alternative: Instead of polling, how could you implement the desktop-to-server communication using WebSockets for real-time updates? Outline the server-side and client-side changes required.
- Error Handling in CDA: What error scenarios can occur during QR code authentication (e.g., mobile device doesn’t support passkeys, user cancels, network issues)? How would your implementation provide helpful feedback to the user on both the mobile and desktop clients?
4. Platform-Specific APIs
While WebAuthn provides a standardized interface for browsers, native applications (iOS, Android, desktop apps) leverage platform-specific APIs for deeper integration with the operating system’s credential management and secure enclaves.
4.1 Android’s Credential Manager API
Android 14+ introduced the Credential Manager API, a unified API for managing all types of user credentials, including passkeys, passwords, and federated login data. It simplifies the developer experience and offers a consistent UI for users.
Key Features:
- Unified Credential Storage: Handles passkeys, passwords, and federated accounts (e.g., “Sign in with Google”).
- Automatic Passkey Creation and Sign-in: The system can intelligently suggest creating a passkey or using an existing one based on context.
- Cross-Device Passkey Transfer: Android 16’s “Restore Credentials” feature allows passkeys to be seamlessly restored on new devices.
- WebAuthn Integration: Under the hood, it still relies on WebAuthn standards but provides a more native and integrated developer experience.
Developer Experience (Conceptual):
// Android (Kotlin) - Using Credential Manager API
import android.os.Bundle
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.credentials.CreateCredentialRequest
import androidx.credentials.CreatePasskeyRequest
import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetCredentialResponse
import androidx.credentials.GetPasskeyRequest
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.GetCredentialException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONObject
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
class PasskeyActivity : AppCompatActivity() {
private val credentialManager by lazy { CredentialManager.create(this) }
private val scope = CoroutineScope(Dispatchers.Main)
private val passkeyCreationResult = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
// Passkey creation was successful, handle the response
val response = CreateCredentialResponse.fromIntent(result.data!!)
val registrationJson = JSONObject(response.data.getString("androidx.credentials.BUNDLE_KEY_REGISTRATION_RESPONSE") ?: "")
// Send registrationJson to your backend for verification
println("Passkey registered successfully: $registrationJson")
} else {
// Passkey creation failed or was cancelled
val exception = CreateCredentialException.createFrom(result.data!!)
println("Passkey creation failed: ${exception.errorMessage}")
}
}
private val passkeyLoginResult = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
// Passkey login was successful, handle the response
val response = GetCredentialResponse.fromIntent(result.data!!)
when (val credential = response.credential) {
is PasskeyCredential -> {
val authenticationJson = JSONObject(credential.authenticationResponseJson)
// Send authenticationJson to your backend for verification
println("Passkey login successful: $authenticationJson")
}
is GoogleIdTokenCredential -> {
println("Signed in with Google ID token: ${credential.idToken}")
}
else -> {
println("Unknown credential type")
}
}
} else {
// Passkey login failed or was cancelled
val exception = GetCredentialException.createFrom(result.data!!)
println("Passkey login failed: ${exception.errorMessage}")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_passkey)
findViewById<Button>(R.id.registerPasskeyButton).setOnClickListener {
registerPasskey()
}
findViewById<Button>(R.id.loginPasskeyButton).setOnClickListener {
loginWithPasskey()
}
}
private fun registerPasskey() = scope.launch {
try {
// Backend provides the registration JSON based on WebAuthn standards
// Example:
val registrationJson = """
{
"challenge": "base64urlEncodedChallenge",
"rp": {"id": "yourdomain.com", "name": "Your App"},
"user": {"id": "base64urlEncodedUserId", "name": "user@example.com", "displayName": "User Name"},
"pubKeyCredParams": [{"type": "public-key", "alg": -7}],
"timeout": 60000,
"attestation": "none"
}
""".trimIndent()
val request = CreatePasskeyRequest(
registrationJson = registrationJson,
preregistrationJson = null // For Android 16+ pre-registration features
)
val pendingIntent = credentialManager.createCredential(request)
passkeyCreationResult.launch(IntentSenderRequest.Builder(pendingIntent.intentSender).build())
} catch (e: CreateCredentialException) {
println("Passkey creation failed: ${e.errorMessage}")
}
}
private fun loginWithPasskey() = scope.launch {
try {
// Backend provides the authentication JSON
// Example for discoverable credential:
val authenticationJson = """
{
"challenge": "base64urlEncodedChallenge",
"rpId": "yourdomain.com",
"allowCredentials": [],
"timeout": 60000,
"userVerification": "preferred"
}
""".trimIndent()
val request = GetPasskeyRequest(
jsonRequest = authenticationJson,
// Also support passwords or federated if desired for a unified login UI
credentialTypes = setOf(PasskeyCredential.TYPE, PasswordCredential.TYPE, GoogleIdTokenCredential.TYPE)
)
val pendingIntent = credentialManager.getCredential(request)
passkeyLoginResult.launch(IntentSenderRequest.Builder(pendingIntent.intentSender).build())
} catch (e: GetCredentialException) {
println("Passkey login failed: ${e.errorMessage}")
}
}
}
4.2 Apple’s AuthenticationServices (ASAuthorizationController)
On iOS and macOS, the AuthenticationServices framework (specifically ASAuthorizationController and ASAuthorizationPlatformPublicKeyCredentialProvider) provides a native UI and API for interacting with passkeys stored in iCloud Keychain.
Key Features:
- System-Provided UI: Offers a consistent and trusted user experience for passkey creation and sign-in.
- iCloud Keychain Integration: Seamlessly stores and syncs passkeys across all of a user’s Apple devices.
- Automatic Account Creation: iOS 17+ supports automatic passkey creation during account registration.
ASCredentialIdentityStore: Allows your app to manage and report existing passkeys, helping the system offer the right credential at the right time.ASCredentialUpdater(iOS 26+): Enables the RP to signal updates to passkey metadata (e.g., username change) to the system.
Developer Experience (Conceptual):
// iOS (Swift) - Using AuthenticationServices
import AuthenticationServices
import CryptoKit
class PasskeyManager: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
var currentRequestCompletion: ((ASAuthorizationCredential?, Error?) -> Void)?
// MARK: - Register a Passkey
func registerPasskey(userId: String, username: String, challenge: String, completion: @escaping (ASAuthorizationCredential?, Error?) -> Void) {
self.currentRequestCompletion = completion
// 1. Create a platform public key credential registration request.
let registrationRequest = ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest(
challenge: Data(base64Encoded: challenge)!, // Convert base64url challenge to Data
name: username,
userID: userId,
// RP ID should match your web app's domain (e.g., "example.com")
// Make sure this matches the RP ID used on your backend.
relyingPartyIdentifier: "yourdomain.com"
)
// Add additional options if needed (e.g., attestation, authenticator selection)
// registrationRequest.authenticatorAttachment = .platform
// 2. Create an authorization controller with the request.
let authorizationController = ASAuthorizationController(authorizationRequests: [registrationRequest])
authorizationController.delegate = self
authorizationController.presentationContextProvider = self
authorizationController.performRequests()
}
// MARK: - Log in with a Passkey
func loginWithPasskey(challenge: String, completion: @escaping (ASAuthorizationCredential?, Error?) -> Void) {
self.currentRequestCompletion = completion
// 1. Create a platform public key credential assertion request.
let assertionRequest = ASAuthorizationPlatformPublicKeyCredentialAssertionRequest(
challenge: Data(base64Encoded: challenge)!, // Convert base64url challenge to Data
// RP ID must match the one used during registration.
relyingPartyIdentifier: "yourdomain.com"
)
// Optionally, specify allowed credentials if you're doing a username-first flow
// assertionRequest.allowedCredentials = [ASAuthorizationPlatformPublicKeyCredentialAssertionRequest.Credential(id: Data(base64Encoded: "base64urlCredentialId")!)]
// 2. Create an authorization controller with the request.
let authorizationController = ASAuthorizationController(authorizationRequests: [assertionRequest])
authorizationController.delegate = self
authorizationController.presentationContextProvider = self
authorizationController.performRequests()
}
// MARK: - ASAuthorizationControllerDelegate
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
guard let completion = currentRequestCompletion else { return }
switch authorization.credential {
case let credential as ASAuthorizationPlatformPublicKeyCredentialRegistration:
// Passkey registration successful
let rawCredential = [
"id": credential.credentialID.base64URLEncodedString(),
"rawId": credential.credentialID.base64URLEncodedString(),
"response": [
"clientDataJSON": credential.rawClientDataJSON.base64URLEncodedString(),
"attestationObject": credential.rawAttestationObject.base64URLEncodedString()
],
"type": "public-key"
]
// Send rawCredential (JSON representation) to your backend for verification
completion(credential, nil)
case let credential as ASAuthorizationPlatformPublicKeyCredentialAssertion:
// Passkey login successful
let rawCredential = [
"id": credential.credentialID.base64URLEncodedString(),
"rawId": credential.credentialID.base64URLEncodedString(),
"response": [
"clientDataJSON": credential.rawClientDataJSON.base64URLEncodedString(),
"authenticatorData": credential.rawAuthenticatorData.base64URLEncodedString(),
"signature": credential.signature.base64URLEncodedString(),
"userHandle": credential.userID.base64URLEncodedString()
],
"type": "public-key"
]
// Send rawCredential (JSON representation) to your backend for verification
completion(credential, nil)
default:
completion(nil, NSError(domain: "PasskeyError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Unknown authorization credential"]))
}
}
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
currentRequestCompletion?(nil, error)
}
// MARK: - ASAuthorizationControllerPresentationContextProviding
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
// Return the window that the authorization UI should be presented over
return UIApplication.shared.windows.first!
}
}
// Extension to convert Data to base64url string
extension Data {
func base64URLEncodedString() -> String {
return self.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
Exercises/Mini-Challenges
- Platform Credential Manager Benefits: Compare and contrast the benefits of using Android’s Credential Manager API or Apple’s AuthenticationServices over a purely web-based WebAuthn implementation in a native mobile application.
- Passkey Update (
ASCredentialUpdater): Research theASCredentialUpdaterAPI in iOS 26+. Describe a scenario where your backend might use this to update a passkey’s metadata on the user’s device. - Account Creation API: How does Apple’s
ASAuthorizationPlatformPublicKeyCredentialRegistrationRequestsimplify the user experience for new account sign-ups compared to traditional password-based registration?
5. Passkey Syncing Provider Integration
Passkey syncing providers (like iCloud Keychain, Google Password Manager, and third-party password managers like 1Password) play a crucial role in the user experience by enabling passkeys to be available across a user’s devices. As a Relying Party, understanding how these providers interact with your service is key.
5.1 How Syncing Works (Under the Hood)
- End-to-End Encryption: When a passkey is created, the private key is generated on the user’s device and then encrypted using a key derived from the user’s account password/PIN (e.g., iCloud account password, Google account password). This encrypted private key is then synced to the cloud.
- Decryption on New Devices: When the user logs into a new device, the encrypted private key is retrieved from the cloud. The user’s platform credentials (e.g., iCloud account credentials, Google account login) are used to decrypt the private key, making it available for local authentication.
- RP Agnostic: Crucially, the Relying Party (your website/app) typically does not directly interact with the syncing mechanism. You continue to interact with the standard WebAuthn API, and the operating system or browser handles the storage and retrieval from its underlying credential manager, which in turn might use a syncing provider.
5.2 Third-Party Password Manager Integration
Major password managers (e.g., 1Password, Bitwarden, Dashlane) are increasingly supporting passkeys. This provides an alternative syncing and management solution for users who prefer a single, cross-platform password manager.
Key Aspects for RPs:
- No Direct Integration (Typically): For the most part, your WebAuthn implementation doesn’t need to do anything special to support third-party password managers. If the user has configured their browser to use a specific password manager as a passkey provider, the browser will act as the intermediary, directing WebAuthn calls to that provider.
- “Passkey-First” UX: A user-friendly UX for passkeys should allow the browser (and by extension, the OS/password manager) to present the available passkeys first, regardless of their origin.
conditional mediationandautocomplete="username webauthn": These browser features (mentioned in Advanced Topics) are essential for allowing password managers to automatically suggest passkeys when a user focuses on an input field.
Example autocomplete usage for better provider integration:
<!-- In your HTML login form -->
<form>
<label for="username">Username:</label>
<input type="text" id="username" name="username" autocomplete="username webauthn" required>
<!-- When the user focuses on this input, the browser/OS/password manager can suggest passkeys -->
<button type="submit">Log In</button>
</form>
5.3 Secure Import/Export and CXP (Credential Exchange Protocol)
One significant advancement addressing “vendor lock-in” and enhancing user control is the ability to securely import and export passkeys between different credential managers.
- CXP (Credential Exchange Protocol): A FIDO Alliance specification that defines a secure way to transfer credentials (including passkeys) between authenticators or credential managers.
- iOS 26+
ASAuthorizationCredentialTransferProvider: Apple has implemented CXP for secure passkey import/export in iOS 26. This allows users to move passkeys from iCloud Keychain to a third-party password manager, or vice-versa, with cryptographic integrity. Android is also working on similar features.
Implications for RPs:
- Improved User Experience: Users have more control over where their passkeys are stored, reducing anxiety about platform lock-in.
- No Direct RP Action: Similar to syncing, the RP typically doesn’t directly participate in the import/export process. Your job is to support the WebAuthn standard, and the client-side ecosystem handles the credential transfer.
- Reduced Support Burden: As users can manage their passkeys more flexibly, it might reduce support requests related to passkey portability.
Exercises/Mini-Challenges
- User Choice: Discuss the pros and cons of using a platform passkey provider (e.g., iCloud Keychain) versus a third-party password manager for passkey storage from a user’s perspective.
autocompleteImportance: How doesautocomplete="username webauthn"bridge the gap between your web application and the user’s credential manager, facilitating a smoother passkey login experience?- CXP Impact: Explain why the Credential Exchange Protocol (CXP) and its implementation in platforms like iOS 26 are significant for the long-term adoption and user trust of passkeys.
6. Advanced Attestation
Attestation is a powerful, yet often complex, feature of WebAuthn that allows a Relying Party (RP) to cryptographically verify properties of the authenticator (the device or software providing the passkey). While often ’none’ for consumer apps, it’s vital for high-assurance scenarios.
6.1 Purpose of Attestation
- Authenticity Verification: Proof that the authenticator is a genuine FIDO-certified device from a known vendor, not a malicious software implementation.
- Security Properties: Can indicate if the authenticator has a secure enclave, a certified hardware component, or if user verification (e.g., biometrics) is performed securely on the device.
- Compliance: Meeting regulatory requirements for strong identity assurance.
6.2 Attestation Formats
The attestationObject returned during registration contains the attestation statement, which can be in various formats:
none(recommended for consumer apps): No attestation data is provided. This maximizes privacy and broadens compatibility.packed: A general-purpose format, widely supported. Can include a full certificate chain, a signed attestation statement, and AAGUID.fido-u2f: For U2F-compatible security keys. Often has a specific certificate.android-key,android-safetynet: Specific to Android authenticators.android-safetynetrelies on Google Play Services to attest to the device’s integrity.apple: Specific to Apple devices, providing proof that the credential was created by a genuine Apple platform authenticator.tpm: For Trusted Platform Modules (TPMs) found in many Windows machines.
6.3 Verifying Attestation (@simplewebauthn/server)
Verifying attestation involves:
- Parsing the
attestationObject: Extracting the attestation statement and authenticator data. - Validating the Certificate Chain (if present): Ensuring the certificate (if any) is valid, unrevoked, and signed by a trusted root certificate. This often requires storing a set of trusted Root CAs for different authenticator vendors.
- Verifying the Attestation Signature: Checking that the attestation statement is cryptographically signed by the authenticator’s attestation private key.
- Checking AAGUID: Optionally comparing the AAGUID (Authenticator Assurance Globally Unique ID) against a list of known/trusted AAGUIDs.
Server-Side Example (Conceptual, with trusted roots):
// server.js (excerpt for advanced attestation verification)
const { verifyRegistrationResponse } = require('@simplewebauthn/server');
// Imagine you have a collection of trusted root certificates for different vendors
const trustedRootCertificates = new Set([
// Load PEM-encoded certificates of trusted FIDO authenticator vendors
// Example: fs.readFileSync('certs/yubico-root.pem'),
// fs.readFileSync('certs/google-titan-root.pem'),
]);
app.post('/verify-registration-with-attestation', async (req, res) => {
const { email, credential, attestationPreference } = req.body;
// ... basic validation and user lookup ...
try {
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: user.currentChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
requireUserVerification: true, // Example: requiring UV for high assurance
requireUserPresence: true,
attestationOptions: {
// If you request a specific attestation type, you need to verify it.
// For 'none', this isn't strictly necessary for verification logic itself.
// trustedRootCertificates: trustedRootCertificates, // Provide your trusted root certificates
// trustedAttestationTypes: [attestationPreference], // E.g., 'packed', 'apple'
},
});
const { verified, registrationInfo } = verification;
if (verified && registrationInfo) {
// Further checks based on attestation info if `attestationType` was not 'none'
if (registrationInfo.attestationObject) {
// Parse attestation for more granular checks (e.g., AAGUID, specific extensions)
const attestation = registrationInfo.attestationObject; // Raw buffer or parsed object
// If using 'apple' attestation
// if (attestation.fmt === 'apple') {
// const AppleAttestation = attestation.attStmt.x5c[0];
// // Perform custom checks on Apple's certificate if needed
// }
}
// ... store credential as before ...
return res.json({ message: 'Passkey registered with attestation', verified });
} else {
return res.status(400).json({ error: 'Passkey registration failed verification' });
}
} catch (error) {
console.error('Error verifying registration with attestation:', error);
return res.status(500).json({ error: error.message });
}
});
6.4 Advanced Attestation Use Cases and Considerations
- Enterprise Environments: Often used to ensure employees are using FIDO-certified security keys issued by the organization, or devices with secure enclaves.
- Regulatory Compliance: Certain industries or regulations might mandate high assurance levels that require attestation.
- Trust Anchors: Maintaining a robust set of trusted root certificates is critical for attestation verification. This list needs to be updated regularly.
- Privacy vs. Assurance: Attestation provides assurance at the cost of some privacy, as it reveals information about the authenticator. Balancing this trade-off is crucial.
- AAGUID Filtering: You might choose to only allow authenticators with specific AAGUIDs if you are enforcing certain hardware.
Exercises/Mini-Challenges
- Attestation Trade-offs: Discuss the security benefits gained by requiring attestation, and the potential drawbacks related to user privacy, compatibility, and implementation complexity.
- Trusted Roots Management: If you were to implement attestation in a production system, describe the process of managing
trustedRootCertificates. Where would you get them, how would you store them, and how would you ensure they stay up-to-date? - AAGUID Filtering Scenario: Imagine you are building a system that only allows passkeys from a specific, highly secure hardware security key. How would you use the AAGUID and potentially attestation to enforce this policy?
7. Passkey Provisioning/Deprovisioning (Enterprise Context)
In an enterprise setting, managing passkeys goes beyond individual user actions. Organizations need to provision (issue) passkeys to employees and deprovision (revoke) them when employees leave or devices are lost.
7.1 Automated Provisioning
Automated provisioning allows administrators to issue passkeys to users without manual intervention, often integrated with Identity Providers (IdPs) or User Lifecycle Management (ULM) systems.
- FIDO Alliance Metadata Service (MDS): Provides information about authenticators, including their AAGUIDs, enabling enterprises to enforce policies on what types of authenticators are allowed.
- Direct Credential Creation (Backend Initiated): While most passkeys are user-initiated, in some highly controlled environments, an administrator might be able to provision a credential to a user’s managed device. This is less common for “passkeys” (which are typically user-controlled) and more for managed FIDO security keys.
- Managed Devices: For company-issued devices, Mobile Device Management (MDM) solutions can pre-configure FIDO authenticators or facilitate passkey enrollment during device setup.
7.2 Deprovisioning and Revocation
When a passkey is lost, compromised, or a user leaves the organization, it must be swiftly revoked.
- Server-Side Revocation: The primary mechanism. When a passkey is revoked (e.g., by an administrator or the user through account settings), the Relying Party server deletes the associated public key credential from its database. This prevents the compromised passkey from being used for future authentication.
- Client-Side Deletion (User Action): Users can delete passkeys from their device’s credential manager (e.g., iCloud Keychain, Google Password Manager). While this prevents local use, the server-side credential must also be revoked to be fully secure.
- Account Recovery and Credential Re-issuance: A robust recovery process should allow users to regain access and register new passkeys, while ensuring old, compromised passkeys are definitively revoked.
/.well-known/passkey-endpointsfor Management: This standardized endpoint helps credential managers (like third-party password managers) find the passkey management page on your website, allowing users to easily revoke/delete passkeys from your service.
Example /.well-known/passkey-endpoints (JSON file):
This file should be served at https://yourdomain.com/.well-known/passkey-endpoints
{
"passkey_management_url": "https://yourdomain.com/settings/passkeys",
"developer_contact_email": "security@yourdomain.com"
}
Credential managers can then use this to direct users to your passkey management page.
Exercises/Mini-Challenges
- Enterprise Policy: As an administrator for a company, what criteria would you use to decide which types of authenticators (platform vs. cross-platform, specific AAGUIDs) are allowed for employee passkeys?
- Deprovisioning Workflow: Design a comprehensive deprovisioning workflow for an employee leaving a company, ensuring that all their passkeys associated with company resources are revoked. Consider both server-side and client-side actions.
- Metadata Service Role: How could the FIDO Alliance Metadata Service (MDS) assist an enterprise in enforcing policies on the types of authenticators allowed for passkey registration?
8. WebAuthn Extensions
WebAuthn extensions provide additional functionality beyond the core registration and authentication flows, allowing Relying Parties (RPs) to request specific information from or capabilities from authenticators.
8.1 Understanding Extensions
Extensions are optional features that the RP can request from the authenticator during registration or authentication. The authenticator may or may not support a given extension.
How they work:
- RP Requests Extension: The RP includes the extension in the
extensionsdictionary of the WebAuthn options (publicKeyCredentialCreationOptionsorpublicKeyCredentialRequestOptions). - Authenticator Processes Extension: If the authenticator supports the extension, it processes the request and includes the requested information in the
clientExtensionResultsof theAuthenticatorResponse. - RP Verifies/Uses Results: The RP server then processes the
clientExtensionResultsduring verification.
8.2 Common and Useful Extensions
credProps(Credential Properties Extension):- Purpose: Allows the RP to request properties of the created credential, such as whether it’s a discoverable (resident) key (
rk) and whether user verification (UV) was performed on the authenticator (uvm). - Use Case: Useful for RPs to confirm that a discoverable passkey was indeed created, or to understand the security context of the authentication.
- Example (Request):
// generateRegistrationOptions extensions: { credProps: true } - Example (Result in
verifyRegistrationResponse.registrationInfo.credential.clientExtensionResults):{ "credProps": { "rk": true, // true if resident key (discoverable) "uvm": [ // User verification methods used [1, 2, 4] // Example: Face ID, Fingerprint, PIN (specific bitmask values) ] } }
- Purpose: Allows the RP to request properties of the created credential, such as whether it’s a discoverable (resident) key (
uvm(User Verification Method Extension):- Purpose: Provides a numerical representation (bitmask) of the user verification method(s) employed by the authenticator (e.g., fingerprint, face, PIN).
- Use Case: For RPs that need a more granular understanding of how the user was verified, particularly in high-assurance scenarios. It can differentiate between strong (biometric) and weaker (PIN) verification methods.
- Example (Request):
// generateAuthenticationOptions extensions: { uvm: true } - Example (Result): Similar to
credProps.uvm.
altRelyingParty(Alternative Relying Party ID Extension):- Purpose: Allows an authenticator to register a credential for a primary RP ID but also be usable for alternative RP IDs (e.g., for different subdomains of the same organization).
- Use Case: Simplifies credential management for large organizations with multiple subdomains.
credBlob(Credential Blob Extension):- Purpose: Allows the RP to store an opaque blob of data alongside the credential on the authenticator itself.
- Use Case: Can be used to store a small, encrypted token or identifier that the authenticator returns during subsequent authentications, without the RP needing to look it up. This must be handled with extreme care to avoid security risks.
hmac-secret(HMAC Secret Extension):- Purpose: Enables the generation of a client-side HMAC secret that can be used by the RP for deriving additional user-specific secrets.
- Use Case: Can be used for secure derivation of encryption keys or for binding to other cryptographic protocols.
8.3 Implementing Extensions with @simplewebauthn
The @simplewebauthn library handles the parsing and verification of common extensions automatically. You primarily need to specify them in the options and then access the results.
Example server.js (requesting and processing credProps):
// server.js (modified generateRegistrationOptions)
app.post('/generate-registration-options', async (req, res) => {
// ...
const options = await generateRegistrationOptions({
// ... other options ...
extensions: {
credProps: true, // Request credential properties
},
});
// ...
});
// server.js (modified verifyRegistrationResponse)
app.post('/verify-registration', async (req, res) => {
// ...
const verification = await verifyRegistrationResponse({
response: credential,
// ... other parameters ...
// If you requested extensions, ensure they are handled if specific logic is needed
expectedExtensions: {
// Example: If you strictly required 'credProps' to be present
// 'credProps': true,
},
});
const { verified, registrationInfo } = verification;
if (verified && registrationInfo) {
// Access extension results from registrationInfo
const clientExtensionResults = registrationInfo.clientExtensionResults;
if (clientExtensionResults && clientExtensionResults.credProps) {
console.log('Credential Properties:', clientExtensionResults.credProps);
if (!clientExtensionResults.credProps.rk) {
console.warn('Warning: Non-resident key created, passwordless login might be affected.');
// You might choose to disallow non-resident keys for a passkey-only system
}
}
// ... store credential ...
}
// ...
});
Exercises/Mini-Challenges
credProps.uvmvs.userVerification: What is the difference between settinguserVerification: 'required'inauthenticatorSelectionand requesting theuvmextension? How do they complement each other?hmac-secretUse Case: Research a practical scenario where thehmac-secretextension could be used to enhance security or simplify a cryptographic workflow in a web application.- Extension Adoption: Why do you think some WebAuthn extensions are more widely adopted than others? What factors influence an authenticator’s decision to support an extension?
9. Guided Projects
Project 1: Full-Stack Passkey Application with React and Node.js
Objective: Build a complete web application with a React frontend and Node.js backend that supports user signup, passkey registration, and passkey login. This project will integrate concepts learned throughout this document, including server-side library usage and dynamic client-side interactions.
Problem Statement: Develop a modern, passwordless authentication system for a web application using passkeys, providing a smooth user experience for both registration and login.
Technologies Used:
- Frontend: React (with
create-react-appor Vite),@simplewebauthn/browser - Backend: Node.js (Express),
@simplewebauthn/server - Database: In-memory store (for simplicity, but easily replaceable with a real DB)
- Local HTTPS:
mkcertor self-signed certificates.
Project Structure:
passkey-fullstack-app/
├── server/
│ ├── src/
│ │ ├── server.js # Node.js backend logic
│ │ └── database.js # Simple in-memory user/credential store
│ ├── package.json
│ ├── .env.example
│ └── ssl/ # Your SSL certificates (server.key, server.crt)
├── client/
│ ├── public/
│ ├── src/
│ │ ├── App.js # Main React app component
│ │ ├── index.js
│ │ ├── components/
│ │ │ ├── AuthForm.js # Component for registration/login
│ │ │ └── PasskeyList.js # Component to manage passkeys
│ │ └── api.js # API calls to backend
│ ├── package.json
│ └── .env.example
├── .gitignore
└── README.md
Steps:
Project Setup:
- Create the root folder:
mkdir passkey-fullstack-app && cd passkey-fullstack-app - Backend Setup:
mkdir server && cd server npm init -y npm install express @simplewebauthn/server dotenv mkdir src ssl # Create dummy SSL certs as before (or use mkcert) # cp path/to/mkcert-generated-certs/localhost+1.pem ssl/server.crt # cp path/to/mkcert-generated-certs/localhost+1-key.pem ssl/server.key cd .. # Go back to root - Frontend Setup (using Vite):
npm create vite@latest client -- --template react cd client npm install @simplewebauthn/browser npm install qrcode.react # For potential QR login later cd .. # Go back to root - Create
.gitignorein root:node_modules .env
- Create the root folder:
Backend Implementation (
server/src/database.js):// server/src/database.js const users = new Map(); // Stores user info: { email, id, currentChallenge } const credentials = new Map(); // Stores user's public key credentials: Map<userId, Array<Credential>> function getUserByEmail(email) { return users.get(email); } function getUserById(id) { for (const user of users.values()) { if (user.id === id) { return user; } } return undefined; } function addUser(user) { users.set(user.email, user); credentials.set(user.id, []); } function updateUser(user) { users.set(user.email, user); } function getCredentialsByUserId(userId) { return credentials.get(userId) || []; } function addCredential(userId, newCredential) { const userCredentials = credentials.get(userId); userCredentials.push(newCredential); credentials.set(userId, userCredentials); } function updateCredentialCounter(userId, credentialId, newCounter) { const userCredentials = credentials.get(userId); if (userCredentials) { const cred = userCredentials.find(c => c.id === credentialId); if (cred) { cred.counter = newCounter; credentials.set(userId, userCredentials); return true; } } return false; } function deleteCredential(userId, credentialId) { let userCredentials = credentials.get(userId); if (userCredentials) { const initialLength = userCredentials.length; userCredentials = userCredentials.filter(cred => cred.id !== credentialId); credentials.set(userId, userCredentials); return userCredentials.length < initialLength; } return false; } module.exports = { getUserByEmail, getUserById, addUser, updateUser, getCredentialsByUserId, addCredential, updateCredentialCounter, deleteCredential, getAllUsers: () => Array.from(users.values()), getAllCredentials: () => Array.from(credentials.entries()), };Backend Implementation (
server/src/server.js):- Integrate the
database.jsfunctions. - Implement all registration and authentication endpoints (
/generate-registration-options,/verify-registration,/generate-authentication-options,/verify-authentication) using@simplewebauthn/server. - Implement passkey management endpoints (
/get-passkeys,/delete-passkey). - Add CORS handling for React frontend.
// server/src/server.js require('dotenv').config(); const express = require('express'); const https = require('https'); const fs = require('fs'); const cors = require('cors'); // npm install cors const { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } = require('@simplewebauthn/server'); const db = require('./database'); const app = express(); const SERVER_PORT = process.env.SERVER_PORT || 3001; const CLIENT_PORT = process.env.CLIENT_PORT || 5173; // Default Vite port const RP_NAME = 'Fullstack Passkey App'; const RP_ID = 'localhost'; // Use your actual domain in production (e.g., 'yourdomain.com') const ORIGIN = `https://${RP_ID}:${SERVER_PORT}`; // Server origin const CLIENT_ORIGIN = `http://localhost:${CLIENT_PORT}`; // React client origin // CORS configuration app.use(cors({ origin: CLIENT_ORIGIN, credentials: true, })); app.use(express.json()); // --- Passkey Registration Endpoints --- app.post('/api/generate-registration-options', async (req, res) => { const { email } = req.body; if (!email) { return res.status(400).json({ error: 'Email is required' }); } let user = db.getUserByEmail(email); if (!user) { user = { id: String(Date.now()), // Unique ID for the user email, currentChallenge: '', }; db.addUser(user); } try { const options = await generateRegistrationOptions({ rpName: RP_NAME, rpID: RP_ID, userID: user.id, userName: email, authenticatorSelection: { authenticatorAttachment: 'platform', residentKey: 'required', userVerification: 'preferred', }, excludeCredentials: db.getCredentialsByUserId(user.id).map(cred => ({ id: Buffer.from(cred.id, 'base64url'), type: 'public-key', transports: cred.transports, })), timeout: 60000, }); user.currentChallenge = options.challenge; db.updateUser(user); return res.json({ options }); } catch (error) { console.error('Error generating registration options:', error); return res.status(500).json({ error: error.message }); } }); app.post('/api/verify-registration', async (req, res) => { const { email, credential } = req.body; if (!email || !credential) { return res.status(400).json({ error: 'Missing required fields' }); } const user = db.getUserByEmail(email); if (!user) { return res.status(400).json({ error: 'User not found' }); } try { const verification = await verifyRegistrationResponse({ response: credential, expectedChallenge: user.currentChallenge, expectedOrigin: ORIGIN, expectedRPID: RP_ID, requireUserVerification: false, requireUserPresence: true, }); const { verified, registrationInfo } = verification; if (verified && registrationInfo) { const { credentialPublicKey, credentialID, counter, transports, aaguid, } = registrationInfo; const newCredential = { id: Buffer.from(credentialID).toString('base64url'), publicKey: Buffer.from(credentialPublicKey).toString('base64url'), algorithm: -7, counter: counter, transports: transports || [], aaguid: aaguid ? Buffer.from(aaguid).toString('hex') : null, registeredAt: new Date().toISOString(), }; db.addCredential(user.id, newCredential); user.currentChallenge = ''; db.updateUser(user); return res.json({ message: 'Passkey registered successfully', verified }); } else { return res.status(400).json({ error: 'Passkey registration failed verification' }); } } catch (error) { console.error('Error verifying registration:', error); return res.status(500).json({ error: error.message }); } }); // --- Passkey Authentication Endpoints --- app.post('/api/generate-authentication-options', async (req, res) => { const { email } = req.body; let user; let userCredentials = []; if (email) { user = db.getUserByEmail(email); if (user) { userCredentials = db.getCredentialsByUserId(user.id); } else { return res.status(404).json({ error: 'User with this email not found.' }); } } else { // For passwordless/discoverable login without email if (db.getAllUsers().length > 0) { // Pick any user to get a challenge if no email provided, or improve this logic // In a real app, the client would specify credential ID if known, or // the authenticator would discover. user = db.getAllUsers()[0]; // Just pick first user for demo challenge userCredentials = db.getCredentialsByUserId(user.id); console.log(`Authenticating without email, using credentials of user: ${user.email} for challenge.`); } else { return res.status(400).json({ error: 'No users registered to authenticate.' }); } } if (userCredentials.length === 0) { return res.status(400).json({ error: `No passkeys registered for ${email || 'this user'}.` }); } try { const options = await generateAuthenticationOptions({ rpID: RP_ID, allowCredentials: userCredentials.map(cred => ({ id: Buffer.from(cred.id, 'base64url'), type: 'public-key', transports: cred.transports, })), userVerification: 'preferred', timeout: 60000, }); // Store the challenge for verification if (user) { // Ensure user is defined before setting currentChallenge user.currentChallenge = options.challenge; db.updateUser(user); } return res.json({ options }); } catch (error) { console.error('Error generating authentication options:', error); return res.status(500).json({ error: error.message }); } }); app.post('/api/verify-authentication', async (req, res) => { const { email, credential } = req.body; if (!credential) { return res.status(400).json({ error: 'Missing credential in request' }); } let user = null; let storedCredential = null; const credentialIdFromClient = Buffer.from(credential.rawId).toString('base64url'); if (email) { user = db.getUserByEmail(email); if (user) { const userCredentials = db.getCredentialsByUserId(user.id); if (userCredentials) { storedCredential = userCredentials.find( (cred) => cred.id === credentialIdFromClient ); } } } else { // Discoverable credential flow, try to find the user by credential ID const allUsers = db.getAllUsers(); for (const u of allUsers) { const userCreds = db.getCredentialsByUserId(u.id); storedCredential = userCreds.find(cred => cred.id === credentialIdFromClient); if (storedCredential) { user = u; break; } } } if (!user || !storedCredential) { return res.status(400).json({ error: 'Credential not found for user.' }); } try { const verification = await verifyAuthenticationResponse({ response: credential, expectedChallenge: user.currentChallenge, expectedOrigin: ORIGIN, expectedRPID: RP_ID, authenticator: { credentialID: Buffer.from(storedCredential.id, 'base64url'), credentialPublicKey: Buffer.from(storedCredential.publicKey, 'base64url'), counter: storedCredential.counter, transports: storedCredential.transports, }, requireUserVerification: false, requireUserPresence: true, }); const { verified, authenticationInfo } = verification; if (verified && authenticationInfo) { const { newCounter } = authenticationInfo; db.updateCredentialCounter(user.id, storedCredential.id, newCounter); user.currentChallenge = ''; db.updateUser(user); return res.json({ message: 'Login successful', verified, user: { email: user.email, id: user.id } }); } else { return res.status(400).json({ error: 'Passkey authentication failed verification' }); } } catch (error) { console.error('Error verifying authentication:', error); return res.status(500).json({ error: error.message }); } }); // --- Passkey Management Endpoints --- app.get('/api/get-passkeys', (req, res) => { const { email } = req.query; if (!email) { return res.status(400).json({ error: 'Email is required.' }); } const user = db.getUserByEmail(email); if (!user) { return res.status(404).json({ error: 'User not found.' }); } const userCredentials = db.getCredentialsByUserId(user.id); const simplifiedPasskeys = userCredentials.map(cred => ({ id: cred.id, transports: cred.transports, registeredAt: cred.registeredAt, // You might add a friendly name here if you implemented it })); return res.json({ passkeys: simplifiedPasskeys }); }); app.post('/api/delete-passkey', (req, res) => { const { email, credentialId } = req.body; if (!email || !credentialId) { return res.status(400).json({ error: 'Email and credentialId are required.' }); } const user = db.getUserByEmail(email); if (!user) { return res.status(404).json({ error: 'User not found.' }); } const success = db.deleteCredential(user.id, credentialId); if (success) { return res.json({ success: true, message: 'Passkey deleted successfully.' }); } else { return res.status(404).json({ success: false, error: 'Passkey not found for this user.' }); } }); // --- Start Server --- const options = { key: fs.readFileSync('ssl/server.key'), cert: fs.readFileSync('ssl/server.crt') }; https.createServer(options, app).listen(SERVER_PORT, () => { console.log(`HTTPS Server running on ${ORIGIN}`); console.log(`Client will access at ${CLIENT_ORIGIN}`); });- Create
server/.env.example:
Copy toSERVER_PORT=3001 CLIENT_PORT=5173server/.envand adjust ports if needed.
- Integrate the
Frontend Implementation (
client/src/App.js):- This will be your main React component, managing authentication state.
// client/src/App.js import React, { useState, useEffect } from 'react'; import AuthForm from './components/AuthForm'; import PasskeyList from './components/PasskeyList'; import './App.css'; // Create App.css for basic styling function App() { const [loggedInUser, setLoggedInUser] = useState(null); const [email, setEmail] = useState(''); // To persist email across components useEffect(() => { // In a real app, you'd check for a session token here // For this demo, we'll start logged out. }, []); const handleLoginSuccess = (user) => { setLoggedInUser(user); setEmail(user.email); alert(`Welcome, ${user.email}!`); }; const handleLogout = () => { setLoggedInUser(null); setEmail(''); alert('Logged out.'); }; return ( <div className="App"> <header className="App-header"> <h1>Passkey Fullstack Demo</h1> </header> <main> {!loggedInUser ? ( <AuthForm onLoginSuccess={handleLoginSuccess} initialEmail={email} onEmailChange={setEmail} /> ) : ( <div className="dashboard"> <h2>Hello, {loggedInUser.email}!</h2> <button onClick={handleLogout} className="logout-button">Logout</button> <PasskeyList userEmail={loggedInUser.email} /> </div> )} </main> </div> ); } export default App;Frontend Implementation (
client/src/components/AuthForm.js):- Handles the registration and login buttons.
// client/src/components/AuthForm.js import React, { useState } from 'react'; import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; import { generateRegOptions, verifyRegistration, generateAuthOptions, verifyAuthentication } from '../api'; function AuthForm({ onLoginSuccess, initialEmail, onEmailChange }) { const [email, setEmail] = useState(initialEmail); const [message, setMessage] = useState(''); const [isError, setIsError] = useState(false); const [isLoading, setIsLoading] = useState(false); const displayFeedback = (msg, error = false) => { setMessage(msg); setIsError(error); }; const handleRegister = async () => { if (!email) { displayFeedback('Please enter your email.', true); return; } setIsLoading(true); displayFeedback('Initiating passkey registration...'); try { // 1. Get registration options from backend const { options } = await generateRegOptions(email); // 2. Start WebAuthn registration in browser const attResp = await startRegistration(options); // 3. Send credential to backend for verification and storage const result = await verifyRegistration(email, attResp); displayFeedback('Passkey registered successfully! You can now log in.'); setIsLoading(false); } catch (error) { console.error('Registration failed:', error); displayFeedback(`Registration failed: ${error.message || 'Unknown error'}`, true); setIsLoading(false); } }; const handleLogin = async () => { // Email can be optional for discoverable login, but we'll include it for a clear flow. // If email is empty, the server will fetch options for a generic user (demo only) // or the authenticator will try to discover. setIsLoading(true); displayFeedback('Initiating passkey login...'); try { // 1. Get authentication options from backend const { options } = await generateAuthOptions(email); // 2. Start WebAuthn authentication in browser const asseResp = await startAuthentication(options); // 3. Send assertion to backend for verification const { user } = await verifyAuthentication(email, asseResp); displayFeedback('Login successful! Welcome back.'); onLoginSuccess(user); // Notify App component of successful login setIsLoading(false); } catch (error) { console.error('Authentication failed:', error); displayFeedback(`Login failed: ${error.message || 'Unknown error'}`, true); setIsLoading(false); } }; return ( <div className="auth-form-container"> <input type="email" placeholder="Enter your email" value={email} onChange={(e) => { setEmail(e.target.value); onEmailChange(e.target.value); // Update parent's email state }} disabled={isLoading} /> <button onClick={handleRegister} disabled={isLoading}> {isLoading && message.includes('registration') ? 'Registering...' : 'Register with Passkey'} </button> <button onClick={handleLogin} disabled={isLoading}> {isLoading && message.includes('login') ? 'Logging In...' : 'Login with Passkey'} </button> {message && ( <p className={`feedback-message ${isError ? 'error' : 'success'}`}> {message} </p> )} </div> ); } export default AuthForm;Frontend Implementation (
client/src/components/PasskeyList.js):- Displays and allows deletion of passkeys.
// client/src/components/PasskeyList.js import React, { useState, useEffect } from 'react'; import { getPasskeys, deletePasskey } from '../api'; function PasskeyList({ userEmail }) { const [passkeys, setPasskeys] = useState([]); const [message, setMessage] = useState(''); const [isError, setIsError] = useState(false); const [isLoading, setIsLoading] = useState(false); const fetchPasskeys = async () => { setIsLoading(true); setMessage(''); try { const data = await getPasskeys(userEmail); setPasskeys(data.passkeys); setIsLoading(false); } catch (error) { console.error('Error fetching passkeys:', error); setMessage(`Error loading passkeys: ${error.message}`); setIsError(true); setIsLoading(false); } }; const handleDelete = async (credentialId) => { if (window.confirm('Are you sure you want to delete this passkey?')) { setIsLoading(true); setMessage(''); try { await deletePasskey(userEmail, credentialId); setMessage('Passkey deleted successfully.'); setIsError(false); fetchPasskeys(); // Refresh the list } catch (error) { console.error('Error deleting passkey:', error); setMessage(`Failed to delete passkey: ${error.message}`); setIsError(true); } finally { setIsLoading(false); } } }; useEffect(() => { if (userEmail) { fetchPasskeys(); } }, [userEmail]); if (!userEmail) { return <p>Please log in to manage your passkeys.</p>; } return ( <div className="passkey-list-container"> <h3>Your Registered Passkeys</h3> <button onClick={fetchPasskeys} disabled={isLoading} className="refresh-button"> {isLoading ? 'Refreshing...' : 'Refresh Passkeys'} </button> {message && ( <p className={`feedback-message ${isError ? 'error' : 'success'}`}> {message} </p> )} {passkeys.length === 0 && !isLoading && !message && ( <p>No passkeys registered yet. Register one above!</p> )} <ul className="passkey-items"> {passkeys.map((passkey) => ( <li key={passkey.id} className="passkey-item"> <span>ID: {passkey.id.substring(0, 15)}...</span> <span>Registered: {new Date(passkey.registeredAt).toLocaleDateString()}</span> <button onClick={() => handleDelete(passkey.id)} disabled={isLoading}>Delete</button> </li> ))} </ul> </div> ); } export default PasskeyList;Frontend Implementation (
client/src/api.js):- Wrapper functions for API calls.
// client/src/api.js const API_BASE_URL = import.meta.env.VITE_API_URL || 'https://localhost:3001/api'; async function callApi(endpoint, method = 'GET', data = null) { const options = { method, headers: { 'Content-Type': 'application/json', }, body: data ? JSON.stringify(data) : null, }; const response = await fetch(`${API_BASE_URL}${endpoint}`, options); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || `API call failed: ${response.statusText}`); } return response.json(); } export const generateRegOptions = (email) => callApi('/generate-registration-options', 'POST', { email }); export const verifyRegistration = (email, credential) => callApi('/verify-registration', 'POST', { email, credential }); export const generateAuthOptions = (email) => callApi('/generate-authentication-options', 'POST', { email }); export const verifyAuthentication = (email, credential) => callApi('/verify-authentication', 'POST', { email, credential }); export const getPasskeys = (email) => callApi(`/get-passkeys?email=${encodeURIComponent(email)}`); export const deletePasskey = (email, credentialId) => callApi('/delete-passkey', 'POST', { email, credentialId });- Create
client/.env.example:
Copy toVITE_API_URL=https://localhost:3001/apiclient/.envand adjust port if needed.
Frontend Styling (
client/src/App.css):/* client/src/App.css */ body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; background-color: #f0f2f5; color: #333; display: flex; justify-content: center; align-items: flex-start; min-height: 100vh; padding: 20px; box-sizing: border-box; } .App { background-color: #ffffff; border-radius: 12px; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1); padding: 30px; max-width: 600px; width: 100%; text-align: center; } .App-header h1 { color: #007bff; margin-bottom: 30px; font-size: 2.5em; } .auth-form-container, .dashboard, .passkey-list-container { padding: 20px; border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 25px; background-color: #f9f9f9; } input[type="email"] { width: calc(100% - 24px); padding: 12px; margin-bottom: 15px; border: 1px solid #ccc; border-radius: 6px; font-size: 1em; } button { background-color: #007bff; color: white; padding: 12px 25px; border: none; border-radius: 6px; cursor: pointer; font-size: 1em; margin: 0 8px 10px 8px; /* Added margin-bottom */ transition: background-color 0.2s ease, transform 0.1s ease; } button:hover:not(:disabled) { background-color: #0056b3; transform: translateY(-1px); } button:disabled { background-color: #cccccc; cursor: not-allowed; } .feedback-message { margin-top: 20px; padding: 10px; border-radius: 4px; font-weight: bold; } .feedback-message.success { background-color: #d4edda; color: #155724; border-color: #c3e6cb; } .feedback-message.error { background-color: #f8d7da; color: #721c24; border-color: #f5c6cb; } .dashboard h2 { color: #28a745; margin-bottom: 20px; } .logout-button { background-color: #dc3545; margin-bottom: 20px; } .logout-button:hover:not(:disabled) { background-color: #c82333; } .passkey-list-container h3 { color: #343a40; margin-bottom: 15px; } .passkey-items { list-style: none; padding: 0; margin-top: 20px; } .passkey-item { display: flex; justify-content: space-between; align-items: center; background-color: #ffffff; border: 1px solid #ddd; padding: 12px 18px; margin-bottom: 10px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); } .passkey-item span { flex-grow: 1; text-align: left; } .passkey-item button { background-color: #dc3545; padding: 8px 15px; font-size: 0.9em; margin-left: 15px; } .passkey-item button:hover:not(:disabled) { background-color: #c82333; } .refresh-button { background-color: #6c757d; margin-top: 10px; margin-bottom: 15px; } .refresh-button:hover:not(:disabled) { background-color: #5a6268; }Run the Applications:
- Start Backend:(Make sure your
cd server node src/server.jssslcertificates are in place andserver.keyandserver.crtexist.) - Start Frontend:
cd client npm run dev - Open your browser to
http://localhost:5173. (You might need to manually visithttps://localhost:3001first to accept the self-signed certificate in your browser).
- Start Backend:
Guided Steps:
- Register a New User: Enter an email address (e.g.,
test@example.com) in the React app and click “Register with Passkey.” Your device (phone/computer) will prompt for biometric verification. Complete it. You should see a success message. - Log In with Passkey: Immediately try to log in using the same email. Click “Login with Passkey.” Again, your device will prompt for biometrics. Upon success, you’ll be redirected to the dashboard.
- Manage Passkeys: On the dashboard, click “Refresh Passkeys.” You should see your newly registered passkey listed.
- Delete a Passkey: Click the “Delete” button next to your passkey. Confirm the deletion. The passkey should disappear from the list.
- Test Edge Cases: Try to log in with an unregistered email, or register the same passkey twice (the
excludeCredentialsshould prevent this if working correctly). Observe the error messages.
6. Bonus Section: Further Learning and Resources
The world of passkeys and WebAuthn is constantly evolving. Here are some excellent resources to continue your learning journey:
Recommended Online Courses/Tutorials
- FIDO Alliance: While not a course, their documentation and guides (like passkeycentral.org) are authoritative.
- Google Developers: Check out Google’s guides on implementing passkeys, often with code labs for web and Android.
- Apple Developer Documentation: For iOS and macOS specific implementations.
- What’s new in passkeys - WWDC25 Videos (and related videos from WWDC22, WWDC24)
- AuthenticationServices Framework
Official Documentation
- WebAuthn Specification: The ultimate source for technical details. Be warned, it’s very dense! https://www.w3.org/TR/webauthn/
- FIDO Alliance Documentation: Comprehensive resources on FIDO standards. https://fidoalliance.org/
- passkeys.dev: A community-driven resource with excellent explanations and device support matrices. https://passkeys.dev/
@simplewebauthnLibraries: The library we used in this guide has great documentation. https://github.com/MasterKale/SimpleWebAuthn
Blogs and Articles
- Corbado Blog: Frequently updated with detailed articles on passkey features, adoption strategies, and platform-specific updates (e.g., Android 16, iOS 26 passkey analyses). https://www.corbado.com/blog
- Yubico Blog: Offers insights into hardware security keys and broader WebAuthn best practices. https://www.yubico.com/blog/
- idDataWeb Blog: Provides analyses on the shift from OTPs to passkeys and industry trends. https://www.iddataweb.com/
- web.dev: Google’s platform for web development, often features excellent articles on WebAuthn and passkeys.
YouTube Channels
- Google Chrome Developers: Look for videos related to WebAuthn and passkeys.
- Apple Developer: WWDC (Worldwide Developers Conference) videos offer deep dives into Apple’s passkey implementations.
- FIDO Alliance: May host webinars or technical presentations.
- Individual Security/Dev Channels: Many content creators explain these concepts; search for “WebAuthn tutorial” or “passkey explanation.”
Community Forums/Groups
- FIDO-dev Community Group: A great place to ask technical questions and engage with experts from major companies. https://groups.google.com/a/fidoalliance.org/g/fido-dev
- Passkeys Developer Discussions: Another active forum for developers. https://passkeys.dev/discuss
- Stack Overflow: Search for
webauthnorpasskeystags. - Discord Servers: Many developer communities have channels dedicated to security or web development where you can ask questions.
By continuing to learn and experiment, you’ll be at the forefront of the passwordless revolution, building more secure and user-friendly authentication experiences for everyone.