CORS, Origins, and Browser Enforcement

LESSON

HTTP Protocol and Content Delivery

010 25 min intermediate

CORS, Origins, and Browser Enforcement

The core idea: CORS is the browser's permission check for exposing cross-origin responses to JavaScript, not the server's authorization system, so the useful design question is which origin may read which response under which credentials.

Core Insight

Imagine a checkout frontend running at https://www.shop.test. It calls an API at https://api.shop.test/cart/summary. The API is healthy. curl returns 200 OK. The server logs show the request arriving. But in the browser console, the frontend sees a CORS error and the JavaScript code cannot read the response body.

That failure feels strange until you separate two decisions. The server can decide whether to process the request. The browser decides whether script from one origin is allowed to read a response from another origin. CORS, or Cross-Origin Resource Sharing, is the mechanism that lets a server opt in to that browser-side exposure. It does not prove who the user is, it does not protect the server from non-browser clients, and it does not replace authorization checks.

The important boundary is the origin. An origin is the tuple of scheme, host, and port. https://www.shop.test, https://api.shop.test, http://www.shop.test, and https://www.shop.test:8443 are different origins. They may belong to the same company and even the same "site" for cookie purposes, but browser JavaScript treats them as different origins.

The trade-off is frontend integration versus cross-origin data exposure. Modern applications often split the user interface, API, assets, identity provider, and analytics across different hosts. Without a cross-origin sharing mechanism, that architecture would be painful. With an overly broad one, a page from the wrong origin may be able to read private API responses. A good CORS policy is therefore a small contract: these origins may read these responses, with these methods, headers, and credential rules.

What the Browser Is Protecting

The browser runs code from many places in the same user session. A user can be logged in to shop.test, then open an unrelated page in another tab. That unrelated page can contain JavaScript. Without browser isolation, that script could try to read private data from every service the user can reach.

The same-origin policy is the baseline rule that prevents that. A script loaded from one origin is restricted when it tries to read data from another origin. The browser may still allow many cross-origin actions: loading images, submitting forms, following redirects, or sending some requests. The sensitive action is exposing the response to the calling script.

That distinction is easy to miss:

request reaches server     -> maybe yes
server processes request   -> maybe yes
browser exposes response   -> only if same-origin or CORS allows it

This is why curl is not a CORS test. curl is not a browser executing untrusted page JavaScript inside a user's session. It does not enforce the browser's same-origin policy, and it does not need Access-Control-Allow-Origin to print a response. curl can prove that the server is reachable and that the HTTP response exists. It cannot prove that browser JavaScript is allowed to read that response.

The browser makes the CORS decision from request context and response headers. The client script says what it wants to fetch. The browser adds an Origin header on cross-origin requests. The server replies with headers that state which origins, methods, headers, and credential modes it allows. Then the browser either exposes the response to JavaScript or blocks script from seeing it.

The Simple Cross-Origin Read

Start with a read-only API call from the checkout frontend:

const response = await fetch("https://api.shop.test/cart/summary", {
  credentials: "include"
});

const cart = await response.json();

The frontend origin is https://www.shop.test. The API origin is https://api.shop.test. Because the origins differ, the browser sends a cross-origin request with an Origin header:

GET /cart/summary HTTP/1.1
Host: api.shop.test
Origin: https://www.shop.test
Cookie: shop_session=s_abc123

The cookie line appears only if the cookie scope and the fetch credentials mode allow it. That was the previous lesson's mechanism: cookie attributes and request context decide whether credentials ride along. CORS answers a different question: after the server responds, may script from https://www.shop.test read the response?

A compatible response looks like this:

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://www.shop.test
Access-Control-Allow-Credentials: true
Vary: Origin
Cache-Control: private, no-store

{"items":3,"subtotal":"48.00"}

Access-Control-Allow-Origin names the origin that may read the response. Because this request includes credentials, the value must be a specific origin; * is not valid for credentialed exposure. Access-Control-Allow-Credentials: true tells the browser that the server permits exposing a credentialed response to the allowed origin. Vary: Origin matters when caches are involved: if the response differs by request origin, shared infrastructure must not reuse an allow decision for the wrong origin.

