Authentication
The two credentials that reach PasskeyBridge, how to mint and send an API key, what each scope gates, inbound and outbound signature policy, and how rotation actually works.
Overview
Two credentials authenticate a request, and which one an endpoint accepts is fixed by that endpoint.
API key. A scoped tenant credential sent in x-pb-api-key, alongside the tenant UUID in x-pb-tenant-id. This is the lane for server-to-server integrations. SCIM and Okta event hooks take the same key as Authorization: Bearer <key> instead, because their clients cannot set a custom header.
Dashboard session token. The signed-in user's Supabase session token in Authorization: Bearer <token>. The function validates it and checks is_tenant_admin, so this lane is tenant admins only. It is deemed to hold the admin scope and therefore satisfies every scope check.
Both lanes resolve to exactly one tenant. Neither crosses a tenant boundary: the key is matched against active rows for the tenant you named, and the session check is is_tenant_admin(user, tenant).
A handful of endpoints need no credential at all: credential status reads, DID resolution, the clock endpoint, the chain checkpoint read, the browser error reporter, the OpenAPI document, the agent verify action and the OpenID4VCI token and credential exchanges, where the pre-authorized code is itself the credential.
Everything except the signed Stripe webhook must arrive through https://api.passkeybridge.io/v1/<function>. A direct call to the Supabase functions host is refused with 403 and x-pb-reason: edge-locked, because that path would bypass the edge rate limits and firewall.
Who can use this: every tenant on every plan. Nothing on this page is plan-gated.
API keys
API keys are minted server-side and shown once.
Generate one. Dashboard > API keys > "Generate API key" opens the ingest setup wizard. Give the key a label, pick its scopes, and tick "Sandbox key" if the key is for testing. The wizard calls the provision_api_key action on shield-admin-mutations, which verifies your admin role, generates the key, hashes it and writes the row as the service role. The raw key is returned exactly once and is copied to your clipboard; it is never recoverable afterwards. The browser holds no write grant on the key table, so this is the only way a key can be created.
Keys are prefixed pb_live_ (production) or pb_test_ (sandbox), followed by two UUIDs. The prefix is derived from the sandbox flag on the server, so it always agrees with the row.
Send it.
curl -X POST https://api.passkeybridge.io/v1/shield-ingest \
-H "x-pb-api-key: pb_live_..." \
-H "x-pb-tenant-id: 00000000-0000-0000-0000-000000000000" \
-H "Content-Type: application/json" \
-d '{"event_type":"sim_swap","phone":"+15551234567"}'| Header | Required | Notes |
|---|---|---|
x-pb-api-key | yes | The raw key. x-shield-api-key is accepted as a legacy alias. |
x-pb-tenant-id | usually | Required wherever the tenant is not in the path or the body. A mismatch between header and path answers 403. |
Content-Type: application/json | for POST | Bodies are parsed as JSON. |
What the server does. It SHA-256 hashes the incoming key and looks for an active row with that hash under the tenant you named. No match is 401 Invalid API key with x-pb-reason: invalid-key. On a match it checks the endpoint's required scope, then the per-tenant quota where the endpoint enforces one, then updates last_used_at without blocking the response.
Sandbox keys. A pb_test_ key sets is_sandbox on the row. Its signals are stored with test_mode true, are never metered, and its outbound playbook actions are recorded as skipped_sandbox rather than sent. Sandbox keys and x-pb-test-mode requests share one allotment of 1,000 signals per rolling 30 days per tenant. The hosted passkey relying party refuses sandbox keys outright.
Confirm it worked. Dashboard > API keys lists each key by label with the first 16 characters of the key, a Test badge for sandbox keys, the creation date and the last-used date. A key that has authenticated at least once shows a last-used date within a minute of the call.
Scopes on a key
Each key carries an array of scopes. An endpoint that requires one refuses a key without it:
HTTP/1.1 403 Forbidden
x-pb-reason: missing-scope
{ "error": "API key missing 'vc_issue' scope" }When several scopes would satisfy the endpoint, the message joins them with or. The admin scope satisfies every check and is deliberately not mintable.
| Scope | What it gates |
|---|---|
ingest | Signal ingest, BLAST tunnel lifecycle, predictive ladder adjudication |
events | Read lane on tenant metrics |
metrics | Read lane on tenant metrics |
vc_issue | Credential issuance and OpenID4VCI offers |
vc_verify | Presentation verification and OpenID4VP request building |
vc_revoke | Credential revoke, suspend, reinstate and status-list rebuild |
spatial | Atomic fingerprint enrolment and verification |
scim | SCIM v2 provisioning, sent as a bearer token |
okta_hooks | Okta event hook consumption, sent as a bearer token |
dpop | DPoP binding, verification and nonce issue |
supply_chain | Build provenance and SBOM ingestion |
passkey_rp | Hosted passkey ceremonies |
passkey_rp_import | Bulk passkey credential import |
receipts | Entropy provenance receipts |
Two of these reach exactly one endpoint. events and metrics are checked by the Prometheus exposition on shield-metrics, which a monitoring agent scrapes at https://api.passkeybridge.io/v1/shield-metrics?action=prometheus. Neither scope opens the JSON actions behind the Observability tab, which stay on the dashboard session lane. Event history is read from Dashboard > Events. Until 2026-09-17 the function was absent from the public worker allowlist, so both scopes were mintable and granted access to nothing.
Retired names. Until 2026-09-15 the wizard also offered playbooks, credentials, agents and shadow. No function ever checked them. Existing keys that carry one keep working, and the name grants nothing; new keys cannot be minted with them.
The full enforcement table, function by function, is in Scopes and permissions.
Dashboard session tokens
The dashboard authenticates with the signed-in user's Supabase session token.
Authorization: Bearer <supabase session access token>The function validates the token with the anon client (never the service-role client, which would bypass row-level security and accept tokens differently), reads the user id, and calls is_tenant_admin(user_id, tenant_id). A user who is not an admin of that tenant is refused with 403 and x-pb-reason: not-tenant-admin.
This lane holds the `admin` scope. Scopes exist to bound what a long-lived key can do, and a key may be pasted into a build server the tenant does not fully control. An interactively authenticated tenant admin is a different principal: they are the party who mints and revokes those keys. Bounding them by a key's scope list would be theatre, so the shared module treats them as holding admin, which satisfies every requirement. This is not a way around the friction barrier on the admin scope, which is about keys: admin cannot be minted onto a credential, and acting through the console with a short-lived token is the supervised path that barrier funnels people into. The grant is scoped to the one tenant the check just passed.
`x-pb-test-mode: true` forces this lane on endpoints that accept both, even when a key is also present. Signal ingest uses it for the dashboard's test signal, and marks the resulting event test_mode.
When to use each lane.
| Situation | Lane |
|---|---|
| Server-to-server integration | API key with the narrowest scope |
| Dashboard action taken by a person | session token, automatically |
| Endpoints with no key lane at all | session token: cross-reference, shadow proxy, cached proofs, agent delegate lifecycle, A2A handshake, cascade |
| Trying an endpoint before minting a key | session token via the dashboard |
Session tokens expire on their own schedule and are invalidated by signing out. There is no way to present one from a server integration, and no reason to try.
Signature verification
Two signing paths exist, and they share one secret per tenant.
Inbound: signals you send us. Sign the raw request body with HMAC-SHA-256 under the tenant's callback signing secret and send the hex digest in x-pb-signature (x-shield-signature is accepted as an alias).
The policy is:
- The header is always verified when present. If it is present and the tenant has no secret configured, the request is refused with
401 Invalid webhook signature, because there is nothing it could verify against. - The header is required only when the tenant opts in. Settings > "Require signed signals" writes
require_signed_ingest. With it on, a request without the header answers401 Webhook signature required for this tenant (x-pb-signature header missing)andx-pb-reason: signature-required. - A rotation has a 24-hour grace period. The previous secret keeps verifying for 24 hours after
rotate_signing_secretruns, so you can roll the secret through your fleet without a flag day.
The public host forwards this header. x-pb-signature and x-shield-signature are both on the Cloudflare worker's forward list, so a signed request sent to api.passkeybridge.io reaches the function with its signature intact, and a tenant with require_signed_ingest on can work through the public host. A header outside that list is dropped before the function runs; the full list is in Edge-first signal verification.
Outbound: callbacks we send you. A webhook_callback playbook action POSTs a JSON envelope to your callback URL and signs it with the same secret, as x-pb-signature. Verify it by recomputing the HMAC over the exact raw body you received.
POST /your/callback
Content-Type: application/json
x-pb-signature: 4f1c...
{ "event": "sim_swap", "phone_hash": "6b2f...", "subject_ref": "acct_8412",
"tenant_id": "...", "timestamp": "2026-09-16T10:00:00.000Z", "metadata": { ... } }Raw identifiers are stripped from the envelope before it is sent. A retried delivery additionally carries detached post-quantum headers that the first attempt does not, because the retry is performed by a separate worker.
Rotate the secret from Settings > Callback signing secret > "Rotate secret". The new value is shown once. Both secrets verify inbound requests for the next 24 hours; outbound callbacks are signed with the new secret immediately.
Rate limits on authenticated calls
Authenticated requests are subject to the same ceilings as everything else. Two are worth knowing at the point you write the client.
Per-tenant quota. Most key-authenticated endpoints check a per-tenant per-minute counter after the key validates and before the work runs. It is summed across every caller of the tenant, so it is the ceiling a fleet of workers shares: Enterprise 30,000, Pro 3,000, Starter 600 requests per minute, or an override set by support. Exceeding it answers 429 with x-pb-reason: quota-exceeded and the body {"error":"tenant_quota_exceeded","scope":"minute","limit":3000,"retry_after_seconds":60}.
Per-IP limit. Two layers keyed by function, tenant and a keyed digest of the source IP: an in-isolate window and a database-backed counter. The applied number is the tenant's rate_limit_per_minute when set, otherwise the plan default (Starter 60, Pro 300, Enterprise 600). Signal ingest uses a wider pre-authentication bucket of 1,200 per minute per IP so a tenant posting from one egress address is never held below its plan before the tenant is known.
Refusals carry Retry-After: 60, X-RateLimit-Limit, X-RateLimit-Remaining: 0 and, from the two-layer limiter, X-RateLimit-Layer. X-RateLimit-Remaining is always 0 on a refusal and there is no reset-timestamp header. X-RateLimit-Degraded: true means the distributed layer could not be consulted and the verdict was made per isolate.
One reporting defect to be aware of: the simple refusal helper hard-codes X-RateLimit-Limit: 60, so the pre-authentication refusal on signal ingest understates the 1,200 limit it actually applied. Retry-After is correct everywhere.
Plan numbers, the Cloudflare edge ceiling and the volume allotments are in Rate limits and quotas.
Rotation and revocation
API keys are not rotated in place. There is no rotation interval, grace period or automatic rotation for an API key; the platform has no such mechanism. Rotate by overlap:
- Dashboard > API keys > "Generate API key". Give it the same scopes and a label that identifies the new generation.
- Deploy the new key to your integration.
- Watch the old key's last-used date in Dashboard > API keys until it stops moving.
- Revoke the old key with the revoke control on its row.
Revocation sets is_active to false through the revoke_key action on shield-admin-mutations, which requires your admin role. It takes effect on the next request: the lookup filters on active rows. The row itself is kept, so the audit trail survives; there is no hard delete.
Tenant signing secrets do rotate, with a real grace period. Settings > Callback signing secret > "Rotate secret" generates a new secret, stores the outgoing one as the previous secret and stamps the rotation time. Inbound signatures verify against either for 24 hours; outbound callbacks are signed with the new secret at once. The response returns the new secret exactly once and it is never written to the audit log.
Tenant signing keys (the ECDSA key that signs credentials and attestations) are provisioned and rotated from the dashboard as well. Rotation derives the curve from the tenant's post-quantum tier, so an ML-DSA-87 tenant cannot be moved back to P-256 by a rotation.
A suggested cadence is every 90 days for a production key, and immediately on any suspicion of exposure. Nothing enforces it. Every provisioning and revocation writes an audit row carrying the key id, the display prefix, the scopes and the sandbox flag, never the key or its hash.
Security considerations
Keep keys server-side. An API key is a bearer credential for a whole tenant. Browser code should use the dashboard session lane instead; there is no browser-safe key.
Mint the narrowest scope that works. An ingest pipeline needs ingest and nothing else. A verifier needs vc_verify. A key limited this way cannot issue credentials or provision users even if it leaks.
Use a separate key per integration. Shared keys cannot be revoked independently, so one compromised consumer forces an outage on every other consumer.
Keep `admin` out of your application. It cannot be minted onto a key from the dashboard by design; that friction is the control. If you find yourself wanting it, the endpoint you are reaching for is almost certainly a console function with no public route.
Watch the last-used date. Dashboard > API keys shows it per key. A key that has not been used in weeks is either an abandoned integration or a credential nobody is watching. Revoke it.
Revoke on suspicion. Revocation is immediate and reversible only by minting a new key. That asymmetry is the right way round.
Verify outbound callback signatures. Recompute the HMAC over the exact raw body before you trust a callback. Without that check, anyone who learns your callback URL can post events to it.
Watch for `x-pb-scope-warning: legacy-unscoped`. It means the key predates scope enforcement and carries no scopes at all. It works today because it was minted before the cutoff; reissue it with explicit scopes.
Identifiers are hashed before storage. Phone numbers, email addresses, subject identifiers and IP addresses go through a keyed HMAC-SHA-256 under a server-held pepper. A digest you send us pre-hashed is keyed again under the pepper before it is stored, so the stored value is not the digest you computed; the envelope you receive and the DSAR endpoint still use your digest. Send the raw value over TLS when you can: a digest keyed from the number correlates with hosted carrier lookups, and a digest you computed does not.