Learning Objectives
- Name the three parts of an origin and say which of them changed in a given pair of URLs
- Say what
fetchandXMLHttpRequestreport for a blocked cross-origin read, and what they never report - Decide from a method and a header list whether a request preflights
- Name the five CORS-safelisted request headers and the restriction on each one's value
- Read a preflight exchange: two request headers, three response headers, and the status it needs
- Say why
Access-Control-Max-Ageis a suggestion - List the seven response headers JavaScript can read cross-origin without being told, and add an eighth
- Send a credentialed cross-origin request and name the four things it forbids on the server
- Say what
mode: 'no-cors'returns and why it solves nothing - Weigh a same-origin proxy against a direct call, in caching, rate limits, keys, and terms
- Name what breaks a scraper and what breaks a mashup
The failure has no detail
An origin is a scheme, a host, and a port. Any one of the three is enough to make two URLs different origins. https://cse134.site and https://cse134site.tpowell.workers.dev differ in the host. http://cse134.site and https://cse134.site differ in the scheme. http://localhost:8787 and http://localhost:3000 differ in the port. The two host names in this step's demo run the same Worker, off the same source file, in the same account.
Ask one of them for something from a page served by the other and your code gets this:
| What you read | fetch | XMLHttpRequest |
|---|---|---|
| The call | The promise rejects with a TypeError | The error event fires. load does not. |
status | There is no Response object to ask | 0 |
statusText | Same | The empty string |
| Response headers | Same | getAllResponseHeaders() returns the empty string |
| Response body | Same | responseText is the empty string |
| Why it was blocked | Nothing. The message is the same one a dead DNS name produces. | Nothing. The error event carries no reason. |
Step 07 listed TypeError: Failed to fetch as the report for DNS failure, connection failure, and a rejected certificate. A CORS block is the fourth thing that produces it. Firefox uses its one message, NetworkError when attempting to fetch resource., for the same set.
The browser knows the reason and writes it to the console. Chrome prints Access to fetch at '…' from origin '…' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. Firefox prints Cross-Origin Request Blocked with a Reason: clause naming the missing or wrong header. Neither string is reachable from JavaScript.
The Network panel does not rescue you either. Measured in Chrome 151: the row for a blocked read shows the request and every header it sent, including Origin, and its Status column reads CORS error. The status the server returned and the headers it returned are not in it. The console line beside it names the reason, and the Issues panel repeats it.
The request was sent anyway
A cross-origin GET that ends in a TypeError still reached the server. The handler ran, the query ran, and the response came back to the browser, which read the headers, found no permission to hand it over, and threw it away. Run the demo below against a local wrangler dev and its log has GET /api/posts 200 OK for the request the page was told had failed.
A refused preflight is the only case where CORS stops a request from arriving. The request you wrote is never sent, and the same log shows the OPTIONS with nothing after it.
Neither one is a protection for the server. Any program that is not a browser can call your endpoint and read every byte of the answer. curl does not implement CORS.
Even a read that works is filtered
A cross-origin response you are allowed to read still arrives with most of its headers hidden. Seven names are on the safelist:
Everything else reads as null until the server names it in Access-Control-Expose-Headers. This is the answer to the question step 04 left open about getAllResponseHeaders(): same-origin it returns everything, cross-origin it returns these seven plus whatever was exposed.
Access-Control-Expose-Headers: * works, and only for a request without credentials. Debugging an integration where the server sends a request ID you cannot see is a normal afternoon. Ask for it to be exposed.
What triggers preflight
Some cross-origin requests go straight out. The rest are held while the browser sends an OPTIONS request to ask whether the real one is allowed. MDN calls the first kind simple requests and notes that the Fetch standard, which defines CORS, does not use that term. The standard states conditions instead, and a request avoids the preflight when all three hold.
- The method is
GET,HEAD, orPOST. Those three are the CORS-safelisted methods.PUT,PATCH, andDELETEpreflight every time. - Every header your code set is CORS-safelisted. Five names qualify, with limits on their values. The browser's own headers do not count: it adds
Origin,Referer,User-Agent, andAccept-Encodingitself, and none of them trigger anything. - Nothing else on the request forces it. Adding a listener to
xhr.uploadforces a preflight. Afetchbody that is aReadableStreamforces one too, because the bytes have no replayable source.
| Header | What the value may contain | Stays simple |
|---|---|---|
Accept | Anything with no CORS-unsafe byte in it | application/json |
Accept-Language | Only 0-9, A-Z, a-z, space, and *,-.;= | en-US,en;q=0.9 |
Content-Language | The same set | en-US |
Content-Type | No CORS-unsafe byte, and the essence must be application/x-www-form-urlencoded, multipart/form-data, or text/plain | text/plain;charset=UTF-8 |
Range | A single byte range with a start, the shape a media element sends | bytes=0-1023 |
Two size rules sit on top of that. A single safelisted header whose value runs past 128 bytes stops being safelisted. If the safelisted headers add up to more than 1024 bytes together, all of them are treated as unsafe at once.
A CORS-unsafe request-header byte is any byte below 0x20 other than tab, plus ", (, ), :, <, >, ?, @, [, \, ], {, }, and DEL. The parameter on Content-Type is allowed: text/plain;charset=UTF-8 contains no unsafe byte and its essence is text/plain, so it is safelisted. Only the essence is compared, so parameters cannot rescue an unsafe type.
Your own Content-Type is usually what did it
application/json is not one of the three. Every JSON POST to another origin preflights, which is most of the API calls anybody writes.
Authorization, X-Requested-With, X-CSRF-Token, and every other custom name do the same. A cross-origin GET that would have gone straight out costs an extra round trip because somebody added X-Requested-With: XMLHttpRequest out of habit.
What goes over the wire
The preflight is an OPTIONS request with no body, carrying the origin and a description of the request the browser is holding.
Access-Control-Request-Headers lists the unsafe header names, lowercased and sorted. The preflight carries no cookies and no Authorization header, whatever the real request is going to carry.
| Response header | What it answers | * allowed | With credentials |
|---|---|---|---|
Access-Control-Allow-Origin | Who may read the response | Yes | No. Name the origin. |
Access-Control-Allow-Methods | Which methods the preflight approves | Yes | No. List them. |
Access-Control-Allow-Headers | Which unsafe headers may be sent | Yes, except Authorization | No. List them. |
Access-Control-Expose-Headers | Which response headers script may read | Yes | No. List them. |
Access-Control-Max-Age | How long this answer may be cached | Not applicable | Unchanged |
Authorization is the one exception the Fetch standard writes into the algorithm by name. It calls it a CORS non-wildcard request-header name, and a * in Access-Control-Allow-Headers does not cover it. Name it or the preflight fails.
The preflight response also has to succeed. A 204 or a 200 works; a 401, a 404, or a 500 on the OPTIONS fails the preflight and the real request never leaves. Servers that require authentication on every route, OPTIONS included, produce this and it looks like a CORS bug rather than an auth bug.
Access-Control-Max-Age is a suggestion
The browser caches a preflight answer per origin, per URL, per method, and per header set, so a second identical call skips the OPTIONS. The header names a number of seconds. Every browser caps it.
| Browser | Longest it will cache | Default when the header is absent |
|---|---|---|
| Firefox | 86400 seconds, 24 hours | Not documented |
| Chromium, version 76 and later | 7200 seconds, 2 hours | Caches for 5 seconds |
| Chromium, before version 76 | 600 seconds, 10 minutes | Caches for 5 seconds |
The Worker behind this series sends 86400. Firefox honors it, Chrome stores 7200 instead, and both drop the entry early when the cache is cleared or the network changes. Send the header, and do not build a latency budget that depends on the answer surviving.
What the extra round trip costs
One request becomes two, in sequence. On a 40 ms round trip a preflighted call starts 40 ms late; on a phone on a bad connection it is 200 ms or more, before the server has done any work. Three ways to stop paying it, best first:
- Delete headers you do not need.
X-Requested-Withis a jQuery-era habit and no modern server reads it. - Set
Access-Control-Max-Age. The first call in a session pays; the rest do not, up to the browser's cap. - Send a safelisted
Content-Type. Posting JSON bytes undertext/plainis a real technique and a real lie. The server has to know to parse it anyway, proxies and logs will believe the header, and you have traded a documented request for a saved round trip.
Credentials
A cross-origin fetch sends no cookies by default. The credentials option defaults to 'same-origin': cookies on your own origin, nothing on anybody else's. XHR does the same with withCredentials defaulting to false.
That one line moves the whole exchange into a stricter mode, and the server has to answer differently or the browser blocks the read.
Access-Control-Allow-Credentials: truehas to be on the response. It is the server saying the credentialed read is allowed.Access-Control-Allow-Originhas to name your origin.*is refused, and refused loudly: the request succeeds on the wire and the browser blocks the read.Access-Control-Allow-Methods,Access-Control-Allow-Headers, andAccess-Control-Expose-Headerslose the wildcard too. List every value.- A preflight in front of a credentialed request still carries no cookies. Its response still needs
Access-Control-Allow-Credentials: true, or the real request never goes.
Naming the origin means reading Origin off the request and reflecting it. The response now varies by request, so send Vary: Origin or a shared cache will hand one origin's response to another. worker/api/http.js in this repository does all three: an allowlist, a reflected origin, and the Vary.
Reflecting the origin is not a check
Reading Origin and echoing it back approves everyone who asks. Combined with Access-Control-Allow-Credentials: true it lets any site on the web read your users' logged-in data by making requests from their browsers. Compare against a list you wrote down.
Getting all four right and still seeing no cookie is common, and the reason is usually the cookie rather than CORS. Chrome treats a cookie with no SameSite attribute as SameSite=Lax, which is not sent on a cross-site request. Reaching it needs SameSite=None; Secure from the server that set it. Firefox does not apply the Lax default, having tried and reverted it, so the same page can work in one browser and not the other. Module 7 covers the cookie flags.
mode: 'no-cors' is not a way around any of this
The name suggests it skips the CORS check. It hands you a response object with nothing in it.
The promise resolves. Nothing threw, and there is no data. The request is also restricted to GET, HEAD, and POST with safelisted headers, the same list that avoids a preflight. Two jobs it does honestly are firing a request whose answer you do not need, and storing a third-party asset in the Cache for a service worker to serve later.
The proxy
CORS is enforced by browsers. Your server is not a browser. It can fetch anything, and it can serve the result from your own origin, where the same-origin policy has nothing to say.
From the page it is fetch('/api/upstream/v1/posts'). No preflight, no allowlist, no exposed headers, no opaque failure. Six things moved onto your side.
- The key. An API key in client-side JavaScript is published, whatever the build tool did to it. On the server it is not. This is the reason to build a proxy even when CORS is not in the way.
- The caching. The browser was caching per user. Now one cache serves everybody, so a stale entry is stale for all of them, and a private response cached in the wrong place is a data leak.
- The rate limit. Your users' traffic arrives at the upstream under one identity. One user in a loop spends the quota for every other user, and the ban lands on you.
- The terms. You are now redistributing somebody else's data. Attribution requirements, caching limits, and no-resale clauses in their terms bind your server, and a proxy is not a way around a refusal.
- The uptime. Two services can be down instead of one, and yours is the one users blame.
- The latency. Browser to your server to their server, and back the same way.
JSONP, and why it is gone
Before CORS there was one hole in the same-origin policy that had always been there: <script src> may point anywhere. JSONP used it. You added a script tag pointing at the remote API with a callback name in the query string, and the server answered with JavaScript instead of JSON: handlePosts({"posts": […]}). The browser ran it, your function received the data, and no policy was violated because nothing had read a response.
The remote server is executing arbitrary JavaScript in your page, with your cookies and your DOM. It is GET-only, it has no error handling worth the name, and one compromised provider owns every page that included it. CORS replaced it.
Demo: two origins, one Worker
The site answers on two host names, cse134.site and cse134site.tpowell.workers.dev. Both serve the same Worker and the same demo file, so which one is same-origin depends entirely on the address bar. The page resolves both from location.origin when it loads and labels them at runtime. Open it on the other host and the two lines at the top trade places, along with every result underneath them.
What changed from step 07: the retry loop, the endpoint selector, and the race are gone. Every call here is one attempt, because a blocked read is not a failure a second try can fix. The instrumentation strip keeps the load counter, the request counter, the build time, and the query string, and adds a count of the reads the browser refused to hand over.
Six things to run.
- Run all four reads. The same-origin GET and the cross-origin GET both succeed and return the same three posts. Compare the two header lists in the panel under the table, not the counts: the same-origin read can see every header on the response, and the cross-origin read sees the safelisted names and nothing else. On the deployed site the same-origin list is longer still, because the edge adds headers of its own.
- Read the blocked row.
&cors=offtells the Worker to answer normally and send no CORS headers. The row shows what yourcatchblock received: aTypeError, no status, no headers, no body. Open the console and the reason is there in a sentence. - Find that request in the Network panel. The Status column reads
CORS errorand the row lists the headers it sent. The panel does not show the response. - Press the no-cors button. Nothing rejects.
response.typeisopaque,statusis 0, and the body is zero bytes. - Watch a preflight. Open the Network panel and press the cross-origin JSON POST. Two rows appear: an
OPTIONSand then thePOST. Press the urlencoded button instead and there is one row, because thatContent-Typeis safelisted. Press the JSON button a second time, with Disable cache unchecked, and theOPTIONSis gone, because the answer is cached. - Compare against the same-origin POST. Same JSON body, same header, no preflight, because a same-origin request has no permission to ask for.
The waterfall at the bottom of the demo does not show the OPTIONS. It records what the page's script does, by wrapping fetch and XMLHttpRequest, and the preflight is sent by the browser between your call and your request. No wrapper inside the page can see it. Use DevTools for that row.
Data nobody promised you
CORS is about permission to read. The rest of this step is about what you are reading and whether it will be there tomorrow.
Scraping
Screen scraping is parsing a page that was never meant to be parsed. There is no contract. A designer renames a class, a framework upgrade changes the DOM, an A/B test serves half your requests a different layout, and your selector returns an empty list.
The failure mode is worse than an outage. A selector that matches the wrong element keeps returning strings, and the strings are wrong. Check the shape of what you extracted before you use it, the same way step 07 checked a parsed body.
Anything served to a browser can be read by a program. Whether you may is a different question, answered by the site's terms, its robots.txt, and the volume you send. Scraping cases have gone both ways in court and the law is still moving. The engineering answer does not depend on how they come out: identify yourself, request at a human rate, cache what you take, and stop when you are asked to.
Mashups
A mashup combines sources into something none of them offered. The canonical one is a map with somebody else's listings on it, and around 2006 that pattern was most of what "Web 2.0" meant. Every source is a dependency you do not control and cannot fix.
Google Maps moved to pay-as-you-go pricing on June 11, 2018, with a $200 monthly credit that covered small sites. That credit was retired in March 2025 and replaced with a smaller per-product free tier. Twitter announced the end of free API access for February 9, 2023, and the $100 entry tier arrived that March. Both changes were announced in advance and permitted by the terms the developers had agreed to.
What to actually do
- Cache on your own server. It cuts your bill, your latency, and their rate limit, and it is the difference between degraded and broken when they go down.
- Render without them. Decide in advance what the page looks like when the third source returns nothing, and build that state first.
- Respect the documented limit. Read the
X-RateLimit-headers if they send them, obeyRetry-Afterfrom step 07, and back off on 429. - Attribute where they ask you to. It is usually in the terms as a condition of use, and it costs a line of markup.
- Keep the key on the server. The proxy section above.
- Have a number for what it costs if the price changes. Somebody will ask.
Next Steps
Every request in this series so far has been one question and one answer, started by the page. Step 9: Beyond request and response covers the shapes that are not that:
- One way out with
navigator.sendBeaconandkeepalive, for the request that has to survive the page - Polling, long polling, and streaming: what people built before the browser could receive anything unasked
- Server-sent events, and the reconnect that looks like a feature until it is not
- WebSocket, what it costs to hold one open, and when the answer is that you do not need it