---
title: 'Cross-Origin Resource Sharing'
source: 'https://academia.sh/en/courses/backend-production/cross-origin-resource-sharing'
course: 'Server Security and Going to Production'
language: en
updated: '2026-08-23T07:00:25+00:00'
license: 'CC BY-SA 4.0'
---

# Cross-Origin Resource Sharing

The server-side configuration of cross-origin sharing: how many of seven origins each of four settings lets the response be used for, how many origins a wildcard setting opens, how a wildcard setting on a credentialed request returns 200 on the server while being silently dropped on the client, the round-trip cost of a preflight request, and how many endpoints carry the policy.

The previous two lessons looked at the request's body. This lesson looks at **where the request
came from** and measures the one thing the server can say about it: the response headers.

The client side of cross-origin sharing — which request the browser blocks and why, the effect of
the policy on the page — was built in the Frontend Quality course and is not repeated here. What
this lesson measures is **the server-side configuration**: which layer the setting sits in, how
many origins a wildcard value opens the door to, what a wrong setting does on a request carrying
credentials, and what a preflight request costs.

The server's job is small: it looks at the incoming origin and either adds a few response headers
or does not. The decision is made by the **client**. This distinction is the point every
measurement in this lesson rests on.

**AS5 (assumption):** the client's rule was written here, for this lesson. The correctness of the
server setting is measured by whether the response is used according to that rule; the measurement
is the outcome of the rule's implementation, not of a real client.

## The Server's One Statement

```js
// policy.mjs — the server's one statement on cross-origin sharing: response headers
export const SETTING = {
  origins: ["https://loans.library", "https://panel.library"],   // "*" means wildcard
  methods: ["GET", "POST"],
  headers: ["content-type", "x-loan-trace"],
  credentials: false,
  preflightMaxAge: 600,
};

export function headers(request, setting) {
  const h = {};
  if (!request.origin) return h;                       // same-origin request; no statement needed
  const wildcard = setting.origins === "*";
  if (!wildcard && !setting.origins.includes(request.origin)) return h;   // silence = reject
  h["access-control-allow-origin"] = wildcard ? "*" : request.origin;
  if (setting.credentials) h["access-control-allow-credentials"] = "true";
  if (!wildcard) h["vary"] = "origin";                 // the response varies by origin
  if (request.method === "OPTIONS") {
    h["access-control-allow-methods"] = setting.methods.join(",");
    h["access-control-allow-headers"] = setting.headers.join(",");
    h["access-control-max-age"] = String(setting.preflightMaxAge);
  }
  return h;
}
```

```js
// client.mjs — the decision a compliant client makes by reading the response headers
// The rule is written here for this lesson; the server setting's correctness is measured against it.
export function decide(request, h) {
  if (!request.origin) return { used: true, reason: "same origin" };
  const allow = h["access-control-allow-origin"];
  if (!allow) return { used: false, reason: "no allow header" };
  if (allow !== "*" && allow !== request.origin) return { used: false, reason: "origin mismatch" };
  if (request.credentials) {
    if (allow === "*") return { used: false, reason: "wildcard + credentials" };
    if (h["access-control-allow-credentials"] !== "true")
      return { used: false, reason: "no credentials permission" };
  }
  return { used: true, reason: "" };
}
```

The policy layer's rejection is not an error, it is **silence**: it adds no header at all to an
origin it does not recognize. The server still returns 200, and the body is still written. This
design decision is the source of every silent outcome in the rest of the lesson.

## The Origins a Wildcard Setting Opens

Seven origins are taken: the loan system's own interface, the internal panel, the same name with a
different scheme and port, a subdomain, an unrelated origin, and a same-origin request that
carries no origin header at all.

```js
// origin.mjs — seven origins, four settings; what the server returns versus what the client uses
import { SETTING, headers } from "./policy.mjs";
import { decide } from "./client.mjs";

const ORIGINS = [
  null,                                 // same origin (no header)
  "https://loans.library",              // its own interface
  "https://panel.library",              // internal panel
  "http://loans.library",               // different scheme
  "https://loans.library:8443",         // different port
  "https://sub.loans.library",          // subdomain
  "https://other.site",                 // unrelated
];

const SETTINGS = [
  ["list, no credentials", { ...SETTING }],
  ["wildcard, no credentials", { ...SETTING, origins: "*" }],
  ["wildcard, credentials", { ...SETTING, origins: "*", credentials: true }],
  ["list, credentials", { ...SETTING, credentials: true }],
];

const passes = (setting, origin, credentials) => {
  const request = { origin, method: "GET", credentials };
  return decide(request, headers(request, setting)).used;
};

console.log(`${ORIGINS.length} origins (1 same origin + ${ORIGINS.length - 1} external origins), ${SETTINGS.length} settings`);
console.log(`\n${"setting".padEnd(24)}${"server 2xx".padStart(11)}${"client used: no credentials".padStart(29)}${"credentials".padStart(13)}`);
for (const [name, setting] of SETTINGS) {
  const k0 = ORIGINS.filter((o) => passes(setting, o, false)).length;
  const k1 = ORIGINS.filter((o) => passes(setting, o, true)).length;
  console.log(name.padEnd(24) + String(ORIGINS.length).padStart(11) + String(k0).padStart(29) + String(k1).padStart(13));
}

const wildcardOpen = ORIGINS.filter((o) => o && passes({ ...SETTING, origins: "*" }, o, false));
const listOpen = ORIGINS.filter((o) => o && passes(SETTING, o, false));
console.log(`\nexternal origins the list setting opens: ${listOpen.length} (${listOpen.join(", ")})`);
console.log(`external origins the wildcard setting opens in this set: ${wildcardOpen.length}; no limit outside the set`);

console.log(`\nrequests carrying credentials under the 'wildcard, credentials' setting:`);
for (const o of ORIGINS.slice(1, 4)) {
  const request = { origin: o, method: "GET", credentials: true };
  const h = headers(request, { ...SETTING, origins: "*", credentials: true });
  const c = decide(request, h);
  console.log(`  ${o.padEnd(30)} server 200 + ${Object.keys(h).length} headers -> client: ${c.used ? "used" : "dropped (" + c.reason + ")"}`);
}
```

