Skip to content

WebAuthn & Passkeys

WebAuthn (FIDO2) is the strongest authentication Craft Easy supports: it binds a login to a hardware-backed credential (Touch ID, Windows Hello, a YubiKey, a platform passkey) and cannot be phished. The implementation lives in craft_easy.core.auth.webauthn.WebAuthnService and is exposed over the /auth/webauthn/* routes.

WebAuthn runs two ceremonies: one for registering a new credential, one for authenticating with it. Both follow the same shape — server generates options, browser runs navigator.credentials.*, server verifies the result.

Prerequisites

A configured WebAuthnService needs three pieces of information:

from craft_easy.core.auth.webauthn import WebAuthnService

webauthn = WebAuthnService(
    rp_id="api.example.com",
    rp_name="My App",
    origin="https://app.example.com",
)
Field Meaning
rp_id The relying-party ID. Must be a registrable suffix of the frontend origin (usually the apex domain).
rp_name Human-readable name shown in the browser prompt.
origin Expected origin value in client data. Must match what the browser sends.

The browser enforces that rp_id be reachable from the current page, so pick it carefully — getting this wrong is the single most common cause of silent WebAuthn failures.

Registration ceremony

1. Request options

The user must already be authenticated with a primary factor (OTP, OAuth2, API key). The server generates a fresh challenge, stashes it in memory keyed on user_id, and returns the full PublicKeyCredentialCreationOptions dict:

resp = client.post("/auth/webauthn/register/options")
options = resp.json()
{
  "challenge": "a1b2...",
  "rp": {"id": "api.example.com", "name": "My App"},
  "user": {"id": "...", "name": "user@example.com", "displayName": "User"},
  "pubKeyCredParams": [
    {"alg": -7, "type": "public-key"},
    {"alg": -257, "type": "public-key"}
  ],
  "authenticatorSelection": {
    "authenticatorAttachment": "platform",
    "userVerification": "preferred",
    "residentKey": "preferred"
  },
  "timeout": 60000,
  "attestation": "none"
}

2. Create the credential in the browser

const options = await fetch("/auth/webauthn/register/options", {
  method: "POST",
  headers: { "X-API-Key": token },
}).then(r => r.json());

// Convert base64url challenge/user.id to Uint8Arrays, then:
const credential = await navigator.credentials.create({ publicKey: options });

3. Verify and store

Post the credential fields back to the server:

await fetch("/auth/webauthn/register", {
  method: "POST",
  headers: { "X-API-Key": token, "Content-Type": "application/json" },
  body: JSON.stringify({
    credential_id: credential.id,
    client_data_json: toB64Url(credential.response.clientDataJSON),
    attestation_object: toB64Url(credential.response.attestationObject),
    transports: credential.response.getTransports?.() ?? [],
    device_name: "MacBook Touch ID",
  }),
});

The server verifies:

  1. The challenge came from its own store (_challenges[user_id]).
  2. clientDataJSON.type == "webauthn.create".
  3. clientDataJSON.challenge matches the stashed challenge.
  4. clientDataJSON.origin == self.origin.

On success a WebAuthnCredential document is written with the credential ID, public key, sign count, transports and device name.

Production note: WebAuthnService stores the attestation object as-is. If you need full attestation verification (trust chain, enterprise attestation, clone detection at the CA level), swap in py_webauthn — the surrounding route handlers do not need to change.

Authentication ceremony

1. Request options

resp = client.post("/auth/webauthn/authenticate/options", json={
    "email": "user@example.com",
})
options = resp.json()

The server loads the user's registered credentials and returns a PublicKeyCredentialRequestOptions dict:

{
  "challenge": "...",
  "rpId": "api.example.com",
  "allowCredentials": [
    {"id": "...", "type": "public-key", "transports": ["internal"]}
  ],
  "userVerification": "preferred",
  "timeout": 60000
}

2. Authenticate in the browser

const assertion = await navigator.credentials.get({ publicKey: options });

3. Verify and receive a token

const res = await fetch("/auth/webauthn/authenticate", {
  method: "POST",
  body: JSON.stringify({
    email: "user@example.com",
    credential_id: assertion.id,
    client_data_json: toB64Url(assertion.response.clientDataJSON),
    authenticator_data: toB64Url(assertion.response.authenticatorData),
    signature: toB64Url(assertion.response.signature),
  }),
});
const { token } = await res.json();

The server verifies the challenge and origin, loads the stored credential, checks the sign count for cloning, updates last_used_at, and returns a standard TokenResponse.

Clone detection

WebAuthn authenticators expose a monotonically increasing signCount. Craft Easy stores the last-seen value per credential and rejects logins where the new count is not strictly greater:

if new_count <= credential.sign_count and credential.sign_count > 0:
    raise ValueError("Possible credential cloning detected")

When this fires, something has copied the credential's private key. Invalidate the credential, ask the user to re-enrol, and open an incident.

Managing credentials

A user can list, name and remove their registered credentials:

# List
resp = client.get("/auth/webauthn/credentials")
# [{"credential_id": "...", "device_name": "iPhone", "registered_at": "...", "last_used_at": "..."}]

# Delete
client.delete(f"/auth/webauthn/credentials/{credential_id}")

Endpoints reference

Method Path Purpose
POST /auth/webauthn/register/options Start a registration ceremony.
POST /auth/webauthn/register Finish registration and store the credential.
POST /auth/webauthn/authenticate/options Start an authentication ceremony.
POST /auth/webauthn/authenticate Finish authentication and return a token.
GET /auth/webauthn/credentials List the current user's credentials.
DELETE /auth/webauthn/credentials/{credential_id} Remove a credential.