Now compare a broken response:

HTTP/1.1 200 OK
Content-Type: application/json

{"items":3,"subtotal":"48.00"}

The server returned a perfectly normal HTTP response. The browser still refuses to hand the body to the calling script because the response does not opt in to cross-origin exposure. In developer tools, the network panel may show the request and response. The JavaScript promise fails as a CORS error. From the application code's point of view, it cannot inspect the status code or body in the usual way.

That is the first practical mental model: CORS failure is often a successful HTTP exchange that the browser withholds from JavaScript.

Preflight: Asking Before the Real Request

Some cross-origin requests are considered simple enough that the browser can send them directly and check the response afterward. Others require a preflight. A preflight is an OPTIONS request the browser sends before the actual request to ask whether the cross-origin operation is allowed.

Suppose the cart UI updates an item:

await fetch("https://api.shop.test/cart/items/17", {
  method: "PATCH",
  credentials: "include",
  headers: {
    "Content-Type": "application/json",
    "X-CSRF-Token": csrfToken
  },
  body: JSON.stringify({ quantity: 2 })
});

This is not a simple cross-origin request. It uses PATCH, JSON content, and a custom header. Before sending it, the browser asks:

OPTIONS /cart/items/17 HTTP/1.1
Host: api.shop.test
Origin: https://www.shop.test
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: content-type, x-csrf-token

The server, gateway, or edge layer must answer the preflight:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://www.shop.test
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, PATCH, OPTIONS
Access-Control-Allow-Headers: content-type, x-csrf-token
Access-Control-Max-Age: 600
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers

If the answer permits the requested method and headers, the browser sends the actual PATCH. If the answer does not permit them, or if an intermediary blocks OPTIONS, the browser stops before the mutation request is sent. This is different from the simple request case, where the request may reach the server and only the response exposure is blocked afterward.

Preflight is not an authentication ceremony. It is a browser permission check about cross-origin method and header use. The server should usually make it cheap and predictable. The actual PATCH still needs normal authentication, authorization, input validation, idempotency or conflict handling where appropriate, and CSRF protection if cookie credentials are used.

The step trace is:

script wants PATCH with custom header
-> browser sees cross-origin non-simple request
-> browser sends OPTIONS preflight with requested method and headers
-> server returns allowed origin, methods, headers, credentials policy
-> browser either sends or suppresses the actual PATCH
-> browser exposes the final response only if CORS allows it too

Each arrow is a separate place to debug. A missing OPTIONS route is different from a missing Access-Control-Allow-Headers. A valid preflight does not mean the actual request will be authorized. A successful actual request does not mean the browser will expose the response if the final response lacks the right CORS headers.

Credentials Make the Policy Smaller

Credentialed CORS is where many production mistakes happen. Browser fetch has a credentials mode. Cookies and some other credentials are not automatically included in every cross-origin fetch; the client usually has to opt in:

fetch("https://api.shop.test/cart/summary", {
  credentials: "include"
});

Once credentials are involved, the server's CORS response has to be narrower. This combination is valid:

Access-Control-Allow-Origin: https://www.shop.test
Access-Control-Allow-Credentials: true

This combination is not valid for exposing a credentialed response:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

The reason is simple: a wildcard says any origin may read the response. Credentials mean the response may contain data tied to a logged-in user. Combining "any origin" with "credentialed private data" would defeat the browser's cross-origin isolation.

A common server implementation keeps an allowlist:

allowed origins:
- https://www.shop.test
- https://admin.shop.test
- https://checkout.partner.example

When a request arrives with an Origin, the server checks whether the origin is in the list. If yes, it echoes that exact origin in Access-Control-Allow-Origin and sets Vary: Origin. If no, it does not send an allow header. The response may still be a normal HTTP response, but browser script from the disallowed origin cannot read it.

