A few years ago I helped untangle a Drupal setup that looked fine in local dev and completely fell apart in production.
The stack was common enough:
- Drupal serving
JSON:API - a separate frontend on another origin
- authenticated requests for logged-in users
- some custom headers from the frontend
- a CDN and reverse proxy in front of Drupal
Everybody thought “CORS is enabled” meant the problem was solved. It wasn’t.
The symptoms were classic:
GETrequests worked in some casesPOST,PATCH, andDELETEfailed mysteriously- requests with cookies failed
- preflight
OPTIONSresponses were inconsistent - one environment returned
Access-Control-Allow-Origin: *, another returned nothing
The annoying part about CORS is that the backend can be functionally correct and still be unusable from a browser. Curl says the API works. The browser says no.
The setup
The frontend lived at:
https://app.example.com
Drupal JSON:API lived at:
https://cms.example.com
A frontend request looked like this:
fetch("https://cms.example.com/jsonapi/node/article", {
method: "GET",
credentials: "include",
headers: {
"Accept": "application/vnd.api+json"
}
});
For anonymous reads, things were mostly okay. For authenticated requests, the browser blocked the response.
Here was the first broken response header set we captured from production:
HTTP/2 200
content-type: application/vnd.api+json
access-control-allow-origin: *
access-control-allow-credentials: true
That combination is invalid for credentialed CORS. If you send cookies or HTTP auth, Access-Control-Allow-Origin cannot be *.
Browsers enforce this hard.
Before: the broken Drupal CORS config
The team had enabled CORS in Drupal’s services.yml, but the config was copied from a gist and never reviewed.
Something like this:
parameters:
cors.config:
enabled: true
allowedHeaders: ['*']
allowedMethods: ['*']
allowedOrigins: ['*']
exposedHeaders: false
maxAge: false
supportsCredentials: true
This is the kind of config that looks permissive and “easy,” but it creates two real problems:
allowedOrigins: ['*']doesn’t work with credentials- wildcard-heavy configs make debugging harder because behavior changes depending on the request path, proxy, and browser expectations
If your frontend uses session cookies, CSRF tokens, or authenticated requests in any form, you need explicit origins.
Why JSON:API made it worse
Drupal JSON:API often triggers preflight requests because the frontend sends headers like:
Content-Type: application/vnd.api+jsonAuthorizationX-CSRF-Token
A browser preflight looked like this:
OPTIONS /jsonapi/node/article HTTP/2
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type,x-csrf-token
And the broken server response was:
HTTP/2 204
access-control-allow-origin: *
access-control-allow-methods: *
Missing:
- a specific allowed origin
- clear allowed headers
- credentials support that actually matched the request
The browser then refused the real request before it even left the page.
The “works in curl” trap
The backend team kept testing with curl:
curl -i https://cms.example.com/jsonapi/node/article
That only proves the API endpoint exists.
To test CORS, you need to send an Origin header, and for preflight you need an actual OPTIONS request.
For example:
curl -i \
-X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type,x-csrf-token" \
https://cms.example.com/jsonapi/node/article
Once we did that, the problem was obvious.
After: a production-safe Drupal CORS config
We replaced the wildcard setup with explicit origins, methods, and headers.
parameters:
cors.config:
enabled: true
allowedOrigins:
- 'https://app.example.com'
- 'https://editor.example.com'
allowedMethods:
- 'GET'
- 'POST'
- 'PATCH'
- 'DELETE'
- 'OPTIONS'
allowedHeaders:
- 'Content-Type'
- 'Authorization'
- 'X-CSRF-Token'
- 'Accept'
- 'Origin'
exposedHeaders:
- 'ETag'
- 'Link'
- 'Location'
maxAge: 86400
supportsCredentials: true
That fixed the browser side immediately.
A correct response for a credentialed request now looked like this:
HTTP/2 200
access-control-allow-origin: https://app.example.com
access-control-allow-credentials: true
access-control-expose-headers: ETag, Link, Location
vary: Origin
content-type: application/vnd.api+json
And the preflight response became:
HTTP/2 204
access-control-allow-origin: https://app.example.com
access-control-allow-credentials: true
access-control-allow-methods: GET, POST, PATCH, DELETE, OPTIONS
access-control-allow-headers: Content-Type, Authorization, X-CSRF-Token, Accept, Origin
access-control-max-age: 86400
vary: Origin
That Vary: Origin matters more than people think. If you have caching layers in front of Drupal, you do not want one origin’s CORS response cached and served to another origin.
The header exposure mistake
The frontend also needed access to pagination and caching metadata. By default, JavaScript can’t read every response header in a cross-origin response.
That surprised the team because the network tab showed the headers just fine. The browser devtools can show them, but your frontend JavaScript still can’t access them unless they’re exposed.
The fix was to use Access-Control-Expose-Headers.
GitHub’s API is a good real-world example of this. A real response from api.github.com includes:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
That’s what a mature API looks like: expose the headers clients actually need.
For Drupal JSON:API, we kept it narrower:
exposedHeaders:
- 'ETag'
- 'Link'
- 'Location'
And the frontend could finally do this:
const res = await fetch("https://cms.example.com/jsonapi/node/article", {
credentials: "include",
headers: {
"Accept": "application/vnd.api+json"
}
});
console.log(res.headers.get("ETag"));
console.log(res.headers.get("Link"));
Before that change, both calls returned null.
Another production gotcha: proxy interference
Drupal wasn’t the only thing setting headers.
The reverse proxy had a fallback rule that appended:
Access-Control-Allow-Origin: *
So even when Drupal returned the correct explicit origin, the final response sometimes had conflicting headers. Browsers hate ambiguity here.
We removed CORS handling from the proxy for JSON:API routes and let Drupal own it end-to-end.
My rule of thumb: pick one layer to manage CORS unless you have a very deliberate edge strategy. Split ownership creates ghost bugs.
Before and after in browser code
Before
fetch("https://cms.example.com/jsonapi/node/article", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/vnd.api+json",
"X-CSRF-Token": token
},
body: JSON.stringify(payload)
});
Browser result:
Access to fetch at 'https://cms.example.com/jsonapi/node/article' from origin 'https://app.example.com' has been blocked by CORS policy
After
Same frontend code. No hack, no proxy workaround, no browser extension.
What changed was the response contract:
- explicit origin
- credentials support
- matching allowed headers
- matching allowed methods
- exposed response headers
Vary: Origin
That’s how CORS is supposed to work.
The config I’d use today
For a typical Drupal JSON:API deployment with a separate frontend, I’d start here:
parameters:
cors.config:
enabled: true
allowedOrigins:
- 'https://app.example.com'
allowedMethods:
- 'GET'
- 'POST'
- 'PATCH'
- 'DELETE'
- 'OPTIONS'
allowedHeaders:
- 'Content-Type'
- 'Authorization'
- 'X-CSRF-Token'
- 'Accept'
- 'Origin'
exposedHeaders:
- 'ETag'
- 'Link'
- 'Location'
maxAge: 86400
supportsCredentials: true
Then I’d verify it with:
- a real browser request
- a real preflight
OPTIONSrequest - a cache/proxy check to confirm
Vary: Origin - a frontend test that reads exposed headers
What actually fixed the incident
Not “turning CORS on.”
What fixed it was treating CORS as a strict API contract between browser and server.
The production checklist ended up being:
- no wildcard origin for credentialed requests
- explicit frontend origins
- explicit allowed request headers
- explicit allowed methods
- exposed response headers the app needs
Vary: Originwhen caching is involved- one layer responsible for CORS
- test preflight, not just normal requests
If you’re running Drupal JSON:API across origins, that’s the difference between an API that technically exists and one a browser can actually use.
For Drupal’s current configuration details, check the official Drupal documentation. For broader HTTP header hardening beyond CORS, including things like CSP, I’d also review your overall header policy carefully rather than treating CORS as a standalone fix.