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.
Tenant configuration
Section titled “Tenant configuration”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.comcoversapp.example.combut notexample.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.
The endpoints
Section titled “The endpoints”Four ceremony routes (options/verify pairs) and three credential management routes.
| Method | Path | Role |
|---|---|---|
| POST | /relay/webauthn/register/options | Start enrollment (creation options). |
| POST | /relay/webauthn/register/verify | Finalize enrollment (attestation). |
| POST | /relay/webauthn/authenticate/options | Start sign-in (request options). |
| POST | /relay/webauthn/authenticate/verify | Finalize sign-in (assertion). |
| POST | /relay/webauthn/fetch/passkeys | List the user’s passkeys. |
| POST | /relay/webauthn/rename/passkey | Rename a passkey. |
| POST | /relay/webauthn/revoke/passkey | Revoke (soft-delete) a passkey. |
Start an enrollment
Section titled “Start an enrollment”browser_origin must be in webauthn_origins. relay_user_linked_id is the
relay-side identifier of the user (24 characters).
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.
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" }'Passkey sign-in
Section titled “Passkey sign-in”Symmetric: authenticate/options then authenticate/verify. At the options
step, relay_user_linked_id is optional — omit it for a usernameless flow
(discoverable credential).
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" }'Manage credentials
Section titled “Manage credentials”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" }'curl $BASE_URL/api/v1/relay/webauthn/rename/passkey \ -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 '{ "passkey_id": "pk_abc123def456", "relay_user_linked_id": "652f1f77bcf86cd799439011", "nickname": "Work computer" }'curl $BASE_URL/api/v1/relay/webauthn/revoke/passkey \ -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 '{ "passkey_id": "pk_abc123def456", "relay_user_linked_id": "652f1f77bcf86cd799439011" }'.well-known association files
Section titled “.well-known association files”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.
Onboarding an app
Section titled “Onboarding an app”Adding an app to the bloonio.com RP is a formal onboarding step, in summary:
- Android fingerprint — the SHA-256 of the app signing key (Play Console → App signing), not the upload key or a local keystore.
- iOS identity — Team ID and canonical bundle id, plus the Associated
Domains entitlement
webcredentials:bloonio.com. - Complete the entry in
passkey_identities.yaml; flip the section toenabled: trueonly when every value is real. - Regenerate (
python wellknown/generate_wellknown.py) and commit the YAML andgenerated/together (CI enforces it). - Deploy the relay — the files ship with the service.
- 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-knownfiles. - Validate:
./wellknown/validate_wellknown.sh(with--googleto query the Digital Asset Links API), to re-run after any Cloudflare change.
Progressive rollout (passkeys_enabled)
Section titled “Progressive rollout (passkeys_enabled)”The per-tenant passkeys_enabled flag drives a progressive rollout without
ever locking anyone out:
| Value | Effect |
|---|---|
null (default) | Enabled when configured (webauthn_rp_id + webauthn_origins). Live tenants unchanged. |
false | Enrollment and sign-in refused (403) — but management stays available. |
true | Same 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}.
Metrics
Section titled “Metrics”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.