Embercode
✕
How the Internet WorksLesson 15 of 15

35 min read

CORS and same-origin

Why this matters

You build a page at http://localhost:3000 and it calls an API at http://localhost:4000. The page loads, the request leaves the browser, the server answers, and then the console shows a red error and the page gets nothing. The server did what you asked. The browser refused to hand you the answer.

This happens to almost every beginner who builds a front end and a back end separately. The rule behind it is called CORS, which stands for Cross-Origin Resource Sharing. It is a set of headers that a server sends so that the browser knows which other pages may read its responses. Once you know the rule, the error message stops being a mystery.

The previous lessons set up the pieces. The URL lesson defined an origin as scheme plus host plus port, and the browser lesson described the same-origin rule, the default wall between origins. This lesson does not repeat those definitions. It covers what the browser does when a page calls a different origin, how the server opens the wall with headers, when the browser asks permission first, and where people usually go wrong. The HTTP lesson showed the error message in the console. Here you will see the headers that make the message go away.

The idea: a locked box with a window

Think of a response from a server as a box of data. The server sends the box to the browser. The browser is the post office, and your page is the person who wants to open the box.

By default the post office hands the box only to a person who lives at the same address. If your page lives at http://localhost:3000 and the box came from http://localhost:4000, the post office keeps the box and does not let your page open it.

The server can change that. It writes a note on the outside of the box that names the addresses allowed to open it. That note is the Access-Control-Allow-Origin header. If the note names your page's origin exactly, the post office lets your page open the box. If the note is missing, or names someone else, the box stays closed.

Two things about this picture matter for everything below:

  • The box still travels. The browser sends the request and the server does its work. A blocked response is not a blocked request. For simple requests the server already ran the code before the browser decided not to show you the result.
  • The note is written by the server, not by your page. Your page cannot grant itself permission. This is why a front-end change alone never fixes a CORS error.

What the browser does with a cross-origin response

Here is the sequence for one cross-origin GET, step by step:

  1. Your page at http://localhost:3000 runs fetch("http://localhost:4000/api/items").
  2. The browser adds an Origin header to the request. Its value is your page's origin, http://localhost:3000.
  3. The server handles the request and sends back a response. Whether the response includes the allow header depends on the server's code.
  4. The browser looks at the response's Access-Control-Allow-Origin header and compares it with the page's origin.
  5. If they match, the browser gives the response to your page. If the header is missing or does not match, the browser throws away the body from the page's point of view. Your fetch call fails with a network-style error, and the console shows the CORS message.

Step 4 is the whole rule. A plain text comparison: the header value must equal the page's origin, character for character, or be the single value *, which means any origin. (The * value has a limit covered later.)

Here is a small model of that check. It is not how a browser is written inside, but it follows the same decision and it lets you test the cases yourself. The function takes the page's origin and the response headers, and returns whether the page may read the response.

Python · editable

Output:

blocked | no Access-Control-Allow-Origin header
allowed | exact match
allowed | wildcard
blocked | header is 'http://localhost:3000/', page is 'http://localhost:3000'
blocked | header is 'http://localhost:4000', page is 'http://localhost:3000'
blocked | header is 'null', page is 'http://localhost:3000'

Each row is one of the cases in the list. Read the rows as a checklist. The first row is the situation in the opening example: the API sent no header, so the box stays closed. The second row is the fix: the server names your page's origin. The third row shows *, which opens the box for every origin. The fourth row fails because of a single trailing slash, since the header must match the origin exactly. The fifth row names a different origin, which is the case of a server that allows its own address by mistake. The sixth row fails because null is a special value, not a wildcard.

Worked example 1: no header, and the request still happened

Start with the simplest case. A tiny API at port 4000 answers any GET with a list of fruit and sends no CORS headers at all. The page at port 3000 calls it.

Walk through what happens. The browser sends the request. The server finds the list and sends it back, status 200 OK. The response has no Access-Control-Allow-Origin header. The model returns blocked | no Access-Control-Allow-Origin header. The page's fetch rejects with an error, and the body is never shown to the page.

Two conclusions follow. First, the server's log will show the request. Second, if the response had been the result of a POST that saved data, the data would already be saved. CORS protects what your page can read. It does not stop a request from being sent or processed. A server that changes data must check who is calling it with its own login checks, whatever the CORS headers say. The cookies and login lesson covers how a server identifies the caller.

Now the fix. The server adds one header that names the page's origin:

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: http://localhost:3000

Run the same check with that header. The model returns allowed | exact match. The page can read the body.

Worked example 2: a server that allows some origins and not others

A real API usually has more than one front end, such as a local development server and a production site. It should allow each one by name, and refuse the rest. The demo below is a server that holds a list of allowed origins. It looks at the Origin header of each request and echoes back that origin only when it is on the list.

The demo runs the server and the test requests in one program, so the output comes from a single run. Pay attention to the Vary header, which tells caches that the response depends on the Origin header. The explanation comes after the output.

Python · editable

Output:

== GET from allowed origin
status: 200
Access-Control-Allow-Origin: http://localhost:3000
Vary: Origin
 
== GET from other origin
status: 200
 
