The report was "the dashboard has a CORS problem." The console agreed: every request to the API was blocked, No 'Access-Control-Allow-Origin' header is present on the requested resource. The obvious move is to go and look at the CORS configuration.
The CORS configuration was fine. The API was returning 502.
Why a 502 looks like CORS
CORS headers are set by the application. When a request never reaches the application โ because the reverse proxy in front of it has nothing healthy to forward to โ the proxy answers with its own 502, and that response carries no Access-Control-Allow-Origin, because nothing that knows about your allowed origins was ever involved.
The browser does not distinguish. It sees a cross-origin response without the header and reports a CORS violation, which is accurate and completely misleading. The layer named in the error is the one layer that was working.
Underneath, the API was crash-looping on getaddrinfo ENOTFOUND postgres: its database containers had been removed. That story is here. This post is about what we changed in CORS itself once the real fault was fixed โ because looking closely at it turned up something we did want to change.
Two questions that had been one
The old policy was an allowlist. The dashboard sends its session cookie cross-origin, so the API reflected Access-Control-Allow-Origin only for origins in SENTRINEL_ALLOWED_ORIGINS, and refused the rest. Correct, for the dashboard.
But the API is also called from places nobody can enumerate: a customer's application posting telemetry, a status page embedded somewhere, a script. Those were being refused too, with the same error, and refusing them protected nothing. A request carrying an API key is authorised by the key. An attacker with the key does not need a browser, and the browser's origin tells us nothing we rely on.
There are two different questions here, and one setting had been answering both:
- May this origin call the API at all? โ Yes. Any origin.
- May it do so with the session cookie? โ Only if it is allowlisted.
The cookie is the part that matters. Whatever origin is allowed to send it can read authenticated responses as a signed-in user. That stays on the allowlist. Everything else is open.
How that is wired
The CORS plugin can only add Access-Control-Allow-Credentials to every response or to none, so the credentials header is added by us, and only for allowlisted origins. A foreign origin gets Allow-Origin and no credentials: the browser lets the request through, and cookies are neither sent nor readable. The dashboard's origin gets both, and keeps working.
The ordering is load-bearing. The plugin answers a preflight from inside its own onRequest hook and returns immediately, so a header added after it never reaches an OPTIONS response โ and a preflight without Allow-Credentials fails every credentialed request the dashboard makes. Our hook is registered first, so the header is already on the response when the plugin's early return happens.
That is exactly the sort of thing a refactor breaks silently, so it is extracted into its own module and there is a test that fails if the hooks are reordered โ one for the response, and a separate one for the preflight, because the preflight is the case a reordering would break first.
What to check first next time
If the dashboard reports CORS and the API is behind a proxy: curl /health before touching the origin list. A 502 has no CORS headers either. The error names the layer that answered last, not the layer that failed.
The setting, and what it now governs, is in the deploy guide.