Passkeys replace passwords with FIDO2 WebAuthn credentials — cryptographic key pairs where the private key never leaves the device. Users authenticate with biometrics (Touch ID, Face ID, Windows Hello) or hardware keys (YubiKey, Titan key) instead of passwords. This guide covers production implementation with @simplewebauthn/server, Keycloak WebAuthn flow setup, and error debugging. For protocol context, see our FIDO vs FIDO2 explainer.

What is FIDO2 WebAuthn?

FIDO2 WebAuthn (W3C spec + CTAP2 protocol) is the standard that enables passkeys. The browser’s navigator.credentials.create() API talks to a local authenticator via CTAP2 over USB/NFC/BLE or the OS platform (TPM, Secure Enclave). The server (Relying Party) verifies the response using the credential’s public key. Unlike FIDO U2F (security-key-only second factor), FIDO2 supports first-factor passwordless login with discoverable credentials stored in platform authenticators.

Why adopt passkeys?

Adopting passkeys eliminates entire attack classes — passkeys are phishing-resistant by design because the credential is scoped to a specific rpID (domain). Key benefits:

  • Phishing-proof: Private key is domain-scoped; a fake site cannot capture or replay it
  • No credential stuffing: No passwords in your database to breach
  • Cross-device sync: iOS Passkeys (iCloud Keychain) and Google Passkeys (Password Manager) let users sign in on new devices without re-enrolling hardware keys

For broader coverage of MFA bypass attacks that passkeys prevent, see our MFA bypass attack analysis.

What are the prerequisites for implementing FIDO2 WebAuthn?

Before diving into implementation, ensure you have the following:

  • A server capable of handling WebAuthn operations (relying party server)
  • Frontend support for the WebAuthn API
  • Understanding of public key cryptography
  • Compliance with FIDO2 standards

How do I set up a relying party server?

The relying party server is responsible for generating authentication challenges, verifying responses, and managing user credentials. Here’s a basic setup using Node.js and the webauthn library.

Install dependencies

First, install the necessary packages:

npm install @simplewebauthn/server

Initialize the server

Create a file named server.js and initialize the server:

const express = require('express');
const { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } = require('@simplewebauthn/server');

const app = express();
app.use(express.json());

// In-memory user store
const users = {};
const userAuthenticators = {};

// Register a new user
app.post('/register', async (req, res) => {
  const { username, displayName } = req.body;

  // Check if user already exists
  if (users[username]) {
    return res.status(400).json({ error: 'User already exists' });
  }

  const userId = username; // Use a unique identifier for the user
  users[username] = { id: userId, username, displayName };

  const options = generateRegistrationOptions({
    rpName: 'My Website',
    rpID: 'localhost',
    userID: userId,
    userName: username,
    userDisplayName: displayName,
    attestationType: 'none',
    supportedAlgorithmIDs: [-7, -257],
  });

  res.json(options);
});

// Verify registration response
app.post('/verify-registration', async (req, res) => {
  const { username, response } = req.body;
  const user = users[username];

  let verification;
  try {
    verification = await verifyRegistrationResponse({
      credential: response,
      expectedChallenge: user.currentChallenge,
      expectedOrigin: 'http://localhost:3000',
      expectedRPID: 'localhost',
    });
  } catch (error) {
    return res.status(400).json({ error: error.message });
  }

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

    userAuthenticators[user.id] = {
      credentialID,
      credentialPublicKey,
      counter,
    };

    delete user.currentChallenge;

    res.json({ status: 'success' });
  } else {
    res.status(400).json({ error: 'Verification failed' });
  }
});

// Generate authentication options
app.post('/authenticate', async (req, res) => {
  const { username } = req.body;
  const user = users[username];

  if (!user) {
    return res.status(400).json({ error: 'User not found' });
  }

  const options = generateAuthenticationOptions({
    timeout: 60000,
    allowCredentials: userAuthenticators[user.id].map(authenticator => ({
      id: authenticator.credentialID,
      type: 'public-key',
      transports: ['usb', 'nfc', 'ble', 'internal'],
    })),
    userVerification: 'preferred',
  });

  user.currentChallenge = options.challenge;

  res.json(options);
});

// Verify authentication response
app.post('/verify-authentication', async (req, res) => {
  const { username, response } = req.body;
  const user = users[username];

  if (!user) {
    return res.status(400).json({ error: 'User not found' });
  }

  let verification;
  try {
    verification = await verifyAuthenticationResponse({
      credential: response,
      expectedChallenge: user.currentChallenge,
      expectedOrigin: 'http://localhost:3000',
      expectedRPID: 'localhost',
      authenticator: userAuthenticators[user.id],
    });
  } catch (error) {
    return res.status(400).json({ error: error.message });
  }

  const { verified, authenticationInfo } = verification;
  if (verified) {
    userAuthenticators[user.id].counter = authenticationInfo.newCounter;

    delete user.currentChallenge;

    res.json({ status: 'success' });
  } else {
    res.status(400).json({ error: 'Verification failed' });
  }
});

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

Test the server

Run the server using:

node server.js

You can test the endpoints using tools like Postman or curl.

