---
title: 'Cross-Origin Resource Sharing'
source: 'https://academia.sh/en/courses/frontend-quality/cross-origin-resource-sharing'
course: 'Frontend Quality'
language: en
updated: '2026-08-17T18:11:06+00:00'
license: 'CC BY-SA 4.0'
---

# Cross-Origin Resource Sharing

What the same-origin policy blocks, the distinction between simple requests and preflight, what the sharing headers mean, the rules for credentialed requests, and common misconfigurations.

The previous lesson established that the content security policy decides which origin a
script may load from. Once the script has loaded, a separate boundary takes over. The
North Slope Measurement Station interface is served from `https://measurements.example.test`,
while the measurement data comes from an endpoint at a separate domain. When the
interface's script sends a request to that endpoint, it is not the policy but the
same-origin policy that decides whether the script may read the response.

This lesson builds that decision, and the audited way of relaxing it.

## What the Same-Origin Policy Blocks

As defined in the Browser and the Web Platform course, an **origin** is the triple formed
by scheme, host name, and port. If any component of the triple differs, the origin
differs: `https://measurements.example.test` and `https://data.example.test` are separate
origins, and so is `http://measurements.example.test`.

The most common misconception about what the same-origin policy does is the assumption
that it blocks the request from being sent. What is blocked is **reading the response**.
A script can send a request to another origin; the request reaches the server, the server
processes it and returns a response; the browser just does not deliver that response to
the script. If the request produced a side effect, that side effect has already happened.

This distinction is what drives the next lesson's topic, and it should be noted here: the
policy restricts reading, not writing.

The policy applies only to connections opened from script. Image, stylesheet, font, and
script elements can load from other origins; the document embeds them but cannot read
their content. The **opaque response** concept, introduced in the Service Workers lesson
of the Browser and the Web Platform course, is the name for this state: the response can
be used, but its body and headers are not visible to the program.

## Simple Requests and Preflight

Some cross-origin requests are sent directly; others ask for permission before being sent.

Requests sent directly are the ones forms have long been able to make across origins: the
`GET`, `HEAD`, or `POST` method, a limited set of content types, and no headers the
application has added on its own. These requests get no extra round trip; the browser
sends the request and decides whether the response can be read by checking the headers
that come back.

Every other request requires **preflight**. If the method is `PUT` or `DELETE`, if the
content type is `application/json`, or if the application is adding a header that starts
with `X-`, the browser first sends a separate request with the `OPTIONS` method. In this
request, the `Access-Control-Request-Method` and `Access-Control-Request-Headers` headers
tell the server what the real request is about to do. If the server allows it, the real
request is sent; if not, the real request never happens.

The reason preflight exists is backward compatibility. The requests a browser can send
directly are already the requests forms have always been able to send; new capabilities
are not opened up without the server's knowledge.

## Server-Side Configuration

The server below serves measurement data and makes the sharing decision through a single
allowlist.

```js
// cors-server.mjs — cross-origin resource sharing configuration for the measurement endpoint
import { createServer } from 'node:http';

const ALLOWED_ORIGINS = new Set(['https://measurements.example.test']);
const ALLOWED_METHODS = 'GET, POST, OPTIONS';
const ALLOWED_HEADERS = 'content-type, x-measurement-key';

const server = createServer((request, response) => {
  const origin = request.headers.origin;
  const allowed = origin !== undefined && ALLOWED_ORIGINS.has(origin);

  response.setHeader('Vary', 'Origin');
  if (allowed) {
    response.setHeader('Access-Control-Allow-Origin', origin);
    response.setHeader('Access-Control-Allow-Credentials', 'true');
  }

  if (request.method === 'OPTIONS') {
    if (allowed) {
      response.setHeader('Access-Control-Allow-Methods', ALLOWED_METHODS);
      response.setHeader('Access-Control-Allow-Headers', ALLOWED_HEADERS);
      response.setHeader('Access-Control-Max-Age', '600');
    }
    response.writeHead(204).end();
    return;
  }

  response.setHeader('Content-Type', 'application/json');
  response.end(JSON.stringify({ station: 'north-slope', temperature: -3.4 }));
});

server.listen(8791, '127.0.0.1', () => {
  console.log('listening: http://127.0.0.1:8791');
});
```

The server is started in the background, three requests are sent, and it is stopped at the
end. The `-D -` flag writes the response headers to standard output; `tr -d '\r'` strips
the transport line-ending character, and `grep` filters out the date line, which changes
on every run.

```bash
node cors-server.mjs &
server=$!
sleep 1

echo '--- 1. preflight from an allowed origin ---'
curl -s -o /dev/null -D - -X OPTIONS http://127.0.0.1:8791/measurements \
  -H 'Origin: https://measurements.example.test' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type' | tr -d '\r' | grep -v '^Date:'

echo '--- 2. preflight from an origin not on the list ---'
curl -s -o /dev/null -D - -X OPTIONS http://127.0.0.1:8791/measurements \
  -H 'Origin: https://other.example.test' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type' | tr -d '\r' | grep -v '^Date:'

echo '--- 3. real request from an allowed origin ---'
curl -s -D - http://127.0.0.1:8791/measurements \
  -H 'Origin: https://measurements.example.test' | tr -d '\r' | grep -v '^Date:'

kill $server
```

```
listening: http://127.0.0.1:8791
--- 1. preflight from an allowed origin ---
HTTP/1.1 204 No Content
Vary: Origin
Access-Control-Allow-Origin: https://measurements.example.test
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: content-type, x-measurement-key
Access-Control-Max-Age: 600
Connection: keep-alive
Keep-Alive: timeout=5

--- 2. preflight from an origin not on the list ---
HTTP/1.1 204 No Content
Vary: Origin
Connection: keep-alive
Keep-Alive: timeout=5

--- 3. real request from an allowed origin ---
HTTP/1.1 200 OK
Vary: Origin
Access-Control-Allow-Origin: https://measurements.example.test
Access-Control-Allow-Credentials: true
Content-Type: application/json
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 44

{"station":"north-slope","temperature":-3.4}
```

The second case shows what a refusal looks like. The server does not return an error
status; it just does not write the permission headers. The decision belongs to the
browser, and after a preflight response with no permission headers, the browser never
sends the real request at all. This also explains why the refused case does not show up as
an error in the server log.

## What the Headers Mean

`Access-Control-Allow-Origin` declares the single origin allowed to read the response. Its
value is either a full origin string or an asterisk denoting all origins.

`Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` are meaningful only in a
preflight response; they list which method and which headers the real request may use.
`Access-Control-Max-Age` says how many seconds the preflight result may be cached for,
which keeps another round trip from being made before every request.

`Access-Control-Expose-Headers` extends which headers on the response the script may read.
By default only a small set of headers can be read; an endpoint carrying pagination
information in a custom header has to list that header here, or the script never sees it.

`Vary: Origin` is not a permission header, but its absence is a real defect. Because the
response's content changes based on the request's `Origin` header, every cache along the
path has to include this header in the cache key. If it does not, permission headers
produced for one allowed origin can be served to a different origin.

## Credentialed Requests

Attaching cookies and identity headers to a cross-origin request also has to be requested
explicitly. On the client side this is done by marking that the request should carry
credentials; on the server side, two rules apply at once.

First, the `Access-Control-Allow-Credentials` header must carry the value `true`. Second,
`Access-Control-Allow-Origin` cannot be an asterisk; a full origin string must be written.
This rule keeps a "publicly open" configuration from unintentionally covering requests
that carry credentials.

Whether the cookie is actually sent depends on one more condition: the cookie's
`SameSite` attribute has to allow cross-origin sending. This attribute is the subject of
the next lesson, and the two mechanisms have to be considered together; even if the
sharing headers are correct, if the cookie attribute blocks it, the request goes out
without credentials.

## Common Misconfigurations

**Reflecting the incoming origin as-is.** A server that writes the request's `Origin`
header back into the response without validating it looks like it is maintaining an
allowlist but has actually allowed every origin. The example above does reflect, but only
for a value already on the list.

**Trying to combine an asterisk with credentials.** The browser rejects this; switching to
reflecting the origin to get around this rejection reproduces the first defect.

**Writing the domain match loosely.** A check that accepts any name ending in
`example.test` also accepts `example.test.other.test`. The match has to be made against
the full origin, not through the end of a string.

**Allowing the `null` origin.** Some contexts send the `Origin` header as `null`; putting
this value on the allowlist means allowing all of those contexts.

## Sharing Is Not Authorization

Cross-origin resource sharing protects the user's browser from having another site's
script read data. It does not protect the server. A client that is not a browser does not
observe these headers at all; writing an origin name that is not on the allowlist does not
form a barrier either, because nothing outside a browser is required to honor the `Origin`
header.

The rule that follows is clear: an endpoint is not considered protected without
authentication and authorization. A sharing configuration does not stand in for these
checks; it is layered on top of them.

## Summary

- The same-origin policy does not block a cross-origin request from being sent; it blocks
  reading its response. A request that produces a side effect still happens.
- Requests that fail to meet the simple-request conditions go through a preflight round
  with the `OPTIONS` method; if the server does not allow it, the real request never
  happens.
- A refusal shows up as the absence of permission headers, not as an error status.
- For credentialed requests, `Access-Control-Allow-Origin` cannot be an asterisk and
  `Access-Control-Allow-Credentials` is required; the cookie's `SameSite` attribute must
  also allow it.
- Sharing configuration is for the browser; the server's authentication and authorization
  are still required independently of it.

## Next Step

An open question is left at the start of this lesson: if the same-origin policy blocks
reading and a write request still reaches the server, can another site's page delete a
measurement record through the user's session? The response not being readable does not
mean the operation did not happen. The next lesson takes up this class of attack and the
defense built from cookie attributes and request tokens.