== OPTIONS preflight from allowed origin
status: 204
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, PUT
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 600
Vary: Origin
 
== OPTIONS preflight from other origin
status: 204
Access-Control-Allow-Methods: GET, PUT
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 600

The first request is allowed. The server saw Origin: http://localhost:3000, which is on the list, so it echoed that exact value back. The second request has no Access-Control-Allow-Origin header. The status is still 200, because the server did its job. The browser would block the page from reading it.

The server echoes the origin back rather than sending a fixed string. Why? Because a server usually allows several origins, and the browser only accepts one value per response. Echoing works when the origin is on the list. The Vary: Origin line is there for caches. Suppose a cache stored the allowed response and then served it to a request from http://localhost:5000. The stored note would name the wrong origin and the browser would block it. Vary: Origin tells the cache to keep one stored copy per origin.

The last two lines of output show something important. The preflight from the other origin still gets the methods and headers lines. A beginner might think that is enough. It is not. The browser checks the origin first, and without a matching Access-Control-Allow-Origin the preflight fails, whatever the other headers say.

Simple requests and preflight

So far the browser simply sent the request and then decided what to show. That is true only for some requests. The browser calls these simple requests. A simple request uses one of the methods GET, HEAD or POST, and it uses only a small set of headers, with a Content-Type of text/plain, application/x-www-form-urlencoded or multipart/form-data.

Some requests are not simple. A PUT or DELETE, a POST with Content-Type: application/json, or a request with an Authorization header all fall outside the list. For these, the browser does something first. It sends a separate request called a preflight to ask whether the real request is allowed. The preflight uses the method OPTIONS, which asks a server what it accepts.

The preflight carries three headers that describe the real request you want to send:

OPTIONS /api/items HTTP/1.1
Host: 127.0.0.1:4000
Origin: http://localhost:3000
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type

The browser writes this for you. You never type it. The three headers ask: may the origin http://localhost:3000 send a PUT with the content-type header?

The server answers the preflight. The answer includes the allow headers for the things it accepts:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, PUT
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 600

If the origin, the method and the headers all match, the browser sends the real PUT. If not, the browser never sends it, and the page sees a failure. The Access-Control-Max-Age header says how many seconds the browser may remember this answer. With 600, the browser asks once every ten minutes, not before every call.

Here is the difference in order. A simple request is sent once, and the browser decides after the response whether to show it. A preflighted request is sent twice. The first send is the OPTIONS check. The second send is the real request, and only happens if the check passes. The real request is never sent when the check fails. That is the one case where a blocked request does not reach the server at all.

What makes a request need a preflight

A request needs a preflight when it is not simple. The most common triggers in day-to-day work are:

  • a method other than GET, HEAD or POST, such as PUT, PATCH or DELETE
  • a Content-Type of application/json, which is how most JSON APIs receive their bodies
  • an Authorization header, which carries login tokens
  • any custom header, such as X-Request-Id

This is why a simple form post to another origin is sent without a check, while a fetch with a JSON body waits for a preflight. The form post is simple, so the browser sends it without asking. The JSON fetch is not simple, so it asks first.

Worked example 3: a JSON PUT from the front end

Here is the full path for a practical case. The page at http://localhost:3000 saves a change with PUT /api/items and a JSON body. The request needs a preflight. The server from example 2 is the API. Follow each step.

  1. The page runs fetch with method: "PUT" and a JSON body. The body makes the Content-Type application/json, so the request is not simple.
  2. The browser sends OPTIONS /api/items with Origin: http://localhost:3000, Access-Control-Request-Method: PUT and Access-Control-Request-Headers: content-type.
  3. The server echoes the origin, lists GET, PUT as methods and Content-Type as an allowed header. The browser checks each part. The origin matches, PUT is in the list, and content-type is in the list. The check passes.
  4. The browser sends the real PUT. The server saves the change and answers with the allow header again, because the browser needs the header on the real response too.
  5. The browser checks that header on the real response, and gives the body to the page.

Now the failing version. Suppose the server's Access-Control-Allow-Methods line says only GET. Step 3 fails on the method. The browser never sends the PUT. The page sees a failed request, and the console shows a CORS error that names the preflight, not the PUT. The fix is to add PUT to the methods list on the server. The page's code does not change.

This is a useful habit. When a CORS error appears, open the Network tab, find the OPTIONS row, and read its response headers. If the OPTIONS row is missing, the real request was simple, and the problem is the Access-Control-Allow-Origin header on the response. If the OPTIONS row is present and fails, the problem is one of the allow headers for the preflight.

Allowing cookies and login state

Browsers do not send cookies on cross-origin requests by default. A page that needs to send them has to ask. In fetch this is credentials: "include". The cookies lesson explains what a cookie is. Here the only question is what CORS needs from the server.

The server must send the header Access-Control-Allow-Credentials: true. It must also send the exact origin in Access-Control-Allow-Origin, not *. The browser then sends cookies and lets the page read the response.

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Credentials: true
Content-Type: application/json

Without the credentials header, the browser drops the response even when the origin matches. With the credentials header and the wildcard, the browser also refuses, because a wildcard would let any site use the user's cookies. This is the reason * has a limit. It works for public data that needs no cookies and no login. It never works for requests that carry credentials.

Common mistakes

1. Fixing the error in the front end. Some beginners add mode: "no-cors" to fetch, and the error disappears. The page now gets a response it cannot read. The status, body and headers are hidden, so the code that depends on the data breaks in a quieter way. The request is still sent. The fix belongs on the server, in the allow headers.

2. Writing the origin with a path or a slash. A server that sends Access-Control-Allow-Origin: http://localhost:3000/ fails, as row 4 of the model output shows. The header holds an origin, which has no path and no trailing slash. Copy the page's origin from the address bar, up to and including the port, and stop there.

3. Allowing a single origin, then testing from another one. The demo's ALLOWED set holds one origin. Opening the same page from http://127.0.0.1:3000 loads, but its origin is http://127.0.0.1:3000, a different string from http://localhost:3000. Both need a place in the list. Until that exact origin is on the list, its requests are blocked.

4. Using the wildcard with cookies. A server sends Access-Control-Allow-Origin: * and the front end sends credentials: "include". The browser refuses. The console message says the wildcard may not be used when credentials are included, and that the header must name the exact origin. The wording varies by browser, but the meaning is the same, and the fix is to echo the origin and send the credentials header.

5. Forgetting the header on error responses. The server sends a 500 Internal Server Error without the allow header. The browser blocks the response, so the page never sees the 500. The console shows only a CORS error, and the real error is hidden. The fix is to add the allow header on every response, including errors. Many frameworks let you set it once, in a shared function that runs on every request, rather than in each handler.

6. Caching without Vary: Origin. Two front ends share one CDN (a network of servers that keep copies of responses near users) or proxy cache. The first response to http://localhost:3000 is stored with its allow header, and the second origin gets that stored copy. The second origin is blocked, and the bug only appears after the first visitor. Send Vary: Origin whenever the allow header depends on the request's origin.

7. Thinking CORS protects the server. A beginner reads "blocked" and concludes that a cross-origin POST cannot change data. It can. A simple request is sent and processed. CORS only controls whether another origin's page may read the answer. The server must still check who the caller is.

Practice

Exercise 1. The page is at https://shop.example.com and the API is at https://api.shop.example.com. Are they the same origin? Explain which part of the origin differs.

Show the answer

No. The scheme is the same and the port is the same, but the host differs: shop.example.com against api.shop.example.com. Any difference in scheme, host or port makes two origins different.

Exercise 2. The page is at http://localhost:3000. The API replies to a GET with the header Access-Control-Allow-Origin: http://localhost:3000/. Will the browser let the page read the body? Use the model in this lesson to check your answer.

Show the answer

No. The header has a trailing slash, so it does not equal the page's origin. The model returns blocked | header is 'http://localhost:3000/', page is 'http://localhost:3000'. The fix is to send the origin without the slash.

Exercise 3. Which of these requests need a preflight? Assume none of them sets a custom header.

  • (a) GET /api/items
  • (b) POST /api/items with Content-Type: application/x-www-form-urlencoded
  • (c) PUT /api/items with no body
  • (d) DELETE /api/items/3
  • (e) POST /api/items with Content-Type: application/json

Show the answer

Requests (c), (d) and (e) need a preflight. Requests (a) and (b) are simple: GET is simple and a form-encoded POST is simple. The PUT and DELETE methods are not simple, and a POST with a JSON body is not simple.

Exercise 4. The demo server allows only http://localhost:3000. Change the server so that a GET from http://localhost:5000 is also allowed. Describe the one line you change and what the output would show for the second request.

Show the answer

Add http://localhost:5000 to the ALLOWED set, so it reads ALLOWED = {"http://localhost:3000", "http://localhost:5000"}. The second request then gets status: 200 and the header Access-Control-Allow-Origin: http://localhost:5000, plus Vary: Origin. The server echoes only origins on the list, so the same code handles both.

Exercise 5. The page calls fetch with credentials: "include". The server sends Access-Control-Allow-Origin: * and no credentials header. Will the page get the body? List what the server must send for it to work.

Show the answer

No. A wildcard is not allowed with credentials, and the credentials header is missing. The server must send the exact origin, such as Access-Control-Allow-Origin: http://localhost:3000, and Access-Control-Allow-Credentials: true. The page's fetch also keeps credentials: "include".

Summary

  • CORS is the set of response headers that lets a server open its responses to other origins. The main one is Access-Control-Allow-Origin.
  • The browser sends the request and then compares the response header with the page's origin. A missing or non-matching header keeps the body from the page.
  • The server is the only party that can grant access. Front-end changes such as no-cors hide the error and do not fix it.
  • A request that is not simple, such as a PUT or a JSON POST, is first checked with an OPTIONS preflight. The real request is sent only if the preflight passes.
  • With cookies, the server must name the exact origin and send Access-Control-Allow-Credentials: true. The wildcard does not work there.
  • CORS controls what a page can read, not what the server processes. Every endpoint that changes data still needs its own login checks.
Check yourself: Cookies, caching and CORS (10 questions) →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← HTTP caching headers