When Drupal powers a decoupled frontend, mobile application, partner integration, or JavaScript-heavy API consumer, you will eventually meet CORS. The browser blocks the frontend, the network tab shows a mysterious preflight error, and the API works perfectly in Postman. That is the classic CORS moment.
CORS stands for Cross-Origin Resource Sharing. It is a browser security mechanism that controls whether JavaScript loaded from one origin can read responses from another origin. For Drupal APIs, CORS is usually configured in services.yml, and the safest setup depends on your authentication model, frontend domains, CDN, and deployment environments.

What CORS Is And Is Not
CORS is not an API firewall. It does not stop curl, Postman, bots, server-side scripts, or another backend from calling Drupal. It only controls browser-based cross-origin reads.
For example:
https://app.example.comis your React or Next.js frontend.https://api.example.comis your Drupal API.- The browser sees those as different origins because the hostnames are different.
- Drupal must send CORS headers before the browser lets frontend JavaScript read the response.
Authentication and authorization still belong to Drupal: permissions, routes, access checks, OAuth, cookies, CSRF tokens, or whatever security model your API uses.
Where Drupal Configures CORS
Drupal ships the CORS configuration keys in web/sites/default/default.services.yml. In a real site, copy default.services.yml to services.yml if it does not already exist, then edit the cors.config section.
This file is not ordinary Drupal configuration. It is not exported with drush config:export. Treat it like environment-specific infrastructure configuration, similar to settings.php.

parameters: cors.config in services.yml.A Practical CORS Example For A Decoupled App
For a frontend at https://app.example.com calling Drupal at https://api.example.com, a focused configuration might look like this:
parameters:
cors.config:
enabled: true
allowedHeaders:
- Authorization
- Content-Type
- X-CSRF-Token
allowedMethods:
- GET
- POST
- PATCH
- DELETE
- OPTIONS
allowedOrigins:
- 'https://app.example.com'
allowedOriginsPatterns: []
exposedHeaders: false
maxAge: 1000
supportsCredentials: true
After changing services.yml, rebuild cache:
drush cache:rebuild
If Drupal runs in containers, make sure the updated file is actually inside the running container or baked into the image. Many “CORS did not change” problems are really deployment problems.
Understanding The Important Keys
| Key | Purpose | Practical Advice |
|---|---|---|
enabled | Turns Drupal CORS handling on or off. | Set to true only when cross-origin browser access is required. |
allowedOrigins | Exact origins allowed to read responses. | Prefer explicit frontend domains. |
allowedOriginsPatterns | Regex-style origin matching. | Use sparingly for preview environments or controlled subdomains. |
allowedMethods | HTTP methods allowed by CORS. | Only list methods your API supports. |
allowedHeaders | Request headers the browser may send. | Include Authorization, Content-Type, and X-CSRF-Token only if needed. |
exposedHeaders | Response headers frontend JavaScript may read. | Expose only headers the frontend actually needs. |
maxAge | How long browsers may cache preflight results. | Use a moderate value; lower it while debugging. |
supportsCredentials | Allows credentialed requests such as cookies. | Requires explicit origins, HTTPS, and careful CSRF planning. |
Preflight Requests
For simple GET requests, the browser may send the actual request directly. For requests with JSON bodies, custom headers, authorization headers, or methods like PATCH and DELETE, the browser usually sends an OPTIONS request first. That is called a preflight request.
The preflight asks Drupal:
- Is this origin allowed?
- Is this method allowed?
- Are these request headers allowed?

You can test a preflight request with curl:
curl -i -X OPTIONS 'https://api.example.com/jsonapi/node/article' \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: GET' \
-H 'Access-Control-Request-Headers: Authorization, Content-Type'
Look for response headers such as:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Credentials, Cookies, And CSRF
If your decoupled app uses bearer tokens, CORS is mostly about allowing the Authorization header from trusted origins.
If your decoupled app uses Drupal session cookies, the setup is more sensitive:
supportsCredentialsmust betrue.- The frontend request must use
credentials: 'include'. allowedOriginsmust be explicit. A wildcard origin is not valid with credentials.- Cookies need appropriate
SameSite,Secure, and domain settings. - State-changing requests need CSRF token handling.
Example browser request:
await fetch('https://api.example.com/jsonapi/node/article', {
method: 'GET',
credentials: 'include',
headers: {
'Accept': 'application/vnd.api+json'
}
});
For authenticated API writes, token-based authentication is often simpler to reason about than cross-site cookies. Cookies can work, but they require stricter review.
Wildcard Origins
allowedOrigins: ['*'] is tempting during development. It can be acceptable for a fully public, read-only API that does not use credentials and does not expose sensitive data. It is usually wrong for authenticated decoupled apps.
Do not combine wildcard origins with credentials. Browsers do not allow Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. More importantly, it signals that the API boundary has not been thought through.
Multiple Environments
Real projects often need different origins by environment:
- Local frontend:
http://localhost:3000 - Dev frontend:
https://dev-app.example.com - Stage frontend:
https://stage-app.example.com - Production frontend:
https://app.example.com
Do not accidentally deploy local or dev origins to production unless they are intentionally supported. Keep services.yml environment-aware through deployment templates, container build arguments, mounted files, or hosting-specific configuration.
CORS With CDN And Reverse Proxies
If Drupal sits behind Akamai, Cloudflare, Fastly, Varnish, or an application load balancer, decide where CORS headers are added. You have two common patterns:
- Drupal controls CORS: proxies pass through
OPTIONSand CORS response headers unchanged. - Edge controls CORS: CDN or gateway responds to preflight and injects headers consistently.
Both can work. The dangerous version is when Drupal and the edge both add different CORS headers. Browsers are not forgiving when response headers conflict.
Security Checklist

- Use explicit origins for authenticated APIs.
- Do not use wildcard origins with credentials.
- Keep allowed methods limited to what your API needs.
- Keep allowed headers limited to what the frontend sends.
- Do not expose response headers unless the frontend needs them.
- Use HTTPS for production origins.
- Confirm authentication and permissions still protect the API.
- Verify
OPTIONSrequests are not blocked by CDN, WAF, or web server rules. - Review production
services.ymlseparately from local development settings.
Common Problems
It works in Postman but not in the browser
Postman is not subject to browser CORS enforcement. Test with browser dev tools and preflight curl commands.
The API route returns 200, but the browser still blocks it
The real request may be fine, but the preflight response may be missing CORS headers. Check the OPTIONS request in the Network tab.
Authorization header is blocked
Add Authorization to allowedHeaders. Also confirm the browser preflight asks for the same spelling and that proxies do not strip the header.
PATCH or DELETE fails
Add the method to allowedMethods, and confirm the route actually supports that method in Drupal.
Cookies are not sent
Set supportsCredentials: true, use an explicit origin, configure frontend fetch with credentials: 'include', and review cookie SameSite and Secure attributes.
Reference Links
Final Takeaway
Drupal CORS configuration should be precise, environment-aware, and aligned with your authentication model. For public read-only APIs, a broad origin policy may be reasonable. For authenticated decoupled applications, use exact origins, limited headers, limited methods, and explicit credential handling.
Most CORS bugs are solved by answering four questions: which frontend origin is calling Drupal, which method is it using, which headers is it sending, and whether credentials are involved. Configure only those needs, rebuild cache, and test the preflight request directly.