Skip to main content
Technology & EngineeringAuth Patterns213 lines

Passkeys

Passkeys and WebAuthn implementation for passwordless authentication

Quick Summary18 lines
You are an expert in passkeys and the Web Authentication API (WebAuthn) for passwordless authentication. You understand FIDO2 protocols, public key credential management, authenticator attestation, and user verification flows.

## Key Points

- **Relying Party (RP)**: The website or application requesting authentication, identified by its origin.
- **Authenticator**: The device or software that generates and stores key pairs (platform authenticator like Touch ID, or roaming authenticator like a YubiKey).
- **Credential**: A public/private key pair bound to a specific RP and user.
- **Challenge**: A server-generated random value that the authenticator signs to prove possession of the private key.
- **User Verification (UV)**: Local verification (biometric, PIN) performed by the authenticator before signing.
- **Attestation**: An optional authenticator-generated statement proving the credential was created by a specific type of device.
- **Discoverable Credentials (Resident Keys)**: Credentials stored on the authenticator that can be used without the server providing a credential ID, enabling username-less login.
1. **Offer passkeys as an upgrade path**, not a forced replacement. Let users register a passkey alongside their existing password, then encourage adoption.
2. **Use discoverable credentials** (`residentKey: 'preferred'`) to enable username-less authentication.
3. **Set `attestationType: 'none'`** unless you have a specific compliance requirement. Attestation adds complexity and privacy trade-offs.
4. **Track the signature counter** and flag anomalies — a counter that goes backward may indicate a cloned authenticator.
5. **Allow multiple passkeys per account** so users can register credentials on multiple devices.
skilldb get auth-patterns-skills/PasskeysFull skill: 213 lines
Paste into your CLAUDE.md or agent config

Passkeys / WebAuthn — Authentication & Authorization

You are an expert in passkeys and the Web Authentication API (WebAuthn) for passwordless authentication. You understand FIDO2 protocols, public key credential management, authenticator attestation, and user verification flows.

Core Philosophy

Overview

Passkeys are a FIDO2-based authentication mechanism that replaces passwords with public key cryptography. The user's device (or a security key) generates a key pair; the private key never leaves the device, and the server stores only the public key. Authentication is performed by signing a server-generated challenge with the private key. Passkeys are phishing-resistant because the credential is bound to the relying party's origin, and they can sync across devices through platform credential managers (iCloud Keychain, Google Password Manager, Windows Hello).

Core Concepts

  • Relying Party (RP): The website or application requesting authentication, identified by its origin.
  • Authenticator: The device or software that generates and stores key pairs (platform authenticator like Touch ID, or roaming authenticator like a YubiKey).
  • Credential: A public/private key pair bound to a specific RP and user.
  • Challenge: A server-generated random value that the authenticator signs to prove possession of the private key.
  • User Verification (UV): Local verification (biometric, PIN) performed by the authenticator before signing.
  • Attestation: An optional authenticator-generated statement proving the credential was created by a specific type of device.
  • Discoverable Credentials (Resident Keys): Credentials stored on the authenticator that can be used without the server providing a credential ID, enabling username-less login.

Implementation Patterns

Registration (Server — Node.js with @simplewebauthn/server)

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from '@simplewebauthn/server';

const RP_NAME = 'Example App';
const RP_ID = 'example.com';
const ORIGIN = 'https://example.com';

// Step 1: Generate registration options
app.post('/auth/passkey/register/options', async (req, res) => {
  const user = await getUser(req.session.userId);
  const existingCredentials = await getCredentialsForUser(user.id);

  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userID: user.id,
    userName: user.email,
    userDisplayName: user.name,
    attestationType: 'none',  // 'none' for most apps; 'direct' if you need attestation
    authenticatorSelection: {
      residentKey: 'preferred',        // Enable discoverable credentials
      userVerification: 'preferred',   // Request biometric/PIN
      authenticatorAttachment: 'platform',  // Platform authenticator (passkey)
    },
    excludeCredentials: existingCredentials.map(c => ({
      id: c.credentialId,
      type: 'public-key',
    })),
  });

  req.session.currentChallenge = options.challenge;
  res.json(options);
});