How do I integrate the WebAuthn API in the frontend?

The frontend interacts with the WebAuthn API to register and authenticate users. Here’s how you can do it using JavaScript.

Register a new user

async function registerUser(username, displayName) {
  const response = await fetch('/register', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ username, displayName }),
  });

  const options = await response.json();

  const publicKey = Object.assign({}, options, {
    challenge: Uint8Array.from(
      atob(options.challenge.replace(/_/g, '/').replace(/-/g, '+')),
      c => c.charCodeAt(0)
    ),
    user: {
      ...options.user,
      id: Uint8Array.from(
        atob(options.user.id.replace(/_/g, '/').replace(/-/g, '+')),
        c => c.charCodeAt(0)
      )
    },
    excludeCredentials: options.excludeCredentials.map((cred) => ({
      ...cred,
      id: Uint8Array.from(
        atob(cred.id.replace(/_/g, '/').replace(/-/g, '+')),
        c => c.charCodeAt(0)
      )
    }))
  });

  const credential = await navigator.credentials.create({ publicKey });

  const attestationObject = new Uint8Array(credential.response.attestationObject);
  const clientDataJSON = new Uint8Array(credential.response.clientDataJSON);

  const data = {
    username,
    response: {
      id: credential.id,
      rawId: credential.rawId,
      type: credential.type,
      response: {
        attestationObject: String.fromCharCode(...attestationObject),
        clientDataJSON: String.fromCharCode(...clientDataJSON),
      },
    },
  };

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

  const result = await verificationResponse.json();
  console.log(result);
}

// Usage
registerUser('john_doe', 'John Doe');

Authenticate a user

async function authenticateUser(username) {
  const response = await fetch('/authenticate', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ username }),
  });

  const options = await response.json();

  const publicKey = Object.assign({}, options, {
    challenge: Uint8Array.from(
      atob(options.challenge.replace(/_/g, '/').replace(/-/g, '+')),
      c => c.charCodeAt(0)
    ),
    allowCredentials: options.allowCredentials.map((cred) => ({
      ...cred,
      id: Uint8Array.from(
        atob(cred.id.replace(/_/g, '/').replace(/-/g, '+')),
        c => c.charCodeAt(0)
      )
    }))
  });

  const assertion = await navigator.credentials.get({ publicKey });

  const authenticatorData = new Uint8Array(assertion.response.authenticatorData);
  const clientDataJSON = new Uint8Array(assertion.response.clientDataJSON);
  const signature = new Uint8Array(assertion.response.signature);
  const userHandle = new Uint8Array(assertion.response.userHandle || []);

  const data = {
    username,
    response: {
      id: assertion.id,
      rawId: assertion.rawId,
      type: assertion.type,
      response: {
        authenticatorData: String.fromCharCode(...authenticatorData),
        clientDataJSON: String.fromCharCode(...clientDataJSON),
        signature: String.fromCharCode(...signature),
        userHandle: String.fromCharCode(...userHandle),
      },
    },
  };

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

  const result = await verificationResponse.json();
  console.log(result);
}

// Usage
authenticateUser('john_doe');

What are the common pitfalls to avoid?

Avoid these common mistakes during implementation:

  • Not validating challenges: Always verify that the challenge sent by the server matches the one received in the response.
  • Ignoring user verification: Enable user verification to prevent unauthorized access.
  • Storing sensitive data insecurely: Securely store private keys and other sensitive information.

How do I handle errors during registration and authentication?

Errors are inevitable. Here’s how to handle them gracefully.

Registration errors

Common registration errors include:

  • Invalid state: The user is already registered.
  • Network issues: The server is unreachable.

Example error handling:

try {
  await registerUser('john_doe', 'John Doe');
} catch (error) {
  console.error('Registration failed:', error);
}

Authentication errors

Common authentication errors include:

  • Credential not found: The user doesn’t have a registered credential.
  • Signature verification failed: The response couldn’t be verified.

Example error handling:

try {
  await authenticateUser('john_doe');
} catch (error) {
  console.error('Authentication failed:', error);
}

What are the security considerations for FIDO2 WebAuthn?

WebAuthn’s security model is built on domain-scoping and public key cryptography, but there are server-side requirements you must enforce:

  • rpID validation: The rpID in the client’s clientDataJSON.origin must exactly match your configured rpID. Reject any mismatch — this prevents cross-origin credential use.
  • Challenge replay protection: Generate a new random 32-byte challenge per ceremony (registration or authentication), store it server-side with a TTL (60s), and reject any challenge not in your store. Never reuse challenges.
  • signCount clone detection: For platform authenticators (iPhone, Android), signCount is often 0 (Apple/Google do not increment). For hardware keys (YubiKey), increment signCount server-side after every successful auth; reject credentials where newCounter ≤ storedCounter (indicates cloning).
  • userVerification requirement: Set userVerification: "required" for passwordless flows so the OS enforces biometric/PIN. Set "preferred" only for second-factor flows where some authenticators may not support UV. Never set "discouraged" in production.
  • Attestation for enterprise: For consumer apps, attestationType: "none" is fine. For enterprise (device trust, BYOD policy), use "indirect" or "direct" with FIDO MDS validation to verify authenticator model and firmware.
