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.
- name
passkeys-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.
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://localhostfor development. - RP ID is a registrable domain suffix of the origin (
example.comforapp.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
noneis the default for consumer apps. Ask fordirectonly 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
| Goal | Configuration |
|---|---|
| Passwordless primary login (recommended for new apps) | residentKey: 'required', userVerification: 'preferred' or 'required', empty allowCredentials |
| Second factor after password | Discoverable 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 only | attestationType: '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,
expectedOriginas an array of exact origins. - Different registrable domains sharing credentials (
example.comandexample.co.uk): Related Origin Requests, a JSON list athttps://<rpID>/.well-known/webauthnnaming the other origins. Browser support is still uneven; verify before relying on it. - Native apps: Android needs Digital Asset Links (
assetlinks.json) and accepts anandroid:apk-key-hash:...origin; iOS needs Associated Domains with thewebcredentials:entry inapple-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
| Option | Phishing resistance | Use when |
|---|---|---|
| Second passkey or security key | High | Always prompt users to register two |
| Synced passkeys (iCloud Keychain, Google Password Manager) | High, recovery handled by platform | Default consumer case |
| Recovery codes (one-time, hashed at rest) | Medium | Accounts without a second authenticator |
| Email magic link | Low; equals email account security | Low-value accounts; require a delay or notification |
| Support-assisted identity verification | Varies | High-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
NotAllowedErrorcovers 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.comis nothttps://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.
excludeCredentialsmust 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
webauthntoken inautocompleteand 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
- 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.
- 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.
- Revoked credential can no longer sign in; audit log shows add, use, and revoke events.
- Chrome DevTools > WebAuthn panel with a virtual authenticator runs the flows in CI-like
manual checks; Playwright can drive the same via the CDP
WebAuthndomain.
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.