Security Editor's pick

Passkeys and WebAuthn skill for Claude Code

Phishing-resistant sign-in with a recovery path to match. Claude builds passkey sign-in the way identity teams ship it: an RP ID chosen for the long term, single-use server-side challenges, verified origins, a credential table with counters, transports, and backup flags, and conditional UI so passkeys appear in autofill. It also designs fallback and account recovery so an email link does not quietly undo the phishing resistance.

  • namepasskeys-webauthn
  • size266 lines · ≈3,350 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Implements passkey sign-in with WebAuthn end to end - registration and authentication ceremonies with SimpleWebAuthn (TypeScript) or py_webauthn (Python), server-stored single-use challenges, RP ID and origin rules, credential storage schema, conditional UI autofill, discoverable credentials, sign counters and backup flags, passkey upgrade prompts, fallback methods, and account recovery that does not undo the phishing resistance. Use when adding passkeys or security keys, replacing passwords or SMS OTP, debugging NotAllowedError or origin mismatch, or reviewing a WebAuthn implementation.

Try asking

  • Add passkey sign-in to our Express app with SimpleWebAuthn and autofill support
  • Users get NotAllowedError when registering a passkey on app.example.com; debug it
  • Design account recovery for passkey-only users that is not just an email link

Use it when

  • Adding passkeys as primary login, second factor, or step-up authentication
  • Replacing passwords or SMS one-time codes
  • Debugging WebAuthn origin, RP ID, or NotAllowedError failures
  • Reviewing an existing WebAuthn implementation before launch

Skip it when

  • You need password hashing, sessions, and TOTP basics - use auth-sessions instead
  • Login is delegated to Google, Okta, or another IdP - use oauth-oidc instead

How to check the result

Registration and sign-in succeed on Safari, Chrome, an Android cross-device QR flow, and a hardware key, while replayed, expired-challenge, and wrong-origin responses are all rejected.

The common mistake it prevents

Picking a subdomain such as app.example.com as the RP ID; moving it later invalidates every registered passkey.

~/.claude/skills/passkeys-webauthn/SKILL.md

--- name: passkeys-webauthn description: Implements passkey sign-in with WebAuthn end to end - registration and authentication ceremonies with SimpleWebAuthn (TypeScript) or… license: MIT ---

Done well means: challenges are generated and consumed server-side exactly once, origin and RP ID are verified on every ceremony, credentials are stored per user with their public key, counter, transports, and backup state, sign-in works through browser autofill, and the recovery path is no weaker than it has to be. A passkey login that can be bypassed by an email link to an attacker-controlled inbox is not phishing-resistant.

Ground rules

  • Use a maintained library for verification: @simplewebauthn/server + @simplewebauthn/browser (v13+), webauthn (py_webauthn 2.x) for Python, or the framework’s vetted integration. Never parse CBOR/COSE or verify signatures by hand.
  • WebAuthn requires a secure context: HTTPS, or http://localhost for development.
  • RP ID is a registrable domain suffix of the origin (example.com for app.example.com). Changing it later orphans every credential. Pick the apex domain you will own long-term.
  • One challenge per ceremony: at least 16 random bytes, stored server-side bound to the session (or a short-lived signed cookie), expires in about 5 minutes, deleted on first use.
  • Store credentials in their own table, many per user. Users will have a phone, a laptop, and a security key.
  • Attestation none is the default for consumer apps. Ask for direct only with an enterprise requirement to allowlist authenticator models.

Workflow

- [ ] 1. Decide the role of passkeys: primary sign-in, MFA, or step-up
- [ ] 2. Choose RP ID and list every origin (web, subdomains, native apps)
- [ ] 3. Create the credential table and challenge storage
- [ ] 4. Registration ceremony (options -> browser -> verify -> store)
- [ ] 5. Authentication ceremony with conditional UI autofill
- [ ] 6. Management UI: list, rename, revoke credentials
- [ ] 7. Fallback and account recovery policy
- [ ] 8. Test on real platforms and verify negative cases

1. Role of passkeys

GoalConfiguration
Passwordless primary login (recommended for new apps)residentKey: 'required', userVerification: 'preferred' or 'required', empty allowCredentials
Second factor after passwordDiscoverable not required; allowCredentials from the user’s stored credentials
Step-up for sensitive actions (payout, delete account)Authentication ceremony with userVerification: 'required', scoped to the action
Enterprise hardware keys onlyattestationType: 'direct', verify against the FIDO Metadata Service, authenticatorAttachment: 'cross-platform'