Do not turn this into "reflect whatever Origin was sent." The Origin header is request metadata, not proof of trust. Non-browser clients can send whatever value they want. A browser will enforce the result for JavaScript, but the server should not treat the presence of an allowed-looking origin as authentication. The server still needs its real identity and authorization checks.

Worked Path: Fixing the Checkout API

The team sees this browser error:

Access to fetch at 'https://api.shop.test/cart/summary'
from origin 'https://www.shop.test' has been blocked by CORS policy.

They trace the path in order.

First, they confirm the browser context:

page origin: https://www.shop.test
request URL: https://api.shop.test/cart/summary
same origin? no
needs CORS? yes
needs cookies? yes, cart is session-backed

Second, they inspect the request:

GET /cart/summary HTTP/1.1
Host: api.shop.test
Origin: https://www.shop.test
Cookie: shop_session=s_abc123

The cookie is present, so the issue is not cookie transport. The server logs show an authenticated user and a 200 response.

Third, they inspect the response headers and find no CORS headers. They add a narrow policy at the API gateway:

if Origin is https://www.shop.test:
  Access-Control-Allow-Origin: https://www.shop.test
  Access-Control-Allow-Credentials: true
  Vary: Origin

Fourth, they test a mutation and discover that PATCH fails before reaching the application. The gateway did not route OPTIONS, so preflight returns 404. They add an OPTIONS response for allowed methods and headers, including content-type and x-csrf-token.

Finally, they keep the application authorization unchanged. The cart API still checks the session, confirms the user owns the cart, validates the CSRF token on unsafe methods, and returns private cache policy. CORS solved browser exposure. It did not replace the business decision about who may read or change a cart.

The useful review question after the fix is not "did we enable CORS?" It is:

Which origins can read which responses, with which credentials,
and what server-side check still authorizes the action?

Operational Failure Modes

Failure: treating CORS as server protection. CORS is enforced by browsers. It does not stop curl, a backend job, a mobile app, or a malicious non-browser client from sending HTTP requests. Protect the server with authentication, authorization, rate limits, validation, and CSRF defenses where relevant.

Failure: allowing every origin with credentials. Reflecting arbitrary origins while also setting Access-Control-Allow-Credentials: true effectively lets any website read credentialed responses in supporting browsers. Use a real allowlist, keep it small, and review it like any other security boundary.

Failure: forgetting caches. If the API echoes different Access-Control-Allow-Origin values depending on the request, add Vary: Origin. For preflight responses, also consider varying by requested method and headers. Otherwise a shared cache or edge layer can reuse an allow decision in the wrong context.

Failure: debugging only the final request. A blocked PATCH may be failing at preflight before the application sees anything. Look for the OPTIONS request, the requested method, the requested headers, and the response headers on the preflight.

Failure: assuming CORS prevents CSRF. A simple cross-origin form submission can still reach the server with cookies depending on cookie settings and browser context. CORS may prevent the hostile page from reading the response, but the state change may already have happened. Unsafe cookie-authenticated actions still need CSRF protection or another request-specific proof of intent.

Useful signals include browser console CORS errors, network traces that show OPTIONS failures, server logs with successful responses that JavaScript cannot read, unexpected Origin values on sensitive routes, and edge-cache entries that vary by origin. When a report says "the API returns 200 but the app says it failed," inspect both the HTTP exchange and the browser exposure decision.

Connections

The cookie lesson explained how browsers decide whether credentials are attached to a request. This lesson adds the next gate: even when credentials are attached and the server responds, browser JavaScript may not be allowed to read the response.

The next lesson moves identity into explicit HTTP authentication and authorization headers. That is a different transport style from cookies, but CORS can still matter for browser clients because the browser still controls cross-origin response exposure.

Close the lesson and reconstruct one request from memory: page origin, request URL, credentials mode, preflight or no preflight, response CORS headers, and the separate server authorization check. If you cannot name each piece, you will have trouble debugging the next real CORS incident.

Resources

Key Takeaways

PREVIOUS Cookies, SameSite, and Session Transport NEXT HTTP Authentication and Authorization Headers