Claude Code for Passkeys and WebAuthn: Passwordless Authentication — Claude Skills 360 Blog
Blog / Security / Claude Code for Passkeys and WebAuthn: Passwordless Authentication
Security

Claude Code for Passkeys and WebAuthn: Passwordless Authentication

Published: November 2, 2026
Read time: 8 min read
By: Claude Skills 360

Passkeys replace passwords with cryptographic credentials stored on the user’s device — TouchID, FaceID, or a hardware security key. The browser handles the biometric prompt; your server only stores a public key. There’s nothing to steal from your database, no password to phish, and the UX is a single touch. Passkeys use the WebAuthn API, which follows a registration ceremony (create credential) and authentication ceremony (prove possession) pattern. Claude Code writes the registration and login flows, credential storage schemas, and the progressive enhancement that keeps passwords as fallback.

CLAUDE.md for WebAuthn Projects

## Auth Stack
- WebAuthn: @simplewebauthn/server (Node) + @simplewebauthn/browser
- Storage: credentials table per user — one user can have multiple passkeys
- Challenge: random 32-byte value, stored in server session, single-use
- RP ID: your domain (e.g., "example.com") — must match origin
- Transport: "internal" for platform (TouchID/FaceID), "usb"/"nfc" for hardware keys
- Progressive enhancement: passkey UI shown if platform supports it; password always available
- attestation: "none" for consumer apps; "direct" for enterprise/FIDO certification

Database Schema

-- Each user can register multiple passkeys (phone, laptop, security key)
CREATE TABLE webauthn_credentials (
  id             TEXT PRIMARY KEY,    -- base64url credentialID from authenticator
  user_id        UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  public_key     BYTEA NOT NULL,      -- COSE-encoded public key
  counter        BIGINT NOT NULL DEFAULT 0,  -- replay attack prevention
  device_type    TEXT NOT NULL,       -- 'singleDevice' or 'multiDevice'
  backed_up      BOOLEAN NOT NULL,   -- synced to iCloud/Google Password Manager
  transports     TEXT[],             -- ['internal', 'hybrid', 'usb']
  aaguid         TEXT,               -- authenticator model identifier
  friendly_name  TEXT,               -- user-provided name ("iPhone", "YubiKey")
  created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  last_used_at   TIMESTAMPTZ
);

CREATE INDEX webauthn_credentials_user_id_idx ON webauthn_credentials(user_id);

-- Challenges: single-use, expire in 5 minutes
CREATE TABLE webauthn_challenges (
  user_id     UUID NOT NULL,
  challenge   TEXT NOT NULL,  -- base64url encoded random bytes
  type        TEXT NOT NULL,  -- 'registration' or 'authentication'
  expires_at  TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '5 minutes',
  PRIMARY KEY (user_id, type)
);

Registration Ceremony

// auth/webauthn/registration.ts
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  type VerifiedRegistrationResponse,
} from '@simplewebauthn/server';
import type { RegistrationResponseJSON } from '@simplewebauthn/types';

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

// Step 1: Generate options and send to browser
export async function generateRegOptions(userId: string, userEmail: string) {
  // Get existing credentials to prevent re-registering same device
  const existingCredentials = await db.getCredentials(userId);
  
  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userName: userEmail,
    userID: Buffer.from(userId),
    attestationType: 'none',
    excludeCredentials: existingCredentials.map(cred => ({
      id: cred.id,
      transports: cred.transports,
    })),
    authenticatorSelection: {
      residentKey: 'preferred',      // Enables passkey (discoverable credential)
      userVerification: 'preferred', // Require biometric/PIN
    },
  });
  
  // Store challenge for verification (single-use)
  await db.saveChallenge(userId, options.challenge, 'registration');
  
  return options;
}

