CORS Explained: The Browser Security Model Everyone Gets Wrong

CORS is the browser security mechanism that controls cross-origin requests. The why, the headers, the preflight dance, and the common mistakes that cost teams weeks of debugging.

CORS Explained: The Browser Security Model Everyone Gets Wrong

You’ve fetched data from your own API in development. It works. You deploy it to production where the API is on a different subdomain. It breaks with Access-Control-Allow-Origin errors. You spend two hours adding random headers until something works. You’ve just been bitten by CORS.

CORS (Cross-Origin Resource Sharing) is the browser security model that controls when JavaScript on one origin can make requests to another origin. It’s misunderstood, often misconfigured, and the source of disproportionate developer frustration. This post explains what CORS actually is, what problems it solves, the request flow, and the configuration patterns that work in production.

The Same-Origin Policy

CORS is the relaxation of an older browser policy: the Same-Origin Policy (SOP).

By default, JavaScript running on https://example.com cannot read responses from https://api.example.com (or https://anything-else.com). This is the SOP — origins are isolated from each other.

“Same origin” means the same scheme + host + port:

  • https://example.com and http://example.com — different origins (scheme).
  • https://example.com and https://api.example.com — different origins (host).
  • https://example.com and https://example.com:8080 — different origins (port).

The SOP exists because of a real attack: a malicious site could otherwise have your browser make authenticated requests to your bank, your email, your admin panel, and read the responses. SOP blocks this by default.

Why CORS Exists

The SOP is too restrictive for the modern web. Legitimate apps regularly need to request data from a different origin: the frontend on app.example.com calling an API on api.example.com, or a JavaScript SDK calling analytics.thirdparty.com.

CORS is the mechanism that lets servers say “I’m OK being called by JavaScript from origin X.” The browser checks the server’s response headers and decides whether to expose the response to the JavaScript.

The key insight: CORS is enforced by the browser, not the server. The server can return whatever it wants; the browser decides whether the JavaScript can read it. This is why CORS errors look weird — the request succeeded, but the JS got an error.

Simple Requests

The simplest CORS flow:

  1. JavaScript makes a request to https://api.example.com/data from a page on https://app.example.com.
  2. Browser includes the Origin header: Origin: https://app.example.com.
  3. Server responds normally but adds: Access-Control-Allow-Origin: https://app.example.com (or * for any origin).
  4. Browser sees the matching header and exposes the response to JavaScript.

If the server doesn’t include the Access-Control-Allow-Origin header (or it doesn’t match the origin), the response is blocked from JavaScript. The fetch resolves but response is essentially opaque.

Simple requests are limited to:

  • Methods: GET, HEAD, POST.
  • Headers: a small allowlist (Accept, Content-Type with limited values, etc.).
  • No custom headers like Authorization.

If the request goes beyond these constraints, you’re in preflighted territory.

Preflighted Requests

When the request can’t be simple (custom headers, non-GET/POST method, etc.), the browser sends an OPTIONS request first to check whether the server permits the real request.

OPTIONS /data HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization, Content-Type

The server responds:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

If the response allows the method and headers, the browser then sends the actual request. If it doesn’t, the actual request is never sent and JavaScript gets a CORS error.

The Access-Control-Max-Age lets browsers cache the preflight result (so they don’t ask before every request). Setting it appropriately (a few hours minimum) eliminates redundant OPTIONS round trips.

The Headers

A quick reference:

Sent by the browser (on the request)

  • Origin: https://app.example.com — Where the request is coming from.
  • Access-Control-Request-Method: PUT — (preflight) What method the real request will use.
  • Access-Control-Request-Headers: Authorization — (preflight) What headers the real request will send.

Sent by the server (on the response)

  • Access-Control-Allow-Origin: https://app.example.com — Which origin is allowed (or * for any).
  • Access-Control-Allow-Methods: GET, POST, PUT, DELETE — Which methods are allowed.
  • Access-Control-Allow-Headers: Authorization, Content-Type — Which request headers are allowed.
  • Access-Control-Allow-Credentials: true — Whether cookies / auth can be included.
  • Access-Control-Expose-Headers: X-Custom-Header — Which response headers JavaScript can read (defaults: a small set).
  • Access-Control-Max-Age: 86400 — How long browsers can cache the preflight response.

Credentials and the * Trap

The Access-Control-Allow-Origin: * is the most permissive setting — any origin can call the API. But it doesn’t work with credentials.

If you want JavaScript to send cookies or auth headers cross-origin:

  • Frontend must set credentials: 'include' on the fetch.
  • Server must set Access-Control-Allow-Origin to a specific origin (not *).
  • Server must set Access-Control-Allow-Credentials: true.
// Frontend
const response = await fetch('https://api.example.com/me', {
    credentials: 'include'
})

// Server response headers
res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com')
res.setHeader('Access-Control-Allow-Credentials', 'true')

This is the most common CORS mistake: trying to use Allow-Origin: * with credentials and getting confused why cookies aren’t sent.

