Step 08

Other people's data

Every request in this series so far went from a page on cse134.site to an endpoint on cse134.site. Change the host name in the URL and the same code stops working, with an error that names nothing. This step is CORS as your code experiences it: what you get told when a read is refused, what makes the browser ask permission before it sends anything, what turning on cookies costs, and what you take on when you route the call through a server of your own. The mechanism and the reasoning behind it are in The security model, module 7 of the browser series.

Learning Objectives

  • Name the three parts of an origin and say which of them changed in a given pair of URLs
  • Say what fetch and XMLHttpRequest report 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-Age is 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 readfetchXMLHttpRequest
The callThe promise rejects with a TypeErrorThe error event fires. load does not.
statusThere is no Response object to ask0
statusTextSameThe empty string
Response headersSamegetAllResponseHeaders() returns the empty string
Response bodySameresponseText is the empty string
Why it was blockedNothing. 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:

Cache-Control Content-Language Content-Length Content-Type Expires Last-Modified Pragma

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.

const response = await fetch('https://api.example.com/v1/posts'); response.headers.get('Content-Type'); // 'application/json', safelisted response.headers.get('X-Request-Id'); // null, until the server exposes it response.headers.get('X-RateLimit-Remaining'); // null, same reason Access-Control-Allow-Origin: https://cse134.site Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining

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, or POST. Those three are the CORS-safelisted methods. PUT, PATCH, and DELETE preflight 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, and Accept-Encoding itself, and none of them trigger anything.
  • Nothing else on the request forces it. Adding a listener to xhr.upload forces a preflight. A fetch body that is a ReadableStream forces one too, because the bytes have no replayable source.
HeaderWhat the value may containStays simple
AcceptAnything with no CORS-unsafe byte in itapplication/json
Accept-LanguageOnly 0-9, A-Z, a-z, space, and *,-.;=en-US,en;q=0.9
Content-LanguageThe same seten-US
Content-TypeNo CORS-unsafe byte, and the essence must be application/x-www-form-urlencoded, multipart/form-data, or text/plaintext/plain;charset=UTF-8
RangeA single byte range with a start, the shape a media element sendsbytes=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.

OPTIONS /api/posts HTTP/1.1 Host: cse134site.tpowell.workers.dev Origin: https://cse134.site Access-Control-Request-Method: POST Access-Control-Request-Headers: content-type

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.

HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://cse134.site Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: Content-Type, X-Requested-With Access-Control-Max-Age: 86400 Vary: Origin
Response headerWhat it answers* allowedWith credentials
Access-Control-Allow-OriginWho may read the responseYesNo. Name the origin.
Access-Control-Allow-MethodsWhich methods the preflight approvesYesNo. List them.
Access-Control-Allow-HeadersWhich unsafe headers may be sentYes, except AuthorizationNo. List them.
Access-Control-Expose-HeadersWhich response headers script may readYesNo. List them.
Access-Control-Max-AgeHow long this answer may be cachedNot applicableUnchanged

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.

BrowserLongest it will cacheDefault when the header is absent
Firefox86400 seconds, 24 hoursNot documented
Chromium, version 76 and later7200 seconds, 2 hoursCaches for 5 seconds
Chromium, before version 76600 seconds, 10 minutesCaches 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-With is 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 under text/plain is 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.

// fetch const response = await fetch('https://api.example.com/v1/me', { credentials: 'include', }); // XMLHttpRequest const xhr = new XMLHttpRequest(); xhr.open('GET', 'https://api.example.com/v1/me'); xhr.withCredentials = true; xhr.send();

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: true has to be on the response. It is the server saying the credentialed read is allowed.
  • Access-Control-Allow-Origin has 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, and Access-Control-Expose-Headers lose 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.

const response = await fetch('https://api.example.com/v1/posts', { mode: 'no-cors', }); response.type; // 'opaque' response.status; // 0 response.ok; // false response.headers; // empty, every get() returns null await response.text(); // ''

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.

const UPSTREAM = 'https://api.example.com'; // Pin the host and the paths. A proxy that forwards whatever URL it is // handed is an open proxy, and open proxies get found and used. const ALLOWED = new Set(['/v1/posts', '/v1/tags']); export default { async fetch(request, env, ctx) { const url = new URL(request.url); const path = url.pathname.replace('/api/upstream', ''); if (!ALLOWED.has(path)) { return new Response('Not proxied', { status: 404 }); } const target = new URL(path + url.search, UPSTREAM); const cache = caches.default; const hit = await cache.match(target.href); if (hit) return hit; const response = await fetch(target.href, { headers: { // The key lives here. No client-side build tool can hide it; a // server does not have to. Authorization: 'Bearer ' + env.UPSTREAM_KEY, Accept: 'application/json', }, }); // Your cache policy now, not theirs. Say a number and own it. const out = new Response(response.body, response); out.headers.set('Cache-Control', 'public, max-age=300'); ctx.waitUntil(cache.put(target.href, out.clone())); return out; }, };

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=off tells the Worker to answer normally and send no CORS headers. The row shows what your catch block received: a TypeError, 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 error and the row lists the headers it sent. The panel does not show the response.
  • Press the no-cors button. Nothing rejects. response.type is opaque, status is 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 OPTIONS and then the POST. Press the urlencoded button instead and there is one row, because that Content-Type is safelisted. Press the JSON button a second time, with Disable cache unchecked, and the OPTIONS is 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, obey Retry-After from 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.sendBeacon and keepalive, 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