Skip to content

Passkeys (WebAuthn)

The Auth Relay brokers WebAuthn / passkey ceremonies for each tenant (HMAC #1 signed). It validates the browser origin against the tenant’s allowlist, then forwards the tenant’s webauthn_rp_id and rp_name to auth_api — so credentials are partitioned per tenant.

Two fields (set at provisioning or via /update/tenant) enable passkeys:

  • webauthn_rp_id — the Relying Party ID, the eTLD+1 of the tenant’s web origin (e.g. example.com). Required; cannot cross an eTLD+1 boundary (example.com covers app.example.com but not example.net).
  • webauthn_origins — the allowlist of origins permitted to run a ceremony. Empty list = passkeys disabled.

If webauthn_rp_id is missing or the origin is outside the allowlist, the ceremony routes return 403.

Four ceremony routes (options/verify pairs) and three credential management routes.

MethodPathRole
POST/relay/webauthn/register/optionsStart enrollment (creation options).
POST/relay/webauthn/register/verifyFinalize enrollment (attestation).
POST/relay/webauthn/authenticate/optionsStart sign-in (request options).
POST/relay/webauthn/authenticate/verifyFinalize sign-in (assertion).
POST/relay/webauthn/fetch/passkeysList the user’s passkeys.
POST/relay/webauthn/rename/passkeyRename a passkey.
POST/relay/webauthn/revoke/passkeyRevoke (soft-delete) a passkey.

browser_origin must be in webauthn_origins. relay_user_linked_id is the relay-side identifier of the user (24 characters).

Fenêtre de terminal
curl $BASE_URL/api/v1/relay/webauthn/register/options \
-X POST \
-H "X-Bloonio-Tenant-Id: tnt_..." \
-H "X-Bloonio-Timestamp: 1718966400000" \
-H "X-Bloonio-Signature: <hex_hmac_sha256>" \
-H "Content-Type: application/json" \
-d '{
"relay_user_linked_id": "652f1f77bcf86cd799439011",
"username": "jane@example.com",
"display_name": "Jane Doe",
"browser_origin": "https://example.com"
}'

The returned data contains the PublicKeyCredentialCreationOptions to pass to navigator.credentials.create(). Then send the session_id and the attestation back to register/verify.

Fenêtre de terminal
curl $BASE_URL/api/v1/relay/webauthn/register/verify \
-X POST \
-H "X-Bloonio-Tenant-Id: tnt_..." \
-H "X-Bloonio-Timestamp: 1718966400000" \
-H "X-Bloonio-Signature: <hex_hmac_sha256>" \
-H "Content-Type: application/json" \
-d '{
"session_id": "sess_abcdef012345",
"attestation": { "id": "...", "rawId": "...", "type": "public-key", "response": {} },
"nickname": "My computer",
"browser_origin": "https://example.com"
}'

Symmetric: authenticate/options then authenticate/verify. At the options step, relay_user_linked_id is optional — omit it for a usernameless flow (discoverable credential).

Fenêtre de terminal
curl $BASE_URL/api/v1/relay/webauthn/authenticate/options \
-X POST \
-H "X-Bloonio-Tenant-Id: tnt_..." \
-H "X-Bloonio-Timestamp: 1718966400000" \
-H "X-Bloonio-Signature: <hex_hmac_sha256>" \
-H "Content-Type: application/json" \
-d '{ "browser_origin": "https://example.com" }'
Fenêtre de terminal
curl $BASE_URL/api/v1/relay/webauthn/fetch/passkeys \
-X POST \
-H "X-Bloonio-Tenant-Id: tnt_..." \
-H "X-Bloonio-Timestamp: 1718966400000" \
-H "X-Bloonio-Signature: <hex_hmac_sha256>" \
-H "Content-Type: application/json" \
-d '{ "relay_user_linked_id": "652f1f77bcf86cd799439011" }'

Native passkeys (Android/iOS) require the app to be declared in the Relying Party bloonio.com association files. The relay serves /.well-known/assetlinks.json (Android) and /.well-known/apple-app-site-association (iOS) itself; Caddy routes the apex bloonio.com paths to the relay.

Adding an app to the bloonio.com RP is a formal onboarding step, in summary:

  1. Android fingerprint — the SHA-256 of the app signing key (Play Console → App signing), not the upload key or a local keystore.
  2. iOS identity — Team ID and canonical bundle id, plus the Associated Domains entitlement webcredentials:bloonio.com.
  3. Complete the entry in passkey_identities.yaml; flip the section to enabled: true only when every value is real.
  4. Regenerate (python wellknown/generate_wellknown.py) and commit the YAML and generated/ together (CI enforces it).
  5. Deploy the relay — the files ship with the service.
  6. Apply the tenant origins on the host: ./bash/install/apply-passkey-origins.py prod --tenant-id tnt_…. Without this step, native Android assertions are rejected even with valid .well-known files.
  7. Validate: ./wellknown/validate_wellknown.sh (with --google to query the Digital Asset Links API), to re-run after any Cloudflare change.

The per-tenant passkeys_enabled flag drives a progressive rollout without ever locking anyone out:

ValueEffect
null (default)Enabled when configured (webauthn_rp_id + webauthn_origins). Live tenants unchanged.
falseEnrollment and sign-in refused (403) — but management stays available.
trueSame as null (explicit enable).

Recommended sequence: onboard the tenant, keep it paused (passkeys_enabled: false) during an internal test, then enable (passkeys_enabled: true). Instant rollback with {"passkeys_enabled": false}.

No metrics backend is wired up: the relay emits parseable log lines at the multi-tenant choke point (app/modules/webauthn/router.py), to aggregate via Loki / CloudWatch / grep:

passkey.metric event=<enroll|signin|revoke> outcome=<ok|fail> tenant=<tnt_…> [reason=<…>]

Client-side failures (cancelled, unsupported, no_credential) never reach the server — the SDKs surface them as typed errors; wire them into your own analytics if you need the cancellation rates.