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:
:443for HTTPS,:80for 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*, andOrigin: nullnever 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.
