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:

  • GET requests worked in some cases
  • POST, PATCH, and DELETE failed mysteriously
  • requests with cookies failed
  • preflight OPTIONS responses 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:

  1. allowedOrigins: ['*'] doesn’t work with credentials
  2. 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+json
  • Authorization
  • X-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:

  1. a real browser request
  2. a real preflight OPTIONS request
  3. a cache/proxy check to confirm Vary: Origin
  4. 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: Origin when 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.