Passkeys have been a game-changer in the world of identity and access management (IAM). They offer a secure, passwordless method of authentication using FIDO2 standards and WebAuthn APIs. However, implementing them in a production environment can be tricky. This guide will walk you through the process, sharing insights and tips based on real-world experience.

The Problem

Traditional password-based authentication is fraught with issues: weak passwords, phishing attacks, and credential stuffing. Passkeys aim to solve these problems by leveraging public-key cryptography and biometric verification, providing a seamless and secure login experience.

Setting Up Your Environment

Before diving into implementation, ensure your environment is ready.

Prerequisites

  • Node.js v18+ installed
  • npm or yarn installed
  • Basic understanding of JavaScript and Web APIs
  • HTTPS server setup (required for WebAuthn)

Choosing a Library

Several libraries simplify WebAuthn implementation. I recommend using simplewebauthn due to its comprehensive documentation and ease of use.

📋 Quick Reference

  • npm install @simplewebauthn/server @simplewebauthn/browser - Install the necessary packages

Server-Side Implementation

The server handles passkey registration and authentication requests.

Registering a New Passkey

Start by creating an endpoint to initiate passkey registration.

// server.js
const express = require('express');
const { generateRegistrationOptions, verifyRegistrationResponse } = require('@simplewebauthn/server');
const app = express();
app.use(express.json());

let user = {
  id: 'user-id',
  username: 'johndoe',
  displayName: 'John Doe',
};

let registeredCredentials = [];

app.post('/register/start', async (req, res) => {
  const options = await generateRegistrationOptions({
    rpName: 'Example Corp',
    rpID: 'example.com',
    userID: user.id,
    userName: user.username,
    userDisplayName: user.displayName,
    attestationType: 'none', // or 'indirect'/'direct'
    supportedAlgorithmIDs: [-7, -257], // ES256 and RS256
  });

  res.json(options);
});

Verifying Registration Response

Handle the client’s registration response to validate and store the passkey.

app.post('/register/finish', async (req, res) => {
  const { clientDataJSON, attestationObject, transports } = req.body;

  let verification;
  try {
    verification = await verifyRegistrationResponse({
      response: {
        clientDataJSON,
        attestationObject,
      },
      expectedChallenge: user.currentChallenge, // Store this during /register/start
      expectedOrigin: 'https://example.com',
      expectedRPID: 'example.com',
      requireUserVerification: true,
    });
  } catch (error) {
    return res.status(400).json({ error: error.message });
  }

  const { verified, registrationInfo } = verification;
  if (verified && registrationInfo) {
    const { credentialPublicKey, credentialID, counter } = registrationInfo;

    registeredCredentials.push({
      id: credentialID,
      publicKey: credentialPublicKey,
      counter,
      transports: transports || [],
    });

    res.status(200).json({ status: 'success' });
  } else {
    res.status(400).json({ error: 'Registration failed' });
  }
});
⚠️ Warning: Always validate the origin and RPID to prevent phishing attacks.

Client-Side Implementation

The client initiates the registration and authentication processes.

Initiating Registration

Create a function to start the registration process.

// client.js
async function startRegistration() {
  const response = await fetch('/register/start', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
  });
  const options = await response.json();

  try {
    const credential = await navigator.credentials.create({ publicKey: options });
    const data = {
      clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
      attestationObject: Array.from(new Uint8Array(credential.response.attestationObject)),
      transports: credential.response.getTransports ? credential.response.getTransports() : [],
    };

    const finishResponse = await fetch('/register/finish', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });

    const result = await finishResponse.json();
    console.log(result);
  } catch (error) {
    console.error(error);
  }
}

document.getElementById('registerButton').addEventListener('click', startRegistration);

Authenticating with a Passkey

Implement the authentication flow.

async function startAuthentication() {
  const response = await fetch('/login/start', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
  });
  const options = await response.json();

  try {
    const credential = await navigator.credentials.get({ publicKey: options });
    const data = {
      id: credential.id,
      rawId: Array.from(new Uint8Array(credential.rawId)),
      type: credential.type,
      response: {
        authenticatorData: Array.from(new Uint8Array(credential.response.authenticatorData)),
        clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
        signature: Array.from(new Uint8Array(credential.response.signature)),
        userHandle: Array.from(new Uint8Array(credential.response.userHandle || [])),
      },
    };

    const finishResponse = await fetch('/login/finish', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });

    const result = await finishResponse.json();
    console.log(result);
  } catch (error) {
    console.error(error);
  }
}