⚠️ Warning: Never store private keys in plaintext — the private key lives on the authenticator exclusively. Store only credentialId, credentialPublicKey, signCount, and transports on your server.

How do I test my implementation?

Testing is crucial to ensure that your implementation works correctly. Here are some steps to follow:

  1. Unit tests: Write unit tests for your server-side logic.
  2. Integration tests: Test the entire authentication flow from registration to authentication.
  3. User testing: Conduct user testing to ensure that the user experience is smooth and intuitive.

Example integration test:

const request = require('supertest');
const app = require('./server');

describe('WebAuthn Integration Tests', () => {
  it('should register a new user', async () => {
    const response = await request(app)
      .post('/register')
      .send({ username: 'test_user', displayName: 'Test User' })
      .expect(200);

    expect(response.body).toHaveProperty('challenge');
  });

  it('should authenticate a registered user', async () => {
    // Register a user first
    await request(app)
      .post('/register')
      .send({ username: 'test_user', displayName: 'Test User' })
      .expect(200);

    // Authenticate the user
    const response = await request(app)
      .post('/authenticate')
      .send({ username: 'test_user' })
      .expect(200);

    expect(response.body).toHaveProperty('challenge');
  });
});

What are the performance implications of using WebAuthn?

Performance is generally good with WebAuthn, but there are a few considerations:

  • Initial setup: Registration may take longer due to the need to generate and store cryptographic keys.
  • Device compatibility: Not all devices support WebAuthn, which can affect adoption rates.
💜 Pro Tip: Optimize your server to handle WebAuthn operations efficiently.

How do I monitor and maintain my implementation?

Monitoring and maintenance are essential to keep your implementation secure and efficient. Here are some tips:

  1. Logging: Implement comprehensive logging to track authentication attempts and errors.
  2. Regular updates: Keep your dependencies up to date to protect against vulnerabilities.
  3. Audit trails: Maintain audit trails for all authentication activities.

Example logging setup:

const morgan = require('morgan');

app.use(morgan('combined'));

How do I migrate existing users to passkeys?

Migrating existing users to passkeys requires a strategy to handle both password and passkey authentication. Here’s a basic approach:

  1. Dual authentication: Allow users to authenticate using either passwords or passkeys.
  2. Promote passkeys: Encourage users to register passkeys by providing incentives or simplifying the process.
  3. Deprecate passwords: Gradually phase out password authentication as more users adopt passkeys.

Example migration flow:

graph TD A[User Login] --> B{Has Passkey?} B -- Yes --> C[Authenticate with Passkey] B -- No --> D[Authenticate with Password] C --> E[Success] D --> E E --> F[Grant Access]

The future of passkeys and WebAuthn looks promising:

  • Wider adoption: More browsers and devices are supporting WebAuthn, increasing its reach.
  • Enhanced security: Ongoing improvements in security protocols and standards.
  • User experience: Continued focus on improving the user experience for passwordless authentication.
💡 Key Point: Stay updated with the latest developments in FIDO2 and WebAuthn to leverage new features and security enhancements.

Keycloak WebAuthn Setup (Quick Reference)

For teams using Keycloak as their IdP, here’s the minimum configuration for production passkeys:

# 1. Enable WebAuthn in realm settings (via Admin CLI)
kcadm.sh update realms/myrealm \
  -s 'webAuthnPolicyRpId=example.com' \
  -s 'webAuthnPolicyAttestationConveyancePreference=none' \
  -s 'webAuthnPolicyAuthenticatorAttachment=platform' \
  -s 'webAuthnPolicyRequireResidentKey=Yes' \
  -s 'webAuthnPolicyUserVerificationRequirement=required'

# 2. Enable the WebAuthn Register required action
kcadm.sh update authentication/required-actions/webauthn-register \
  -r myrealm -s enabled=true -s defaultAction=true

# 3. Add WebAuthn Authenticator to your browser flow
# (Do this in Admin Console: Authentication > Flows > Browser > Add Step > WebAuthn Authenticator)

For Keycloak 26+, the webauthn-register-passwordless action enables a full passwordless flow where users skip the password form entirely. See our Keycloak complete guide for full realm configuration.

Key Takeaways

  • Passkeys are domain-scoped FIDO2 credentials — phishing-resistant by design because the private key never leaves the device and is bound to your rpID.
  • Use authenticatorAttachment: "platform" for consumer passkeys (sync via iCloud/Google), "cross-platform" for YubiKey/hardware flows.
  • Always validate rpID, enforce challenge TTL, and check signCount server-side — these three steps prevent the main WebAuthn attack vectors.
  • Keycloak 21+ has native WebAuthn support; Auth0, Okta, and Entra ID all support FIDO2 natively with minimal configuration.

Passkeys eliminate password databases as an attack surface. Once deployed, your users authenticate with a biometric gesture instead of a string that can be phished, reused, or stolen from a breach. For teams on Keycloak, this is a single afternoon of configuration. For custom builds, @simplewebauthn/server gets you to production in under 200 lines of server code. For more on securing the authentication layer, see our OAuth 2.0 security best practices guide.