```
7 origins (1 same origin + 6 external origins), 4 settings

setting                  server 2xx  client used: no credentials  credentials
list, no credentials              7                            3            1
wildcard, no credentials          7                            7            1
wildcard, credentials             7                            7            1
list, credentials                 7                            3            3

external origins the list setting opens: 2 (https://loans.library, https://panel.library)
external origins the wildcard setting opens in this set: 6; no limit outside the set

requests carrying credentials under the 'wildcard, credentials' setting:
  https://loans.library          server 200 + 2 headers -> client: dropped (wildcard + credentials)
  https://panel.library          server 200 + 2 headers -> client: dropped (wildcard + credentials)
  http://loans.library           server 200 + 2 headers -> client: dropped (wildcard + credentials)
```

The `server 2xx` column is seven in all four settings. The server rejects no request; the setting
only decides whether the client uses the response. The list setting is open to two external
origins; the wildcard setting is open to six in this set, and **there is no limit outside the
set** — every origin whose name was never written down gets in, scheme difference, port
difference, and subdomain included. The difference between wildcard and list in this row is 2
against 6; in reality it is 2 against unlimited.

The third row is this lesson's silent setting. `credentials` is turned on, the server sends both
headers, the request returns 200 — and the client **drops** the response. The `wildcard,
credentials` row is identical to the `wildcard, no credentials` row: turning the setting on
changed nothing. In a log pile, these two settings cannot be told apart; both cases have 200, no
error, no latency. Only the internal panel fails to work, and the reason is invisible on the
server side.

The correct setting is the last row: an origin list **and** credentials permission together. The
three credentialed requests are used only there.

## The Cost of Preflight

The policy layer is measured in a real process. A request carrying a custom header requires a
**preflight** request before it is sent; the preflight's result can be cached for a lifetime.

```js
// server.mjs — local process listening on the loan endpoints; the policy layer sits in front of the endpoints
import http from "node:http";
import { headers } from "./policy.mjs";

export const ENDPOINTS = ["/catalog", "/loans", "/member", "/report", "/archive"];

export function start(setting, covered = ENDPOINTS) {
  const counter = { request: 0, preflight: 0, bytes: 0 };
  const s = http.createServer((q, y) => {
    counter.request++;
    if (q.method === "OPTIONS") counter.preflight++;
    const request = { origin: q.headers.origin ?? null, method: q.method };
    const h = covered.includes(q.url) ? headers(request, setting) : {};
    const body = q.method === "OPTIONS" ? "" : JSON.stringify({ endpoint: q.url, status: "ok" });
    counter.bytes += body.length +
      Object.entries(h).reduce((t, [k, v]) => t + k.length + v.length + 4, 0);
    y.writeHead(q.method === "OPTIONS" ? 204 : 200, { "content-type": "application/json", ...h });
    y.end(body);
  });
  return new Promise((c) => s.listen(0, "127.0.0.1", () =>
    c({ port: s.address().port, counter, close: () => s.close() })));
}
```

```js
// measurement.mjs — real requests: the cost of preflight and the measure of the layer's coverage
import { start, ENDPOINTS } from "./server.mjs";
import { SETTING } from "./policy.mjs";
import { decide } from "./client.mjs";

const ORIGIN = "https://panel.library";
const N = 6;

// Compliant client: a request with a custom header asks for a preflight first, and the result is cached for its lifetime.
async function send(port, endpoint, { custom, cache, credentials = false }) {
  const base = `http://127.0.0.1:${port}`;
  if (custom && !(cache && cache.has(endpoint))) {
    const o = await fetch(base + endpoint, { method: "OPTIONS", headers: { origin: ORIGIN } });
    if (cache) cache.set(endpoint, Number(o.headers.get("access-control-max-age") ?? 0));
  }
  const headers = { origin: ORIGIN, ...(custom ? { "x-loan-trace": "1" } : {}) };
  const y = await fetch(base + endpoint, { headers });
  const h = Object.fromEntries(y.headers);
  return { status: y.status, decision: decide({ origin: ORIGIN, credentials }, h) };
}

