Edge-First Signal Verification

What the Cloudflare Worker in front of api.passkeybridge.io does, which headers it forwards, and which checks run at the origin.

Last reviewed September 16, 2026Fresh

Overview

api.passkeybridge.io is a Cloudflare Worker in front of the Supabase edge functions. The split is deliberate and worth knowing before you debug a 401 or a 404: the Worker terminates TLS, applies the WAF and the per-IP rate limit, refuses function names it does not know, and forwards a fixed list of headers. Everything that touches a key, a secret or the database happens at the origin, in the function.

The Worker is stateless and carries no tenant material: it holds one shared secret that proves a request came through it, and nothing else. HMAC verification of an inbound signal, API key lookup, plan and quota checks, identifier hashing, playbook matching and the audit write all run at the origin in shield-ingest.

One consequence to plan around. A header has to be on the Worker's forward list to be usable at the public host; everything else is dropped before the function runs. The list carries eighteen names, x-pb-signature, x-shield-signature, dpop and if-none-match among them, so an inbound signature reaches the origin, an organization with Require signed signals switched on works through the public host, a DPoP proof is observed, and a conditional status-list read can answer 304. The full list is in the next section.

Shipped architecture

At the edge, in order:

  1. OPTIONS is answered 204 with the CORS headers.
  2. /robots.txt disallows everything, and every response carries X-Robots-Tag: noindex.
  3. / and /health return the Worker version and isolate-local proxy counters without calling upstream. /health?deep=1 makes a real proxied round trip to a harmless upstream function and reports which layer failed.
  4. /v1/_debug/echo answers at the edge with what arrived: the method, the path, whether an API key and an authorization header were present, the first 8 characters of the key, and the Cloudflare ray id. This is the only endpoint that echoes request shape, and it never proxies.
  5. The first path segment is checked against the public function allowlist. Anything else is 404 with x-pb-reason: unknown-function, without touching upstream.
  6. The allow-listed headers are copied onto a new request, the client IP is added as X-Forwarded-For and X-Real-IP, any client-supplied x-pb-edge-secret is deleted, and the real one is injected.
  7. The upstream response is returned with CORS and security headers, an x-pb-debug-id carrying the ray id, and, on a 401, 403 or 429 that arrived without one, x-pb-reason: upstream-auth. A transport failure is 502 with x-pb-reason: upstream-unreachable.

Headers the Worker forwards, eighteen in all: authorization, content-type, apikey, stripe-signature, x-client-info, x-pb-api-key, x-shield-api-key, x-pb-tenant-id, x-pb-test-mode, x-pb-signature, x-shield-signature, dpop, if-none-match, x-okta-verification-challenge and the four Supabase client-info headers. Anything else you send is dropped.

At the origin, shield-ingest refuses any request that did not carry the edge secret with 403 Direct access denied, then authenticates the key or the test-mode session, verifies a signature if one arrived, enforces the plan rate limit and the caps, hashes the identifier under the server-held pepper, matches one playbook, runs the hard or soft response and the actions, and writes the event and audit rows.

client -> Cloudflare Worker                -> shield-ingest
          TLS, WAF, per-IP limit,             edge-secret check, API key, scopes,
          allowlist, header forward,          signature, quota, hashing, playbook,
          edge-secret injection               revocation, actions, event, audit
          stateless, no tenant material       every key and every row

Operation placement today

OperationLayerNotes
TLS terminationCloudflareNearest data centre
WAF and bot mitigationCloudflareCodified in desired-state.json, applied by shield-waf-reconcile, drift-checked nightly
Per-IP rate limitingCloudflare1,200 requests per 60 seconds per source IP per data centre, then a 60-second block
Function allowlistCloudflare WorkerUnknown names 404 with x-pb-reason: unknown-function
Header filtering and edge-secret injectionCloudflare WorkerFixed forward list; client-supplied edge secrets are stripped
Edge-lock enforcementOrigin403 Direct access denied without the injected secret
API key lookup and scope checkOriginDigest lookup in shield_api_keys
Inbound HMAC verificationOriginAgainst the current secret, or the previous one for 24 hours after a rotation
Per-organization rate limitOriginTwo layers, plan limit per organization per source IP
Entitlement, monthly cap, sandbox ceilingOriginPlan, billing status and unmetered allowance
Identifier hashingOriginHMAC-SHA-256 under a server-held pepper
Playbook matching and actionsOriginOne playbook, actions in parallel
Hard and soft responseOriginRevocation or trust evaluation, before the actions
Event row and audit writeOriginshield_events and shield_audit_log

Replay protection is not on this path. Nonce-based replay checking belongs to the A2A handshake, which has its own nonce table and clock-drift rules. Ingest has no nonce: a signal replayed with the same body is accepted and stored again, and deduplication is your side's job, or the correlation id's.

Rule of thumb. If it needs a key, a secret, the plan or the database, it runs at the origin. The Worker's whole state is one shared secret and a per-isolate counter it reports on /health.

Measured latency

There is no published figure for this path in this guide, and that is deliberate: a number restated in several places is a number that goes stale in several places.

What you can measure yourself. The playground at /developers/playground calls the API from your own browser and reports what it observes, live. Whatever it shows is a measurement of your network path to the nearest Cloudflare data centre and back; it is no platform promise.

What the platform records per signal. Every event row carries two figures. decision_ms covers PasskeyBridge's own work: authentication, organization lookup, identifier hashing and playbook matching, up to the decision. response_ms covers the whole request, including outbound playbook actions to Slack, your webhook receiver and Resend. They are kept apart because conflating them made an organization's own chart degrade whenever a third party was slow.

Both are shown per event in Dashboard > Events, on the row and in the drawer, and the decision-latency distribution is available in the Observability tab. Read your own numbers there rather than inferring them from a published figure.

Audit log consistency

Verification and the audit write both happen at the origin, inside one request, so there is no window in which a signal has been verified but not recorded.

Every accepted signal writes a shield_events row, whether or not a playbook matched, and a shield_audit_log row with action signal.ingest carrying the playbook outcome, the action count, the failure count, the test-mode and sandbox flags and the correlation id. A hard signal writes a second audit row, parametric_revocation.triggered, with the revocation counts. Both are scoped to the organization by row-level security.

The correlation id is generated inside shield-ingest, returned in the response, stored in the event's metadata, written to the audit rows and forwarded to the trust engine and the intelligence worker as x-pb-correlation-id. One id therefore joins the verification, the playbook execution, the outbound deliveries and any later assessment.

Two writes are deliberately not in that guarantee. The usage counter and the audit row are written after the event row and only if it succeeded, so a failed insert answers 502 rather than billing for a signal it did not store. And webhook retries happen later, on the worker, so a delivery row can appear minutes after the event row it belongs to.

Moving verification to the edge

Verifying the inbound HMAC at the Cloudflare data centre would reject a forged signal before it costs an origin invocation. It is not shipped, and two things would have to exist first.

A way to get per-organization secrets to every data centre. The Worker holds no tenant material today, which is what makes it safe to treat as untrusted infrastructure. Distributing signing secrets to it means a push-and-revoke path, a rotation story that keeps the 24-hour grace window, and a compromise story for the edge itself.

A validator at the edge equal to the one at the origin. The signature is over the raw body, and the body is only meaningful after the schema check that runs in the function. Verifying a signature over a payload that the origin will then reject buys nothing.

Until then, the answer is: TLS, WAF and rate limiting at the edge; authentication, verification, tenancy and playbooks at the origin.

Limits of the edge layer

The edge stops volume and unknown paths. It cannot make the decisions that need state.

  • Anything about a specific organization. Plan, quota, monthly cap, API key scopes, signing secrets and the playbook set all live in the database, so the request has to reach the origin before any of them can be applied. A per-IP block at the edge is not a per-organization limit and does not replace one.
  • Correlating several signals. A response that depends on what else this organization has seen recently reads shield_events, which the Worker cannot query.
  • Anything carried in a header outside the forward list. A custom header of your own, and an inbound x-pb-correlation-id, are dropped before the function can see them. Signatures and DPoP proofs are on the list and do arrive.
  • Data residency. A globally distributed edge decides nothing about where a verification event is stored; that is a property of the origin and its database.

In each case the shape is the same today: the Worker terminates TLS, applies the WAF and the rate limit, and the origin makes the decision.

Related from the blog