// Step 2: Verify registration response
app.post('/auth/passkey/register/verify', async (req, res) => {
  const verification = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge: req.session.currentChallenge,
    expectedOrigin: ORIGIN,
    expectedRPID: RP_ID,
  });

  if (verification.verified && verification.registrationInfo) {
    const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
    await storeCredential({
      userId: req.session.userId,
      credentialId: credentialID,
      publicKey: credentialPublicKey,
      counter,
      createdAt: new Date(),
    });
    res.json({ verified: true });
  } else {
    res.status(400).json({ verified: false });
  }
});

Authentication (Server)

import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';

// Step 1: Generate authentication options
app.post('/auth/passkey/login/options', async (req, res) => {
  const options = await generateAuthenticationOptions({
    rpID: RP_ID,
    userVerification: 'preferred',
    // Omit allowCredentials for discoverable credential (username-less) flow
    // Include them if the user already provided their username
  });

  req.session.currentChallenge = options.challenge;
  res.json(options);
});

// Step 2: Verify authentication response
app.post('/auth/passkey/login/verify', async (req, res) => {
  const credential = await getCredentialById(req.body.id);
  if (!credential) return res.status(400).json({ error: 'Unknown credential' });

  const verification = await verifyAuthenticationResponse({
    response: req.body,
    expectedChallenge: req.session.currentChallenge,
    expectedOrigin: ORIGIN,
    expectedRPID: RP_ID,
    authenticator: {
      credentialID: credential.credentialId,
      credentialPublicKey: credential.publicKey,
      counter: credential.counter,
    },
  });

  if (verification.verified) {
    // Update signature counter for clone detection
    await updateCredentialCounter(credential.id, verification.authenticationInfo.newCounter);

    req.session.userId = credential.userId;
    res.json({ verified: true });
  } else {
    res.status(401).json({ verified: false });
  }
});

Client-Side (Browser)

import {
  startRegistration,
  startAuthentication,
} from '@simplewebauthn/browser';

// Registration
async function registerPasskey() {
  const optionsRes = await fetch('/auth/passkey/register/options', { method: 'POST' });
  const options = await optionsRes.json();

  const credential = await startRegistration(options);

  const verifyRes = await fetch('/auth/passkey/register/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(credential),
  });

  return (await verifyRes.json()).verified;
}

// Authentication
async function loginWithPasskey() {
  const optionsRes = await fetch('/auth/passkey/login/options', { method: 'POST' });
  const options = await optionsRes.json();

  const assertion = await startAuthentication(options);

  const verifyRes = await fetch('/auth/passkey/login/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(assertion),
  });

  return (await verifyRes.json()).verified;
}

Best Practices

  1. Offer passkeys as an upgrade path, not a forced replacement. Let users register a passkey alongside their existing password, then encourage adoption.
  2. Use discoverable credentials (residentKey: 'preferred') to enable username-less authentication.
  3. Set attestationType: 'none' unless you have a specific compliance requirement. Attestation adds complexity and privacy trade-offs.
  4. Track the signature counter and flag anomalies — a counter that goes backward may indicate a cloned authenticator.
  5. Allow multiple passkeys per account so users can register credentials on multiple devices.
  6. Provide a recovery mechanism (backup codes, email-based recovery, or a second passkey) in case a device is lost.
  7. Use userVerification: 'preferred' rather than 'required' to avoid locking out devices that do not support biometrics.
  8. Validate the RP ID and origin strictly on the server to prevent relay attacks.

Common Pitfalls

  • No fallback authentication method: Users who lose their only registered device are permanently locked out without a recovery path.
  • Requiring attestation unnecessarily: Blocks many consumer authenticators that do not provide attestation statements.
  • Ignoring the signature counter: Missing a cloned-authenticator detection signal.
  • Not binding the challenge to the session: Allows an attacker to replay a challenge from a different session.
  • Confusing WebAuthn with passwordless magic links: Passkeys use public key cryptography and are phishing-resistant; magic links are not.
  • Hard-coding authenticatorAttachment: Setting 'cross-platform' excludes platform authenticators (and vice versa). Use undefined or 'platform' with a cross-platform fallback option.

Anti-Patterns

Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.

Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.

Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.

Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.

Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.

Install this skill directly: skilldb add auth-patterns-skills

Get CLI access →