Our domain registry can stop answering. It can also answer that a customer's domain is no longer allowed. Both cases leave the proxy without a usable current list, but only the first gives us a reason to reuse an older one.

That distinction shaped our per-tenant CORS cache. We keep valid results for 15 minutes and a fallback copy for 24 hours. An authoritative refusal erases that fallback, so the next outage cannot bring a removed domain back.

One domain per customer

At GoodBarber, the web version of each customer app has its own domain, either a platform subdomain or a custom one. We are piloting a proxy that lets widgets call third-party APIs while their private keys stay on the server. Its CORS policy has to fit that domain model, even while the service itself is limited to a small pilot.

Our first static allowlist was manageable for five pilot apps. It could not follow the platform's thousands of domains as customers changed them. Allowing arbitrary origins would also defeat the restriction we wanted; PortSwigger documents that mistake in its guide to CORS misconfigurations. We needed to resolve the list for each tenant.

The preflight does not know who is calling

An actual API call carries a short-lived token naming the tenant. Its preflight arrives earlier, without that token: CORS preflight requests do not carry credentials. We receive an origin, a proposed method and a list of header names, so the token cannot supply the tenant for this lookup.

The request path can. It contains an opaque connector handle belonging to one tenant. We resolve the handle, then the tenant's domains. Because this happens before token validation, the lookup must also tolerate requests from arbitrary callers.

Origins, exactly as a browser sends them

The registry returns a domain. A browser sends an origin, and the comparison is string equality, so the list has to be built the way a browser would write it.

  • The default port for the scheme is omitted: :443 for HTTPS, :80 for HTTP. Non-default ports are kept.
  • A custom domain yields two origins, with and without www, since customers publish both. A subdomain of ours yields exactly one.
  • An internationalised domain is converted to punycode, which is what the browser sends.
  • The response carries the one matching origin and Vary: Origin, as MDN recommends for an explicit origin. Never *, and Origin: null never matches.

A miss needs a reason

Once the main cache entry expires, the registry lookup can produce three different outcomes:

The registry… What the proxy serves What it stores
answers with domains those origins the list for 15 minutes, and a second copy for 24 hours
cannot be reached, answers an error or sends garbage the 24-hour copy, if there is one the main entry again, for 60 seconds
answers clearly that the tenant has no usable domain no CORS headers an empty list for 60 seconds, and it deletes the 24-hour copy

The outage path borrows the idea of stale-if-error. Apps can keep using a previously accepted origin while the registry is unavailable. Caching that fallback decision for 60 seconds reduces repeated lookups; it does not by itself guarantee a global request-rate bound when concurrent misses occur.

An explicit removal has a different meaning. If we kept its old list, a later registry outage could restore the revoked origin from the 24-hour copy. Deleting that copy makes the refusal survive the outage too.

The pre-auth path is bounded

Before any I/O, we check the handle's shape and hash it for use in cache keys. Unknown handles are cached as unknown for 60 seconds. If the shared cache is unavailable, an in-process cache lasting 5 seconds, with at most 4,096 entries, limits repeated database work.

The preflight path responds with 204. If neither the lookup nor its permitted fallback yields an allowed origin, the response omits CORS headers. Dependency errors therefore do not become a 500, and the browser cannot proceed with the cross-origin call after an unsuccessful preflight.

Treat your cache as input

The cache is shared infrastructure, so what comes out of it is validated like anything else that crosses a boundary. Values are JSON, never pickled objects. Every list read back is checked, item by item, against the same pattern that judged it on the way in. A value that exists but fails that check is ignored and logged as a tampering signal.

Review found a mismatch here. Normalisation accepted a hostname containing an underscore, but cache validation used a stricter rule and rejected it. That value would have been repeatedly written, discarded and fetched again, leaving both caches ineffective for the tenant. Applying the same rule on write and read removed the disagreement.

CORS is not the lock

These responses govern browser access. A client such as curl does not enforce them, so actual requests still undergo token validation and the connector's permission checks. Using an older CORS list leaves those checks in place; it changes which browser origins may read the responses.

The feature and its hardening landed on July 23, six minutes apart: the second commit is the adversarial review of the first. The module has 48 tests: normalisation, the three outcomes, the bounds of the pre-auth path, and the few static origins we add on top.

To test the distinction, cache an allowed domain, have the registry explicitly remove it, then make the registry unavailable. The domain should remain absent. Testing an outage alone would miss the case where a cache reverses a decision it had already received.