2. RP ID and origins

  • Web only, one domain: rpID = "example.com", expectedOrigin = "https://example.com".
  • Several subdomains: one RP ID at the apex, expectedOrigin as an array of exact origins.
  • Different registrable domains sharing credentials (example.com and example.co.uk): Related Origin Requests, a JSON list at https://<rpID>/.well-known/webauthn naming the other origins. Browser support is still uneven; verify before relying on it.
  • Native apps: Android needs Digital Asset Links (assetlinks.json) and accepts an android:apk-key-hash:... origin; iOS needs Associated Domains with the webcredentials: entry in apple-app-site-association.

3. Storage

CREATE TABLE webauthn_credentials (
  id               TEXT PRIMARY KEY,          -- credential ID, base64url
  user_id          UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  public_key       BYTEA NOT NULL,            -- COSE public key bytes
  sign_count       BIGINT NOT NULL DEFAULT 0,
  transports       TEXT[] NOT NULL DEFAULT '{}',
  device_type      TEXT NOT NULL,             -- 'singleDevice' | 'multiDevice'
  backed_up        BOOLEAN NOT NULL,
  aaguid           TEXT,                      -- authenticator model, for display names
  nickname         TEXT,
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_used_at     TIMESTAMPTZ
);
CREATE INDEX ON webauthn_credentials (user_id);

Also give every user a stable, random WebAuthn user handle (up to 64 bytes, not the email and not a sequential ID). It is returned on discoverable login and maps back to the user.

4. Registration (SimpleWebAuthn)

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

const rpID = process.env.WEBAUTHN_RP_ID!;            // "example.com"
const origins = process.env.WEBAUTHN_ORIGINS!.split(","); // exact origins

app.post("/webauthn/register/options", requireSession, async (req, res) => {
  const user = req.user;
  const existing = await db.credentials.forUser(user.id);
  const options = await generateRegistrationOptions({
    rpName: "Example",
    rpID,
    userName: user.email,
    userID: user.webauthnHandle,                      // Uint8Array, random, stable
    attestationType: "none",
    excludeCredentials: existing.map((c) => ({ id: c.id, transports: c.transports })),
    authenticatorSelection: { residentKey: "required", userVerification: "preferred" },
  });
  await challenges.put(req.session.id, "register", options.challenge, { ttlSeconds: 300 });
  res.json(options);
});

