Skip to main content

Understanding CORS Headers: Why the Browser Blocks Cross-Origin Requests

A practical guide to CORS and the Access-Control-Allow headers: why browsers block cross-origin calls, how preflight works, and the credentials wildcard trap.

Published By Li Lei
#cors #web-security #http-headers #frontend #backend

Understanding CORS Headers: Why the Browser Blocks Cross-Origin Requests

If you have ever opened your browser console and seen the message "blocked by CORS policy," you have met the part of the web that confuses backend and frontend developers in equal measure. The request looked fine. The server returned a 200. And yet your JavaScript never got to read the response. CORS is doing exactly what it was designed to do, and once you see the mechanics, the fix is usually three response headers long.

This post walks through what CORS actually checks, why the browser is the one enforcing it, what a preflight request is, how credentials change the rules, and the one combination that is forbidden no matter how you configure it.

What CORS Is Actually Protecting

CORS stands for Cross-Origin Resource Sharing. An origin is the triple of scheme, host, and port: https://app.example.com is a different origin from https://api.example.com, and also different from http://app.example.com (different scheme) or https://app.example.com:8443 (different port).

By default, JavaScript running on one origin cannot read a response from a different origin. This is the Same-Origin Policy, and it predates CORS. The reason it exists: your browser carries your cookies for every site you are logged into. Without this rule, any random page you visit could quietly fetch https://your-bank.com/account using your session and read the balance back. The browser stops that by refusing to hand the response body to the calling script.

CORS is the controlled opt-out. It lets a server say "this specific other origin is allowed to read my responses" without throwing the door open to everyone. Critically, the server does not block anything — the browser does. The request often reaches the server and even runs; the browser simply withholds the response from the script if the headers do not grant permission. That is why a tool like curl never sees a CORS error: curl is not a browser and does not enforce the policy.

The Header That Grants Permission

The core of the whole system is one response header. The server sends Access-Control-Allow-Origin to permit a cross-origin caller. If a page on https://app.example.com calls your API and your API responds with:

Access-Control-Allow-Origin: https://app.example.com

then that one site is allowed to read the response. Every other origin still gets blocked. You can also send a wildcard:

Access-Control-Allow-Origin: *

which lets any site read the response. That is perfectly fine for a public, read-only JSON feed where there are no cookies and no auth. It is the wrong choice the moment private data or sessions are involved — more on that below.

Two companion headers describe what kinds of requests are allowed: Access-Control-Allow-Methods lists the HTTP verbs you accept (GET, POST, PUT, DELETE, PATCH), and Access-Control-Allow-Headers lists the request headers the caller may send (Content-Type, Authorization, and any custom ones like X-Requested-With).

Preflight: The Permission Check Before the Real Request

Here is where the "my request never fired" mystery comes from. For anything beyond a so-called simple request, the browser does not just send your call and hope. It first sends a separate OPTIONS request to ask permission. This is the preflight.

A simple request is roughly a GET or a POST with a plain content type (like text/plain or a form encoding) and no unusual headers. The instant you step outside that — a PUT, a DELETE, a JSON body with Content-Type: application/json, or an Authorization header — the browser inserts a preflight OPTIONS request that checks the method and headers your real call wants to use. The server answers the preflight with the methods and headers it permits. If the answer does not cover what you need, the real request is never sent.

So if your fetch sends a PUT carrying an Authorization header, the preflight response must include PUT in Access-Control-Allow-Methods and Authorization in Access-Control-Allow-Headers. Miss either one and the browser stops cold, even though a basic GET to the same endpoint worked perfectly. You can cache that preflight answer with Access-Control-Max-Age: 86400, which tells the browser it may reuse the permission for a day instead of asking before every request.

A Worked Example: Allowing One Origin to POST JSON

Say your single-page app at https://app.example.com needs to POST a JSON body to https://api.example.com. Because the content type is application/json, this triggers a preflight. The response headers your API must return look like this:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

The OPTIONS preflight should return those headers with a 204 No Content status. The actual POST then returns the same Access-Control-Allow-Origin line alongside the real response body. Note that the origin is named explicitly, not wildcarded — that is deliberate, and it becomes mandatory once cookies enter the picture.

The first time I set this up, I had everything right except I was only emitting the headers on the POST and not on the OPTIONS response. The browser kept failing the preflight silently, and I spent an embarrassing half hour staring at a 200 in the network tab wondering why fetch still rejected. The lesson stuck: the preflight is a real, separate response that needs its own headers, and the OPTIONS handler is the first place to look when a request dies before it leaves the browser.

Credentials and the Wildcard Trap

Credentials means cookies, HTTP authentication, or client certificates riding along with the request. To allow them, the server adds Access-Control-Allow-Credentials: true and the client sets credentials: 'include' on its fetch.

Now the forbidden combination: you cannot use * together with Access-Control-Allow-Credentials: true. The browser rejects it outright. The reason is the same threat the Same-Origin Policy guards against — * means "any website," and sending a logged-in user's cookies to any website would let a malicious page read their private data. The specification forbids it, so when credentials are enabled, Access-Control-Allow-Origin must name one concrete origin, never the wildcard.

In practice this means you maintain an allowlist of permitted origins, check the incoming Origin request header against it, and echo back the matching value:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

Do not be tempted to reflect every origin back blindly just to make the error go away — that re-creates the exact hole the rule exists to close.

One More: Reading Custom Response Headers

There is a quieter restriction worth knowing. Even after CORS lets your script read a response, it can only see a short safelist of response headers by default (Cache-Control, Content-Type, and a few others). If your API returns something custom — X-Total-Count for pagination, X-Request-Id for tracing — response.headers.get('X-Total-Count') returns null until you list it in Access-Control-Expose-Headers:

Access-Control-Expose-Headers: X-Total-Count, X-Request-Id

This one trips people up because nothing errors; the header is simply invisible to the client.

Generating Headers You Can Trust

Hand-typing these directives is exactly where the silent mistakes creep in: a missing always flag on an Nginx add_header, a forgotten OPTIONS handler, an Authorization header left out of the allowlist. The CORS Header Generator turns a handful of checkboxes — origin, methods, headers, credentials, max-age — into a ready-to-paste block for a raw header list, Nginx, Apache, or Express, and it flags the wildcard-with-credentials combination the instant you tick both. Once your CORS is sorted, it is worth running the rest of your responses through the HTTP Security Header Auditor to confirm you have not left the other protective headers missing while you were focused on cross-origin access.

CORS is not the browser being difficult. It is the browser refusing to leak your users' data to sites you never authorized. Once you treat the Access-Control headers as a precise grant — this origin, these methods, these headers, with or without credentials — the errors stop being mysterious and start being a checklist.


Made by Toolora · Updated 2026-06-13