// Step 2: Verify browser response and store credential
export async function verifyRegResponse(
  userId: string,
  response: RegistrationResponseJSON,
): Promise<{ credentialId: string; deviceType: string }> {
  const { challenge } = await db.getAndDeleteChallenge(userId, 'registration');
  
  const verification = await verifyRegistrationResponse({
    response,
    expectedChallenge: challenge,
    expectedOrigin: ORIGIN,
    expectedRPID: RP_ID,
  });
  
  if (!verification.verified || !verification.registrationInfo) {
    throw new Error('Registration verification failed');
  }
  
  const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
  
  // Store credential for future authentication
  await db.saveCredential({
    id: Buffer.from(credential.id).toString('base64url'),
    userId,
    publicKey: Buffer.from(credential.publicKey),
    counter: credential.counter,
    deviceType: credentialDeviceType,
    backedUp: credentialBackedUp,
    transports: response.response.transports ?? [],
  });
  
  return { credentialId: credential.id.toString(), deviceType: credentialDeviceType };
}

Authentication Ceremony

// auth/webauthn/authentication.ts
import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type { AuthenticationResponseJSON } from '@simplewebauthn/types';

// Step 1: Generate options (can be usernameless with discoverable credentials)
export async function generateAuthOptions(userId?: string) {
  const allowCredentials = userId
    ? (await db.getCredentials(userId)).map(cred => ({
        id: cred.id,
        transports: cred.transports,
      }))
    : [];  // Empty = browser shows all available passkeys
  
  const options = await generateAuthenticationOptions({
    rpID: RP_ID,
    allowCredentials,
    userVerification: 'preferred',
  });
  
  // For usernameless: store challenge in session without user_id
  if (userId) {
    await db.saveChallenge(userId, options.challenge, 'authentication');
  } else {
    await session.set('authn_challenge', options.challenge);
  }
  
  return options;
}

// Step 2: Verify authentication response
export async function verifyAuthResponse(
  response: AuthenticationResponseJSON,
  userId?: string,
): Promise<string> {
  const credentialId = response.id;
  const credential = await db.getCredentialById(credentialId);
  
  if (!credential) throw new Error('Credential not found');
  
  const challenge = await db.getAndDeleteChallenge(credential.userId, 'authentication');
  
  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge: challenge,
    expectedOrigin: ORIGIN,
    expectedRPID: RP_ID,
    credential: {
      id: Buffer.from(credentialId, 'base64url'),
      publicKey: credential.publicKey,
      counter: credential.counter,
      transports: credential.transports,
    },
  });
  
  if (!verification.verified) throw new Error('Authentication failed');
  
  // Update counter — prevents replay attacks
  await db.updateCredentialCounter(credentialId, verification.authenticationInfo.newCounter);
  await db.updateLastUsed(credentialId);
  
  return credential.userId;
}

Browser-Side (JavaScript)

// client/passkeys.ts
import {
  startRegistration,
  startAuthentication,
  browserSupportsWebAuthn,
} from '@simplewebauthn/browser';

export async function registerPasskey() {
  if (!browserSupportsWebAuthn()) {
    throw new Error('This browser does not support passkeys');
  }
  
  // Get options from server
  const optionsRes = await fetch('/api/auth/passkeys/register/options', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
  });
  const options = await optionsRes.json();
  
  // Trigger browser's passkey UI (FaceID, TouchID, etc.)
  const registrationResponse = await startRegistration({ optionsJSON: options });
  
  // Send response to server for verification
  const verifyRes = await fetch('/api/auth/passkeys/register/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(registrationResponse),
  });
  
  return verifyRes.json();  // { credentialId, deviceType }
}

export async function authenticateWithPasskey() {
  const optionsRes = await fetch('/api/auth/passkeys/authenticate/options', {
    method: 'POST',
  });
  const options = await optionsRes.json();
  
  const authResponse = await startAuthentication({ optionsJSON: options });
  
  const verifyRes = await fetch('/api/auth/passkeys/authenticate/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(authResponse),
  });
  
  return verifyRes.json();  // { userId, sessionToken }
}

For the OAuth2/OIDC authentication that works alongside passkeys for federated identity, the OAuth2 auth guide covers the authorization code flow and token storage. For the JWT session management that follows successful passkey authentication, the authentication guide covers secure token handling. The Claude Skills 360 bundle includes WebAuthn skill sets covering registration/authentication ceremonies, credential storage, and progressive passkey enhancement. Start with the free tier to try passkey flow generation.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free