app.post("/webauthn/register/verify", requireSession, async (req, res) => {
  const expectedChallenge = await challenges.take(req.session.id, "register"); // get + delete
  if (!expectedChallenge) return res.status(400).json({ error: "challenge expired" });
  let verification;
  try {
    verification = await verifyRegistrationResponse({
      response: req.body, expectedChallenge, expectedOrigin: origins, expectedRPID: rpID,
    });
  } catch {
    return res.status(400).json({ error: "verification failed" });
  }
  if (!verification.verified || !verification.registrationInfo) return res.status(400).end();
  const { credential, credentialDeviceType, credentialBackedUp, aaguid } = verification.registrationInfo;
  await db.credentials.insert({
    id: credential.id, userId: req.user.id, publicKey: Buffer.from(credential.publicKey),
    signCount: credential.counter, transports: credential.transports ?? [],
    deviceType: credentialDeviceType, backedUp: credentialBackedUp, aaguid,
  });
  await audit("webauthn.credential.added", req.user.id, { credentialId: credential.id });
  res.json({ ok: true });
});
// browser
import { startRegistration } from "@simplewebauthn/browser";
const optionsJSON = await (await fetch("/webauthn/register/options", { method: "POST" })).json();
try {
  const attResp = await startRegistration({ optionsJSON });
  await fetch("/webauthn/register/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(attResp) });
} catch (e) {
  if ((e as Error).name === "InvalidStateError") showMessage("This device already has a passkey for your account.");
  else if ((e as Error).name === "NotAllowedError") showMessage("Passkey setup was cancelled or timed out.");
  else throw e;
}

Python (py_webauthn) follows the same shape: generate_registration_options(...), options_to_json(options) for the response, then verify_registration_response(credential=..., expected_challenge=..., expected_rp_id=..., expected_origin=...) and store credential_id, credential_public_key, and sign_count from the result.

5. Authentication with conditional UI

Conditional mediation lets the browser offer passkeys in the username field’s autofill.

<input type="text" name="username" autocomplete="username webauthn" />
// browser, on page load of the sign-in page
import { startAuthentication, browserSupportsWebAuthnAutofill } from "@simplewebauthn/browser";
if (await browserSupportsWebAuthnAutofill()) {
  const optionsJSON = await (await fetch("/webauthn/login/options", { method: "POST" })).json();
  const asseResp = await startAuthentication({ optionsJSON, useBrowserAutofill: true });
  await fetch("/webauthn/login/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(asseResp) });
}
// server
import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";

app.post("/webauthn/login/options", async (req, res) => {
  const options = await generateAuthenticationOptions({ rpID, userVerification: "preferred" }); // empty allowCredentials = discoverable
  await challenges.put(req.sessionID, "login", options.challenge, { ttlSeconds: 300 });
  res.json(options);
});

app.post("/webauthn/login/verify", loginRateLimit, async (req, res) => {
  const expectedChallenge = await challenges.take(req.sessionID, "login");
  const cred = expectedChallenge && (await db.credentials.byId(req.body.id));
  if (!cred) return res.status(400).json({ error: "unknown credential" }); // same response for both
  const { verified, authenticationInfo } = await verifyAuthenticationResponse({
    response: req.body, expectedChallenge, expectedOrigin: origins, expectedRPID: rpID,
    credential: { id: cred.id, publicKey: cred.publicKey, counter: cred.signCount, transports: cred.transports },
  }).catch(() => ({ verified: false } as any));
  if (!verified) return res.status(400).json({ error: "verification failed" });
  await db.credentials.update(cred.id, {
    signCount: authenticationInfo.newCounter, backedUp: authenticationInfo.credentialBackedUp, lastUsedAt: new Date(),
  });
  await startSession(req, res, cred.userId);  // rotate session ID here
  res.json({ ok: true });
});

Also check that response.userHandle, when present, matches the stored user’s handle.

6. Management UI

  • List each credential with a name (from AAGUID lookup or user nickname), created and last used dates, and synced vs device-bound.
  • Revoking a credential requires a recent re-authentication. Do not allow removing the last sign-in method without setting up another.
  • When a credential is revoked server-side, browsers that support the WebAuthn Signal API (PublicKeyCredential.signalUnknownCredential) can be told to hide it from autofill; feature-detect before calling.

7. Fallback and recovery

OptionPhishing resistanceUse when
Second passkey or security keyHighAlways prompt users to register two
Synced passkeys (iCloud Keychain, Google Password Manager)High, recovery handled by platformDefault consumer case
Recovery codes (one-time, hashed at rest)MediumAccounts without a second authenticator
Email magic linkLow; equals email account securityLow-value accounts; require a delay or notification
Support-assisted identity verificationVariesHigh-value accounts; slow by design

Rules: recovery must notify all existing channels, can add a delay for high-value accounts, and must not silently disable existing passkeys. After a password-to-passkey migration, keep the password as fallback until the user opts to remove it; do not delete it for them.

Upgrade prompt: after a successful password login, offer “Create a passkey” once per device. Browsers with conditional create (mediation: 'conditional' on navigator.credentials.create) can do this without a modal; feature-detect via PublicKeyCredential.getClientCapabilities().

Gotchas

  • NotAllowedError covers cancel, timeout, and wrong RP ID alike. Check the RP ID against the page’s host before blaming the user.
  • Origin must match exactly: scheme, host, and port. https://www.example.com is not https://example.com.
  • Base64url everywhere. Mixing standard base64 for credential IDs breaks lookups silently.
  • Sign counters: synced passkeys often always report 0. Treat a counter that goes backwards (when nonzero) as a possible clone and flag, rather than hard-failing everyone.
  • excludeCredentials must be populated, or users register duplicate passkeys on the same authenticator.
  • Do not reveal whether an account has passkeys before authentication (enumeration). With discoverable credentials you never need to ask for the username first.
  • Conditional UI requires the webauthn token in autocomplete and must start on page load, not on a button click; abort it before starting a modal ceremony.
  • Cross-device sign-in (QR code with a phone) needs Bluetooth on the desktop; document it in support material.

Verification

  1. Register and sign in on at least: Chrome or Edge with Google Password Manager, Safari with iCloud Keychain, an Android phone via cross-device QR, and a hardware security key.
  2. Negative tests: replayed assertion (same challenge) is rejected; challenge older than TTL is rejected; response from a different origin is rejected; a credential belonging to user A cannot complete a ceremony started for user B in MFA mode.
  3. Revoked credential can no longer sign in; audit log shows add, use, and revoke events.
  4. Chrome DevTools > WebAuthn panel with a virtual authenticator runs the flows in CI-like manual checks; Playwright can drive the same via the CDP WebAuthn domain.

Output contract

## Passkeys: <app>
- RP ID: example.com   Origins: https://example.com, https://app.example.com
- Mode: passwordless primary (discoverable, UV preferred), conditional UI enabled
- Storage: webauthn_credentials (schema above), challenges in Redis TTL 300 s, single use
- Fallback: second passkey prompt, 10 hashed recovery codes, email link with 24 h delay + notice
- Tested: Chrome/GPM, Safari/iCloud, Android QR, YubiKey; negative cases 1-4 passing

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc