Engineering · 2026-04-05
Edge-First Identity Verification: Moving Carrier Signal Processing to the CDN Layer
By J. W. Bouckaert
Signature checks at the origin
When a signal reaches an ingest endpoint, such as a SIM swap or port-out event forwarded by a tenant, the platform has to decide whether the signal is authentic before it acts on it. In most deployments, including PasskeyBridge's today, that check runs at the origin: the request crosses the CDN, reaches the function that handles ingest, and the function verifies the signature after it has looked up the tenant.
This article looks at moving the stateless part of that check to the CDN layer, so that malformed or forged requests are refused at the point of presence (PoP) nearest the sender. It is a design proposal. PasskeyBridge has not built it, and the article publishes no latency figure for it.
The edge runtime
Cloudflare Workers run JavaScript and TypeScript in V8 isolates on Cloudflare's network, which Cloudflare lists as more than 330 cities. The Workers limits that matter here:
| Limit | Workers Free | Workers Paid |
|---|---|---|
| CPU time per HTTP request | 10 ms | 30 seconds by default, configurable up to 5 minutes |
| Memory per isolate | 128 MB | 128 MB |
| Subrequests per invocation | 50 | 10,000 by default |
Work after the response with ctx.waitUntil() | Up to 30 seconds | Up to 30 seconds |
CPU time excludes time spent waiting on network requests. Workers include the Web Crypto API, so crypto.subtle.verify() can check an HMAC-SHA-256 signature without a database or a network call, given the message, the signature and the key.
PasskeyBridge's ingest path today
Every public API call goes to api.passkeybridge.io, which a Cloudflare Worker serves as a reverse proxy. The Worker:
- forwards only an allowlist of functions and request headers, including
x-pb-signatureand the idempotency key headers; - strips the caller's IP address, User-Agent and Cloudflare geolocation headers before the request leaves the Worker, and replaces the address with an HMAC-SHA-256 digest used for rate limiting;
- adds a shared secret that each function checks. A request sent to a function directly, without the proxy, is refused with 403.
The ingest function then, in order:
- reads the body, up to 64 KB (larger bodies get 413);
- parses the JSON (400 on failure) and validates it with a Zod schema (422 on failure);
- validates the optional idempotency key;
- resolves the tenant from the API key and checks the key's
ingestscope; - verifies the signature, if one is present or the tenant requires it;
- keys the phone number with HMAC-SHA-256 under a server-held pepper, matches playbooks and runs their actions.
Signature verification comes after tenant resolution because each tenant has its own signing secret, held encrypted in the database.
Signature format
The x-pb-signature header takes the form t=<unix seconds>,v1=<hex>, where the hex value is the HMAC-SHA-256 of the timestamp, a period and the raw body. A timestamped signature is refused outside a window of plus or minus 300 seconds. A bare hex HMAC of the body alone is still accepted for tenants that have not required the timestamped form, until 31 January 2027.
Signatures are optional unless the tenant turns on signed ingest. A header that is present is always verified. After a secret rotation, the previous secret keeps verifying for 24 hours. A signal that names a registered source must be signed with that source's own secret, and a revoked source is refused.
Retries
Ingest accepts an optional idempotency key (x-pb-idempotency-key or Idempotency-Key, up to 128 characters). The key is claimed before any side effect and the outcome is kept for 24 hours, so a retried request returns the stored answer instead of running the actions again.
Checks that could move to the edge
| Check | Needs state | Candidate for the edge |
|---|---|---|
| Body size limit | No | Yes |
| JSON parse and schema validation | No | Yes |
| Timestamp window on a signed request | No | Yes |
| HMAC verification | The tenant's signing secret | Only if the secret is available at the edge |
| API key resolution and scope check | Key records | No |
| Idempotency claim | Shared, strongly consistent store | No |
| Playbook matching and actions | Tenant configuration | No |
| Audit log write and usage metering | Database | No |
The first three checks are pure computation on the request. Moving them to the edge refuses malformed and expired requests before they reach the origin. The HMAC check is pure computation too, but it needs the tenant's secret, which is the hard part.
Key distribution
To verify at the edge, the Worker needs each tenant's current signing secret, and the previous one during the 24-hour grace period after a rotation. The options:
- Worker secrets. Secrets are encrypted environment variables. They suit a small, fixed set of keys. They do not suit one secret per tenant: a Worker holds at most 64 variables on the Free plan and 128 on Paid, and a change takes a new deployment.
- Workers KV. KV is eventually consistent. Cloudflare states that changes "may take up to 60 seconds or more to be visible" in other locations. A rotated or revoked secret could keep verifying at some PoPs for that long.
- A fetch from the origin with a short cache. The Worker asks the origin for the tenant's key material and caches it briefly. This keeps revocation close to the origin's view, at the cost of an origin call on a cache miss.
Any of these copies tenant secrets to more places than the origin database. That widens the set of systems that must protect them, and it needs a decision on whether the edge copy is the raw secret or something derived from it.
Replay protection
The timestamp window limits how long a captured signed request stays valid. It does not stop a replay inside the window. PasskeyBridge handles retries with the idempotency key, which needs a strongly consistent store and stays at the origin. Durable Objects could hold such state at the edge; KV could not, because of its propagation delay.
Audit records
A SOC 2 audit looks for a reliable record of security decisions. At the origin, the decision and its audit row are written by the same function. If the edge refuses a request, the origin never sees it, so the refusal is recorded only if the Worker records it.
ctx.waitUntil() lets a Worker continue work after it responds, for up to 30 seconds. Cloudflare also notes that crashes, resource pressure and runtime updates can interrupt work. It is not a delivery guarantee. An edge refusal that must appear in the audit log needs a durable path, such as a queue the origin drains, plus a check that compares counts on both sides.
Example Worker
This sketch implements the stateless checks and the HMAC check for a single shared secret. A per-tenant deployment would replace env.SIGNING_SECRET with a key lookup.
// Edge signal gate (sketch). Checks size, JSON, signature and timestamp,
// then forwards the original request body to the origin.
const MAX_BODY_BYTES = 64 * 1024;
const MAX_SKEW_SECONDS = 300;
const SIG_RE = /^t=(\d{1,12}),v1=([a-f0-9]{64})$/;
function hexToBytes(hex) {
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
async function verifySignature(secret, timestamp, body, macHex) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw", enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false, ["verify"]
);
// crypto.subtle.verify compares in constant time.
return crypto.subtle.verify("HMAC", key, hexToBytes(macHex), enc.encode(timestamp + "." + body));
}
function reject(status, message) {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { "Content-Type": "application/json" },
});
}
export default {
async fetch(request, env) {
if (request.method !== "POST") return reject(405, "Method not allowed");
const body = await request.text();
if (new TextEncoder().encode(body).length > MAX_BODY_BYTES) {
return reject(413, "Request body too large");
}
try {
JSON.parse(body);
} catch {
return reject(400, "Invalid JSON body");
}
const match = SIG_RE.exec(request.headers.get("x-pb-signature") || "");
if (!match) return reject(401, "Missing or malformed signature");
const timestamp = match[1];
const skew = Math.abs(Date.now() / 1000 - Number(timestamp));
if (skew > MAX_SKEW_SECONDS) return reject(401, "Signature timestamp outside window");
const valid = await verifySignature(env.SIGNING_SECRET, timestamp, body, match[2]);
if (!valid) return reject(401, "Invalid signature");
// The origin repeats every check. The edge only refuses early.
return fetch(env.ORIGIN_URL, {
method: "POST",
headers: request.headers,
body,
});
},
};
The Worker reads the body once as text, so the bytes it verifies are the bytes it forwards. A production version would also enforce the size limit before reading the whole body, run the full schema check, and forward only an allowlist of headers, as PasskeyBridge's proxy does.
Trusting the edge decision
If the origin skipped its own checks because the edge had passed a request, an attacker who reached the origin directly could claim to be the edge. Three ways to bind the two:
- A shared secret header, checked in constant time by the origin. This is what PasskeyBridge's proxy and functions use today.
- Service Bindings, which connect two Workers on the same Cloudflare account without a public route. They apply only when the origin is also a Worker.
- Mutual TLS between the edge and the origin, with a client certificate the origin validates.
The simpler design keeps the origin checks in place and uses the edge only to refuse requests early. Then the edge does not need to be trusted for anything except the refusals it makes.
Cases for keeping verification at the origin
- Correlation across signals. A playbook that looks at recent signal history needs the database.
- Per-tenant secrets with fast revocation. If a rotated or revoked secret must stop verifying at once, the origin is the one place that has the current value.
- Data residency. A tenant that needs processing in one jurisdiction may not accept a global edge. Cloudflare's Regional Services restrict where traffic is decrypted and processed, at the cost of fewer locations.
- Low volume. If few forged or malformed requests arrive, refusing them at the edge saves little origin work.
Summary
Body size, JSON shape and the timestamp window can be checked at the edge with no state. The HMAC check can move only with a plan for distributing tenant secrets, and the idempotency claim, playbooks, audit writes and metering stay at the origin. Keeping the origin checks in place, with the edge refusing requests early, gains most of the benefit without a new trust relationship.
Read about designing a playbook engine Read about SOC 2 controls for identity APIs Get started with PasskeyBridge