const { port, counter, close } = await start(SETTING);

const run = async (name, option) => {
  const before = { ...counter };
  for (let i = 0; i < N; i++) await send(port, "/catalog", option);
  console.log(`${name.padEnd(28)}${String(counter.request - before.request).padStart(8)}` +
    `${String(counter.preflight - before.preflight).padStart(12)}${String(counter.bytes - before.bytes).padStart(9)}`);
};

console.log(`${N} real requests, one endpoint\n`);
console.log(`${"request shape".padEnd(28)}${"round trip".padStart(8)}${"preflight".padStart(12)}${"bytes".padStart(9)}`);
await run("plain (no preflight needed)", { custom: false });
await run("custom header, no cache", { custom: true });
await run("custom header, cached", { custom: true, cache: new Map() });
console.log(`preflight lifetime: ${SETTING.preflightMaxAge} s -> 1 preflight per endpoint per lifetime`);
close();

// Layer coverage: the policy is registered on four of five endpoints.
const MISSING = ENDPOINTS.filter((u) => u !== "/archive");
const second = await start({ ...SETTING, credentials: true }, MISSING);
console.log(`\npolicy registered on ${MISSING.length}/${ENDPOINTS.length} endpoints\n`);
console.log(`${"endpoint".padEnd(10)}${"server status".padStart(14)}${"client".padStart(9)}  reason`);
let dropped = 0;
for (const endpoint of ENDPOINTS) {
  const r = await send(second.port, endpoint, { custom: false, credentials: true });
  if (!r.decision.used) dropped++;
  console.log(endpoint.padEnd(10) + String(r.status).padStart(14) +
    (r.decision.used ? "used" : "dropped").padStart(9) + "  " + (r.decision.reason || "-"));
}
console.log(`\n${dropped} of ${ENDPOINTS.length} endpoints had their response dropped by the client; errors returned by the server: 0`);
second.close();
```

```
6 real requests, one endpoint

request shape               round trip   preflight    bytes
plain (no preflight needed)        6           0      618
custom header, no cache           12           6     1770
custom header, cached              7           1      810
preflight lifetime: 600 s -> 1 preflight per endpoint per lifetime

policy registered on 4/5 endpoints

endpoint   server status   client  reason
/catalog             200     used  -
/loans               200     used  -
/member              200     used  -
/report              200     used  -
/archive             200  dropped  no allow header

1 of 5 endpoints had their response dropped by the client; errors returned by the server: 0
```

The cost of preflight is a number independent of the run: six custom requests without a cache make
**twelve** round trips, twice as many. Once the lifetime setting takes effect, the count drops to
seven — one preflight per endpoint. Policy bytes nearly triple too: 618 against 1770.

This cost belongs only to requests with a custom header. The first row shows this: six requests
without a custom header produce no preflight at all. **The way to avoid preflight is not to loosen
the setting, but to simplify the shape of the request.**

## Where the Setting Sits

The last table is the layer question. The policy is registered on four of five endpoints; the
`/archive` endpoint was forgotten. That endpoint **accepts** requests, writes the body, and
returns 200. The response is dropped on the client, and there is no trace of it on the server
side: the error count is zero, the status code is the same as the other four endpoints.

How many places the setting is repeated in directly determines this risk. If the policy is
registered separately on every endpoint handler, there are five write sites, and a sixth endpoint
needs a sixth write site added by hand. If the policy sits as a single layer in front of the
endpoints, there is one write site and a new endpoint is covered automatically — the `covered`
list inside `server.mjs` is exactly the difference between these two choices.

## Summary

- The server's one statement on cross-origin sharing is the response headers; its rejection is not
  an error, it is silence. In all four settings, the server returned 2xx to all seven of the
  seven requests.
- The origin list is open to 2 external origins; the wildcard setting is open to 6 origins in the
  measured set and to an unlimited number outside it — scheme difference, port difference, and
  subdomain included.
- Adding credentials permission to the wildcard setting changed nothing: the `wildcard,
  credentials` row was identical to `wildcard, no credentials`. The server sent both headers,
  returned 200, and the client dropped the response. The correct setting uses an origin list
  together with credentials permission.
- The cost of preflight is independent of the run: 6 custom requests without a cache make 12 round
  trips and 1770 bytes; with the lifetime setting, 7 round trips and 810 bytes; 6 requests without
  a custom header make 0 preflights.
- With the policy registered on 4 of 5 endpoints, the response from the skipped endpoint was
  dropped on the client and the server showed 0 errors. Per-endpoint registration repeats at 5
  points; a single layer in front of the endpoints stops at 1 point and covers a new endpoint
  automatically.

## Next Step

The cross-origin sharing setting was nothing more than a set of response headers, and the
measurement showed that the real question is **which layer** produces the header. The same
question applies to the other headers a response carries: the ones that say how the connection
should be established, how the content should be interpreted, and what the browser may load can
just as well be scattered endpoint by endpoint or placed from a single site. The next lesson
counts these headers: how many endpoints they repeat on, how many files moving them to a single
layer touches, what behavior a missing header silently leaves open, and where the point at which
the encrypted connection terminates fits into this picture.
