Claude
Skills
Sign in
Back

webauthn

Included with Lifetime
$97 forever

Implement passwordless authentication with WebAuthn and Passkeys. Use when: adding passkey/biometric login, implementing FIDO2 authentication, replacing password-based login, building touch ID / face ID authentication flows in web apps. Covers browser API, server verification, and simplewebauthn library.

Backend & APIs

What this skill does


# WebAuthn / Passkeys

## Overview

WebAuthn (Web Authentication) lets users authenticate with biometrics (Face ID, Touch ID, Windows Hello) or hardware keys (YubiKey) instead of passwords. Passkeys are WebAuthn credentials synced across devices via iCloud Keychain, Google Password Manager, or 1Password.

**Key concepts:**
- **Relying Party (RP)**: Your server — verifies credentials
- **Authenticator**: Device/platform (Touch ID, Face ID, YubiKey)
- **Credential**: Public/private key pair — private key never leaves device
- **Challenge**: Server-issued random bytes — prevents replay attacks

## Setup

### Install `@simplewebauthn`

```bash
npm install @simplewebauthn/server @simplewebauthn/browser
# Types
npm install -D @types/node
```

**SimpleWebAuthn** abstracts the low-level CBOR/COSE encoding and handles most edge cases.

### Configure Relying Party

```ts
// config/webauthn.ts
export const RP_NAME = "My App";
export const RP_ID = process.env.RP_ID || "localhost"; // domain, no protocol/port
export const ORIGIN = process.env.ORIGIN || "http://localhost:3000";
// RP_ID must match the domain of ORIGIN
// For production: RP_ID = "myapp.com", ORIGIN = "https://myapp.com"
```

---

## Registration Flow

### Overview

```
Client                          Server
  |                                |
  |-- POST /auth/register/begin -->|
  |                                | 1. Generate challenge
  |<-- { options } ---------------|
  |                                |
  | 2. navigator.credentials.create(options)
  |    (user taps Touch ID / Face ID)
  |                                |
  |-- POST /auth/register/finish ->|
  |   { credential }               | 3. Verify & store public key
  |<-- { ok: true } --------------|
```

### Server: generate registration options

```ts
// routes/auth.ts
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { RP_ID, RP_NAME, ORIGIN } from "../config/webauthn";

// In-memory store for demo; use DB in production
const challenges = new Map<string, string>(); // userId → challenge
const credentials = new Map<string, any[]>(); // userId → credentials[]

app.post("/auth/register/begin", async (req, res) => {
  const { userId, username } = req.body;

  // Fetch existing credentials for the user (to exclude re-registration)
  const userCredentials = credentials.get(userId) || [];

  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userName: username,
    userDisplayName: username,
    // Prevent registering the same authenticator twice
    excludeCredentials: userCredentials.map((cred) => ({
      id: cred.id,
      type: "public-key",
    })),
    authenticatorSelection: {
      // "platform" = built-in (Touch ID); "cross-platform" = security key
      authenticatorAttachment: "platform",
      residentKey: "preferred",
      userVerification: "preferred",
    },
  });

  // Store challenge for verification
  challenges.set(userId, options.challenge);

  res.json(options);
});
```

### Server: verify registration

```ts
app.post("/auth/register/finish", async (req, res) => {
  const { userId, credential } = req.body;
  const expectedChallenge = challenges.get(userId);

  if (!expectedChallenge) {
    return res.status(400).json({ error: "No challenge found" });
  }

  let verification;
  try {
    verification = await verifyRegistrationResponse({
      response: credential,
      expectedChallenge,
      expectedOrigin: ORIGIN,
      expectedRPID: RP_ID,
    });
  } catch (err) {
    return res.status(400).json({ error: (err as Error).message });
  }

  if (!verification.verified || !verification.registrationInfo) {
    return res.status(400).json({ error: "Verification failed" });
  }

  // Store the credential (save to DB in production)
  const { credential: cred } = verification.registrationInfo;
  const userCreds = credentials.get(userId) || [];
  userCreds.push({
    id: cred.id,
    publicKey: cred.publicKey,
    counter: cred.counter,
    deviceType: verification.registrationInfo.credentialDeviceType,
    backedUp: verification.registrationInfo.credentialBackedUp,
  });
  credentials.set(userId, userCreds);
  challenges.delete(userId);

  res.json({ ok: true });
});
```

### Client: register passkey

```ts
// client/auth.ts
import {
  startRegistration,
  browserSupportsWebAuthn,
} from "@simplewebauthn/browser";

export async function registerPasskey(userId: string, username: string) {
  if (!browserSupportsWebAuthn()) {
    throw new Error("WebAuthn not supported in this browser");
  }

  // 1. Get options from server
  const optionsRes = await fetch("/auth/register/begin", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, username }),
  });
  const options = await optionsRes.json();

  // 2. Prompt user (opens Touch ID / Face ID)
  let credential;
  try {
    credential = await startRegistration({ optionsJSON: options });
  } catch (err: any) {
    if (err.name === "InvalidStateError") {
      throw new Error("This authenticator is already registered");
    }
    throw err;
  }

  // 3. Verify with server
  const verifyRes = await fetch("/auth/register/finish", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, credential }),
  });
  const result = await verifyRes.json();

  if (!result.ok) throw new Error(result.error);
  return result;
}
```

---

## Authentication Flow

### Overview

```
Client                          Server
  |                                |
  |-- POST /auth/login/begin ----->|
  |                                | 1. Generate challenge
  |<-- { options } ---------------|
  |                                |
  | 2. navigator.credentials.get(options)
  |    (user taps Touch ID)
  |                                |
  |-- POST /auth/login/finish ---->|
  |   { assertion }                | 3. Verify signature + counter
  |<-- { token } -----------------|
```

### Server: generate authentication options

```ts
import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";

app.post("/auth/login/begin", async (req, res) => {
  const { userId } = req.body;
  const userCredentials = credentials.get(userId) || [];

  if (userCredentials.length === 0) {
    return res.status(400).json({ error: "No passkeys registered" });
  }

  const options = await generateAuthenticationOptions({
    rpID: RP_ID,
    allowCredentials: userCredentials.map((cred) => ({
      id: cred.id,
      type: "public-key",
    })),
    userVerification: "preferred",
  });

  challenges.set(userId, options.challenge);
  res.json(options);
});
```

### Server: verify authentication

```ts
app.post("/auth/login/finish", async (req, res) => {
  const { userId, assertion } = req.body;
  const expectedChallenge = challenges.get(userId);
  const userCredentials = credentials.get(userId) || [];

  const credential = userCredentials.find((c) => c.id === assertion.id);
  if (!credential) {
    return res.status(400).json({ error: "Credential not found" });
  }

  let verification;
  try {
    verification = await verifyAuthenticationResponse({
      response: assertion,
      expectedChallenge: expectedChallenge!,
      expectedOrigin: ORIGIN,
      expectedRPID: RP_ID,
      credential: {
        id: credential.id,
        publicKey: credential.publicKey,
        counter: credential.counter,
      },
    });
  } catch (err) {
    return res.status(400).json({ error: (err as Error).message });
  }

  if (!verification.verified) {
    return res.status(401).json({ error: "Authentication failed" });
  }

  // Update counter (replay attack protection)
  credential.counter = verification.authenticationInfo.newCounter;
  challenges.delete(userId);

  // Issue session/JWT here
  const token = issueJWT(userId);
  res.json({ ok: true, token });
});
```

### Client: authenticate with pa
Files: 2
Size: 14.6 KB
Complexity: 26/100
Category: Backend & APIs

Related in Backend & APIs