The console says CORS. curl says 200.
CORS errors are blocking your frontend from your own API
Your backend works. curl gets a perfect response, but the browser console says blocked by CORS policy and the app renders nothing. CORS is not a server bug: it is the browser blocking cross-origin traffic until the server says your frontend's origin is allowed. A simple GET still reaches your server, and only the response is withheld. Anything that needs a preflight is different: if the preflight fails, the real request is never sent, so it never appears in your server logs. The fix AI tools reach for, allow everything, fails against the spec or strips real protection.
Fixed £4,500 · 3 days · pass-or-refund · next slot w/c 3 August
What actually breaks
- The preflight arrives with no credentials, and your auth rejects itBefore a cross-origin PUT, DELETE, or any request carrying an Authorization header, the browser sends its own OPTIONS request first. That preflight carries no cookies and no Authorization header, by design. If your auth middleware runs on every route, it sees an anonymous OPTIONS request, returns 401, and the browser abandons the exchange. The spec requires the preflight response to be a 2xx, so answer OPTIONS with the CORS headers before any auth check runs.
- A wildcard origin with credentials, rejected or reflectedSetting Access-Control-Allow-Origin to * looks like the quick fix, but browsers reject a wildcard origin on any credentialed request; the spec forbids the combination. The workaround AI tools then produce is reflecting whatever Origin arrives straight back. That passes the browser check, and it means any website can make authenticated calls to your API from your users' browsers, riding their cookies. CORS exists to stop exactly that. Name your origins, and never reflect them with credentials on.
- A 500 dressed up as a CORS errorCross-origin JavaScript only gets to read a response if the CORS headers are on it, and those headers are usually attached by your handler or middleware. When the handler crashes, the error response often comes from somewhere else entirely: a platform error page, a gateway timeout, a dead serverless function. No handler, no headers, so the browser reports a CORS error instead of the 500 underneath. Founders burn days on origin lists when the network tab shows the real status the whole time.
- Origin matching is exact, and a redirect cannot fix itThe browser compares scheme, host and port exactly. https against http fails, www against the bare domain fails, and a trailing slash pasted into a config value fails, because an Origin header never has one. The redirect that normally papers over www and http differences makes things worse here: the spec requires a preflight response with an ok status, and a 301 is not one, so a redirect in front of the API fails the preflight in every browser. Put exact per-environment origins in configuration.
- The session cookie travels in one browser and not anotherWhen the frontend and API sit on different sites, every API call is cross-site. In Chrome and Edge, a cookie with no SameSite attribute is treated as Lax and left out of cross-site fetch calls; Firefox does not apply that default. So the same app can keep you signed in on Firefox and arrive silently unauthenticated in Chrome. To travel everywhere, the cookie needs SameSite=None plus Secure, the fetch needs credentials set to include, and the response needs Access-Control-Allow-Credentials against a named origin.
- The wildcard that never covers AuthorizationThe API worked until users logged in. Adding an Authorization header makes a request non-simple, so it now triggers a preflight, and the preflight response must list Authorization in Access-Control-Allow-Headers by name. The spec is explicit that the * wildcard never covers Authorization, and on credentialed requests * is read as a literal header name with no special meaning at all. So a header allowlist that looked maximally permissive still blocks every authenticated call your frontend makes.
Why every failure looks identical in the console
The browser deliberately tells your JavaScript almost nothing when CORS fails; the detail is withheld for security, so six different server problems collapse into one console message. That is why guessing at fixes goes so badly. Diagnosis happens in the network tab: find the OPTIONS request and its status, read which Access-Control headers actually came back, compare the Origin the browser sent against what the server allows, and check whether the cookie was ever attached. We trace each blocked request to its specific cause, move origin lists into per-environment configuration, answer preflights before auth runs, keep CORS headers on error responses, and enable credentialed CORS only where the design needs it.
What “done” looks like
We agree one of these in writing before you pay anything. If it passes, the sprint passed. If it doesn't, you get a full refund and keep the work.
- Cross-origin requests succeed from the production domain, with allowed origins set per environment in configuration, not hardcoded strings.
- Preflight OPTIONS requests return 2xx with the right headers before any auth check, verified in the network tab.
- Error responses carry CORS headers too, so a 500 appears as a 500 in your frontend, not as a CORS error hiding the real bug.
- Credentialed requests work deliberately: SameSite=None with Secure where cookies must cross sites, against a named origin, or the API moved onto the same site.
We agree the acceptance test before any work starts. If it doesn't pass within three working days, you get a full refund and keep every change I've committed. The risk is mine, not yours.