The same-origin policy, and what CORS relaxes
An origin is three things together: scheme, host and port. https://shop.example.com and https://api.example.com are different origins, and so are http://example.com and https://example.com. The path does not count.
Browsers apply the same-origin policy: JavaScript on one origin may send requests to another, but it may not read the response unless the other side agrees. The same rule covers web fonts loaded from another host and reading back the pixels of a cross-origin image drawn on a canvas.
A lot stays allowed without any agreement. A page can embed an image, a stylesheet or a script from anywhere, and a form can post to any site. What the policy guards is reading: without it, any page you open could quietly call your webmail or your bank's API with your cookies and read the answer.
CORS (Cross-Origin Resource Sharing) is that agreement. The browser tells the server which origin is asking, in the Origin request header, and the server answers with Access-Control-* headers that say which origins may read the response. Nothing in CORS stops a request at the server: it is the browser that holds the response back from the page.
Simple requests and preflight requests
Simple requests go straight out. A GET, HEAD or POST qualifies when it carries only CORS-safelisted headers, such as Accept, Accept-Language and Content-Language, and, for a body, a Content-Type of text/plain, multipart/form-data or application/x-www-form-urlencoded. The browser sends it with an Origin header, receives the answer, and hands it to the page only if Access-Control-Allow-Origin allows that origin.
Everything else gets a preflight first: an OPTIONS request that asks permission before the real one is sent. It names the method in Access-Control-Request-Method and any other headers in Access-Control-Request-Headers. The server must answer with a 2xx status, the matching Access-Control-Allow-* headers and no redirect. Only then does the browser send the actual request.
The usual surprise is that a JSON API triggers a preflight on almost every call. Content-Type: application/json is not on the safelist and neither is Authorization, so a fetch that posts JSON with a bearer token costs an extra round trip, unless the browser still has the preflight answer cached. That is what Access-Control-Max-Age is for: Chromium keeps the answer for at most two hours and Firefox for at most 24, and without the header the browser keeps it for five seconds.
A preflight and the answer that lets the real request through
OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://shop.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 7200
Vary: Origin
The Access-Control headers, one by one
Access-Control-Allow-Origin is the one that decides. It holds exactly one value: * for any origin, a single origin such as https://shop.example.com, or null. A comma-separated list is not valid, and null should never be allowed, because sandboxed iframes and local files also send Origin: null.
Access-Control-Allow-Methods and Access-Control-Allow-Headers answer the preflight: which methods and request headers the real request may use. Access-Control-Max-Age says how many seconds the browser may reuse that answer.
Access-Control-Allow-Credentials: true lets the page read the response to a request sent with cookies or HTTP authentication. The next section covers the rules that come with it.
Access-Control-Expose-Headers lists the response headers the page's JavaScript may read. Without it, a script sees only Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma, so a custom X-Request-Id, or the ETag an upload library needs, stays invisible.
The request side has three headers, all set by the browser and impossible for page code to forge: Origin, Access-Control-Request-Method and Access-Control-Request-Headers.
Credentials: cookies, Authorization and why * stops working
A request is credentialed when it carries cookies or HTTP authentication: fetch with credentials: 'include', or XMLHttpRequest with withCredentials = true. For those, the browser tightens every rule.
Access-Control-Allow-Origin must name the exact origin; * is refused. Access-Control-Allow-Credentials: true must be present. And * in Allow-Headers, Allow-Methods or Expose-Headers stops being a wildcard: it is read as a header literally named *.
Since one response can name only one origin, a server with several front ends keeps an allowlist, compares the incoming Origin with it, and echoes the match back. The answer now depends on a request header, so every response, including the ones without any CORS header, must also say Vary: Origin; otherwise a cache hands one origin's answer to another.
The classic mistake is echoing any origin while allowing credentials. That lets every website read your logged-in users' data through their own browsers. Security scanners flag it first, and it falls under broken access control in the OWASP Top 10. Cookies have a gate of their own as well: a cookie travels on a cross-site request only when it was set with SameSite=None and Secure.
An allowlist that echoes the origin (Node.js, Express)
const allowed = new Set([
'https://shop.example.com',
'https://admin.example.com',
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
res.vary('Origin');
if (allowed.has(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Credentials', 'true');
}
if (req.method === 'OPTIONS') {
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
res.set('Access-Control-Max-Age', '7200');
return res.sendStatus(204);
}
next();
});
Common CORS errors and what each one means
The console message names the rule that failed. Read it with the Network tab open, because the status code of the failed request is usually the real story. The messages below are Chromium's wording.
No 'Access-Control-Allow-Origin' header is present on the requested resource. The response arrived without the header. Most often the response is an error, such as a 404, a 500, a redirect to a login page or a firewall's 403, produced by code that never reaches your CORS handling. Fix the status first, then make sure error responses carry the header too, so the next failure shows its real cause.
The 'Access-Control-Allow-Origin' header contains multiple values '*, *', but only one is allowed. Two layers add the header: typically the application and a proxy or CDN in front of it. Keep exactly one.
Response to preflight request doesn't pass access control check: It does not have HTTP ok status. The OPTIONS request got a 401, 403, 404 or 405. Authentication that runs before CORS is the usual cause: a preflight never carries your Authorization header, so it has to be answered before any login check.
Redirect is not allowed for a preflight request. The URL redirects: http to https, a missing trailing slash, another host. Call the final URL directly.
The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. The page sends cookies and the server answers *. Echo the exact origin and add Access-Control-Allow-Credentials: true, or stop sending credentials.
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response. Add the named header to Access-Control-Allow-Headers.
An origin of null means the page was opened from file://; serve it from a local web server instead. And one "fix" to avoid: mode: 'no-cors' silences the error by giving your code an opaque response it cannot read. The request goes through and your script receives nothing.
CORS behind a CDN: caching and Vary: Origin
A cache stores one response per URL and serves it to everyone who asks for that URL. CORS headers that change with the request break that assumption in two ways.
If the origin adds Access-Control-Allow-Origin only when an Origin header is present, the first request decides what gets cached. When that first request was a plain <img> tag or a direct visit, the cached copy has no CORS header, and every cross-origin fetch afterwards fails until the copy expires. The error looks random because it depends on who came first.
If the origin echoes the requesting origin, the cached copy names one origin, and a second site that reads the same URL gets an answer addressed to someone else.
There are two clean designs. For public files such as fonts, images, scripts and public JSON, send the same header to everyone, either * or your one site's origin; every cached copy is then right for every visitor and the hit ratio is untouched. When the answer must differ per origin, send Vary: Origin on every response for that URL so the cache keeps the variants apart. That costs some hit ratio, so keep it to the paths that need it.
Browsers cache too. An image first loaded by a plain <img> tag and later requested with the crossorigin attribute can come from the browser's cache without the header. The same two designs cover that case.
Setting CORS headers on CDN.com.tr
For files served through the CDN, the edge can add the header itself. In the panel, open Delivery Rules, edit the rule that covers the path (for example /fonts/ or /static/), and write one header per line in Custom Headers as Header-Name value (on the tabbed rules page: Headers → Add response headers). A value with spaces goes in double quotes.
The edge adds these headers to successful and redirect responses (2xx and 3xx), cache hits included, from the moment the rule is published. The panel and the edge refuse ;, { and } in a header line; a CORS header never needs them, since Access-Control-Allow-Origin holds a single value.
If your origin already sends an Access-Control-* header for those files, select the same name under Hide Headers in that rule. Otherwise the browser receives two values and rejects both.
Two things stay with your origin. The first is preflights: the edge passes OPTIONS requests to your server and never caches them, so an API that needs preflights answers them itself. Plain GET requests for fonts, images and public JSON need no preflight, and the rule header is all they need. The second is per-origin answers: echoing one of several allowed origins, which credentialed requests require, is your application's decision. The edge keeps those variants apart when your origin sends Vary: Origin, as long as the rule is not set to ignore Vary. On a rule with image optimization, JPEG and PNG images are served through the optimizer, which sets its own Vary, so give images a fixed header.
If the site-wide WAF is on, OPTIONS is among its allowed methods by default; PUT, PATCH and DELETE have to be ticked under Allowed HTTP methods. A request the edge refuses gets a 403 with no CORS headers, which the browser reports as a CORS error. The X-MT-Blocked-By: cdn-edge-security header on that 403 tells you that the edge, not your application, said no.
Custom Headers on a rule for /fonts/ or /static/
Access-Control-Allow-Origin *
Access-Control-Expose-Headers "Content-Length, ETag"
CORS on object storage buckets
Files that a browser uploads straight to a bucket, for example with a presigned PUT from a web app, are answered by the storage service, not by your application, so the bucket needs CORS rules of its own.
CDN.com.tr object storage accepts them through the standard S3 call, PutBucketCors, on https://s3.cdn.com.tr, and answers the browser's preflight requests itself. Any S3 tool that can set bucket CORS works; with the AWS CLI it is one command and a small JSON file.
List exact origins rather than * for anything that writes, and expose ETag: browser upload libraries read it to complete multipart uploads. CORS does not make a bucket public, either. A private bucket still answers 403 to anonymous reads; CORS only decides whether a page may read a response it was allowed to get.
Bucket CORS with the AWS CLI
# cors.json
{
"CORSRules": [{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}]
}
aws s3api put-bucket-cors --bucket my-bucket \
--cors-configuration file://cors.json \
--endpoint-url https://s3.cdn.com.tr
aws s3api get-bucket-cors --bucket my-bucket \
--endpoint-url https://s3.cdn.com.tr
Testing CORS from the command line
curl does not enforce CORS, which makes it the right tool for seeing what a server actually says. Send the Origin header yourself and read the Access-Control-* headers that come back; for a preflight, send OPTIONS with the two headers a browser would add.
Check three things: a 2xx status on the preflight, exactly one Access-Control-Allow-Origin, and Vary: Origin wherever the value changes with the origin. Then repeat the request through the CDN and compare. On CDN.com.tr the X-Proxy-Cache-MT header shows whether the answer came from the cache; if the cached answer differs from the first one, the caching problem described above is at work.
# a simple request: what does the edge answer for this origin?
curl -sI https://cdn.example.com/fonts/inter.woff2 \
-H 'Origin: https://www.example.com' \
| grep -i -E 'access-control|vary|x-proxy-cache'
# a preflight, as the browser would send it
curl -si -X OPTIONS https://api.example.com/v1/orders \
-H 'Origin: https://shop.example.com' \
-H 'Access-Control-Request-Method: PUT' \
-H 'Access-Control-Request-Headers: authorization, content-type'
CORS FAQ
Does CORS protect my API?
No. CORS protects your users' browsers from pages that try to read data on their behalf. It does nothing against a script, a server or curl calling your API directly, and simple requests reach your server even when the browser then hides the answer. Your API still needs its own authentication and authorization.
Is Access-Control-Allow-Origin: * safe?
For public resources that need no login, such as fonts, images, scripts and public JSON, yes: anyone could download them anyway. It is wrong for anything that depends on cookies or the user's session, and browsers refuse to combine it with credentials.
How do I allow more than one origin?
Not with a list: the header takes one value. Keep an allowlist on the server, echo the request's Origin when it is on the list, and send Vary: Origin on every response so caches keep the answers apart.
Why does the request work in Postman or curl but fail in the browser?
Because only browsers enforce CORS. Postman and curl send the request and show whatever comes back; the browser checks the Access-Control-* headers before it lets your JavaScript see the response.
Why does the CORS error happen only sometimes?
Usually a cache. If the response changes with the Origin header but does not say Vary: Origin, whichever request reached the cache first decides what everyone else receives. Send a fixed header for public files, or Vary: Origin where the answer must differ.
How do I enable CORS on CDN.com.tr?
For files the CDN serves, add Access-Control-Allow-Origin in the delivery rule's Custom Headers; preflights for your API and answers that differ per origin come from your origin. For object storage buckets, set CORS rules with the S3 PutBucketCors call on https://s3.cdn.com.tr.