Common Mistakes

Setting Allow-Origin: * with credentials

The browser will block the request. Always set a specific origin with credentials.

Not handling OPTIONS

Your routes handle GET and POST but not OPTIONS. Preflight fails with 404 or 405. Add a generic OPTIONS handler that returns the right CORS headers.

Returning CORS headers only on success

Your error pages return 500 without CORS headers. The browser sees no Access-Control-Allow-Origin and blocks the error response. JavaScript gets a generic CORS error instead of the actual error message.

Always include CORS headers on every response, including errors. Otherwise debugging breaks.

Origin allowlist that’s too narrow

Subdomain mismatch (https://www.example.com vs https://example.com). Use a function or regex to validate origins.

Wildcard subdomain handling

Browsers don’t support wildcard subdomains like https://*.example.com in Access-Control-Allow-Origin. You have to echo back the matched origin:

const allowedOrigins = /\.example\.com$/
const origin = req.headers.origin
if (allowedOrigins.test(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin)
}
res.setHeader('Vary', 'Origin')  // Important for caching!

The Vary: Origin is essential — otherwise CDNs cache the response with one origin’s header and serve it to other origins.

Not setting Vary: Origin

The response is cached at CDN with origin A’s Allow-Origin header. A user from origin B requests; gets origin A’s headers; browser blocks.

Vary: Origin makes the cache key include the Origin header.

Believing CORS is for security

CORS is not a server-side security mechanism. It’s a browser-side relaxation of the SOP. Server-to-server requests, CLI tools, and non-browser clients ignore CORS entirely.

If your server should reject requests from certain origins, validate at the server level (auth, API keys, IP allowlist) — not via CORS.

CORS with a Reverse Proxy

A common pattern: put the API and the frontend on the same origin via a reverse proxy, so CORS isn’t needed at all.

https://example.com/         → frontend (static)
https://example.com/api/*    → backend

Both routes share the same origin. No CORS. No preflight. Simpler.

This is the cleanest approach when feasible. CDNs like Cloudflare make this easy via Workers; nginx/Caddy can do it natively.

CORS and Cloud Storage

S3, GCS, Azure Blob Storage all have CORS configuration. When you serve assets directly from a storage bucket and want JavaScript to read them (e.g., for canvas processing or font loading), you must configure CORS at the bucket level.

{
    "CORSRules": [{
        "AllowedOrigins": ["https://example.com"],
        "AllowedMethods": ["GET"],
        "AllowedHeaders": ["*"],
        "MaxAgeSeconds": 86400
    }]
}

If you serve through a CDN in front of the bucket, configure CORS at the CDN instead.

CORS in Common Frameworks

Express

import cors from 'cors'
app.use(cors({ origin: 'https://app.example.com', credentials: true }))

FastAPI

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Laravel

Configure config/cors.php:

'paths' => ['api/*'],
'allowed_origins' => ['https://app.example.com'],
'allowed_methods' => ['*'],
'supports_credentials' => true,

Go (gorilla/handlers)

import "github.com/gorilla/handlers"
http.ListenAndServe(":8080", handlers.CORS(
    handlers.AllowedOrigins([]string{"https://app.example.com"}),
    handlers.AllowCredentials(),
)(router))

Debugging CORS

Steps that work:

  1. Open DevTools → Network. Find the failing request.
  2. Check if a preflight (OPTIONS) was sent. If yes, look at its response — is Access-Control-Allow-Origin present? Does it match?
  3. Check Console. CORS errors include specific details about which header was missing or didn’t match.
  4. Test with curl to verify the server is sending the headers you expect. CORS issues only manifest in browsers; curl ignores CORS.
  5. Use a CORS test tool like test-cors.org if you want to verify a public endpoint.

The trap: CORS errors in DevTools say something vague like “Access blocked by CORS policy.” The actual fix is almost always at the server: add or fix a header.

TL;DR

  • CORS controls cross-origin JavaScript requests in the browser.
  • Browsers enforce it, not servers. Server returns headers; browser decides.
  • Simple requests (GET/POST, basic headers) require only Access-Control-Allow-Origin.
  • Preflighted requests require an OPTIONS response with explicit Method/Header allowlists.
  • Credentials require a specific origin, not *.
  • Always include CORS headers on errors, not just success.
  • Set Vary: Origin when allowlisting multiple origins.
  • CORS is not security. Validate server-side; CORS is browser UX policy.
  • Same-origin via reverse proxy eliminates CORS entirely.

CORS is one of those topics where the documentation is correct but somehow nobody seems to read it. Once you understand the simple model (browser enforces; server signals permission), the headers stop being magic. For related security-context topics, see TLS handshake; for application-level patterns, IP geolocation in Node.js.

Get Started

Convert IPs into accurate location data in milliseconds.

Sign up today and get 1,000 free monthly stored conversions, and discover why developers trust us for fast, reliable, and affordable IP conversions.