The support ticket arrives with a screenshot: « CORS blocks our API ». The frontend developer insists the backend « open CORS to * ». Meanwhile curl without an Origin header works fine. The confusion costs days because CORS is treated as a server lock — when it is a browser policy controlling who may read the JavaScript response.
CORS does not protect your API from direct attacks: curl, Postman, server scripts, and native mobile apps ignore CORS. Authentication (JWT, session, mTLS) remains mandatory. CORS answers one question: may https://app.example.com expose the JSON response to its JavaScript?
Recurring incidents come from preflight OPTIONS returning 401, * incompatible with cookies, or duplicate CORS headers between nginx and the framework. Here is how to structure a clear policy without opening the door to the entire web.
Preflight OPTIONS and methods
« Non-simple » requests (JSON POST, custom headers, PUT/DELETE) trigger an OPTIONS preflight. The browser sends Origin, Access-Control-Request-Method, sometimes Access-Control-Request-Headers. The server must respond 204/200 with CORS headers before the real request.
Classic mistake: auth middleware treats OPTIONS like POST and returns 401. The browser shows an opaque CORS error — developers blame CORS, the cause is auth.
Exclude OPTIONS from auth on public API routes. Test with:
curl -X OPTIONS -H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-i https://api.example.com/v1/resource
Origins, credentials, and Vary
| Scenario | Access-Control-Allow-Origin | Credentials |
|---|---|---|
| Public API without cookie | exact origin or list | false |
| SPA with session cookie | whitelisted exact origin | true + Allow-Credentials |
* with cookies | forbidden by spec | browser failure |
With Access-Control-Allow-Credentials: true, return the exact request origin if whitelisted — never *.
Add Vary: Origin on dynamic CORS responses so CDNs do not cache the wrong origin.
One layer emits headers
nginx and Express (or Laravel, Django) both adding Access-Control-Allow-Origin produce duplicate headers — some browsers fail intermittently, the worst production bug class.
Choose: CORS at the edge (nginx/API gateway) or in the application — not both. Document in architecture ADR.
Annual audit: grep nginx config + middleware code — single source of truth.
CORS ≠ API security
An attacker forges a request from their server without a browser Origin — CORS does not apply. Rate limiting, auth, input validation, WAF remain necessary.
Short internal doc: « Native mobile apps do not need CORS » — reduces false backend tickets.
OpenAPI: list allowed origins per environment and copy-paste preflight curl example for partners.
Testing and observability
Partner Postman collection maintained in CI — CORS regression caught pre-merge.
Metrics: OPTIONS 204 vs 401 ratio, unknown origins rejected (aggregated log without PII).
On incident: reproduce with curl preflight first, then browser with cache disabled — avoids false Chrome extension leads.
API gateway review
Annual audit: only one layer emits CORS headers. Duplicate headers from nginx plus app middleware break browsers intermittently — worst production bug class.
Preflight OPTIONS must return quickly — timeout here looks like CORS failure to developer but is server slowness.
Document that mobile native apps do not need CORS — reduces false security tickets to backend team.
Operational follow-up
CORS documented in OpenAPI cuts partner support tickets — worthwhile investment. Copy-paste preflight curl example per environment. Partner Postman collection in CI — CORS regression caught pre-merge. Document gaps between host marketing and field measurement in the quarterly review.
Quarterly follow-up
CORS documented in OpenAPI cuts partner support tickets — worthwhile investment. Copy-paste preflight curl example per environment. Partner Postman collection in CI — CORS regression caught pre-merge. Document gaps between host marketing and field measurement in the quarterly review.
Decide and move forward without blind spots
- Reproduce preflight curl — OPTIONS with Origin and Access-Control-Request-Method before touching nginx.
- Explicit origin whitelist — never arbitrary reflect;
Vary: Originif CDN. - Skip auth on OPTIONS — dedicated middleware; 401 on preflight = classic ticket.
- Partner integration kit — exact Origin, credentials, fetch example, 401 vs browser CORS.
- Independent API auth — JWT, rate limit, CSRF if cookies; CORS is not the server lock.
Cross-domain API: comparison tool, directory, backend blog.
Frequently asked questions
Does CORS protect the API from attacks?
No: it tells the browser which origins may read the response. curl, native mobile, and server scripts ignore CORS — API authentication remains mandatory.
Why does * fail with cookies?
The spec forbids Access-Control-Allow-Origin: * when Access-Control-Allow-Credentials: true. You must return the exact request origin from a whitelist.
What if OPTIONS returns 401?
Auth middleware blocks preflight. Exclude OPTIONS from auth — a frequent cause of « broken CORS » tickets.
How to test without the browser?
Reproduce with curl -X OPTIONS -H Origin:https://app.example.com -H 'Access-Control-Request-Method: POST' on the API URL.
Document allowed origins in partner onboarding — fewer Friday night emergency deploys.