document.getElementById('loginButton').addEventListener('click', startAuthentication);

🎯 Key Takeaways

  • Use `generateRegistrationOptions` and `verifyRegistrationResponse` for registration.
  • Use `navigator.credentials.create` and `navigator.credentials.get` for client-side operations.
  • Always validate origins and RPID to enhance security.

Handling Errors

Common issues arise during registration and authentication. Here’s how to handle them.

Registration Errors

Registration might fail due to various reasons, such as unsupported algorithms or invalid origins.

try {
  verification = await verifyRegistrationResponse({
    response: {
      clientDataJSON,
      attestationObject,
    },
    expectedChallenge: user.currentChallenge,
    expectedOrigin: 'https://example.com',
    expectedRPID: 'example.com',
    requireUserVerification: true,
  });
} catch (error) {
  console.error('Registration error:', error.message);
  res.status(400).json({ error: error.message });
}

Authentication Errors

Authentication errors could be due to incorrect credentials or mismatched challenges.

try {
  verification = await verifyAuthenticationResponse({
    credential: {
      id: credentialID,
      rawId: rawIdBuffer,
      type: 'public-key',
      response: {
        authenticatorData: authenticatorDataBuffer,
        clientDataJSON: clientDataJSONBuffer,
        signature: signatureBuffer,
        userHandle: userHandleBuffer,
      },
    },
    expectedChallenge: user.currentChallenge,
    expectedOrigin: 'https://example.com',
    expectedRPID: 'example.com',
    signingAlgorithm: -7, // ES256
    credentialPublicKey: publicKeyBuffer,
    prevCounter: userCredential.counter,
    requireUserVerification: true,
  });
} catch (error) {
  console.error('Authentication error:', error.message);
  res.status(400).json({ error: error.message });
}
🚨 Security Alert: Never log sensitive information like private keys or full error messages to the client.

Integrating with Existing Systems

Passkeys can coexist with traditional authentication methods. Here’s how to integrate them.

Dual-Stack Authentication

Allow users to choose between passkeys and passwords.

app.post('/login/start', async (req, res) => {
  const options = await generateAuthenticationOptions({
    allowCredentials: registeredCredentials.map((cred) => ({
      id: cred.id,
      type: 'public-key',
      transports: cred.transports,
    })),
    userVerification: 'preferred',
    rpID: 'example.com',
  });

  res.json(options);
});

Migration Strategy

Gradually migrate users to passkeys by offering a seamless transition path.

💜 Pro Tip: Educate users on the benefits of passkeys to encourage adoption.

Browser Compatibility

Not all browsers support WebAuthn equally. Here’s a quick overview.

BrowserSupportNotes
ChromeFullAvailable since version 67
FirefoxFullAvailable since version 60
SafariLimitedPartial support in version 13.1+
EdgeFullAvailable since version 79
OperaFullAvailable since version 54
💡 Key Point: Test thoroughly across different browsers to ensure compatibility.

Performance Considerations

Optimize your implementation for speed and reliability.

Minimizing Latency

Reduce latency by optimizing network requests and server responses.

app.use(express.json({ limit: '1mb' })); // Increase limit if needed
app.use(express.urlencoded({ extended: true, limit: '1mb' }));

Efficient Credential Storage

Store credentials efficiently to avoid performance bottlenecks.

💜 Pro Tip: Use a database optimized for read-heavy operations, like Redis or MongoDB.

Security Best Practices

Follow these guidelines to ensure a secure implementation.

Secure Key Storage

Never store private keys in plaintext. Use secure vaults or hardware security modules (HSMs).

🚨 Security Alert: Regularly rotate keys and monitor access logs.

User Verification

Enable user verification to prevent unauthorized access.

const options = await generateRegistrationOptions({
  // ...
  userVerification: 'required',
});

Challenge Management

Generate unique challenges for each request to prevent replay attacks.

function generateChallenge() {
  return Buffer.from(crypto.randomBytes(32)).toString('base64url');
}

🎯 Key Takeaways

  • Test across different browsers to ensure compatibility.
  • Optimize for performance to minimize latency.
  • Follow best practices for secure key storage and user verification.

Conclusion

Implementing FIDO2 WebAuthn passkeys in production requires careful planning and execution. By following this guide, you can provide a secure, passwordless authentication experience for your users. Remember to test thoroughly and stay updated with the latest security practices.

That’s it. Simple, secure, works. Happy coding!