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:
{
"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:
- The challenge came from its own store (
_challenges[user_id]). clientDataJSON.type == "webauthn.create".clientDataJSON.challengematches the stashed challenge.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:
WebAuthnServicestores the attestation object as-is. If you need full attestation verification (trust chain, enterprise attestation, clone detection at the CA level), swap inpy_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¶
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. |