Web

CORS (Cross-Origin Resource Sharing)

Browsers block cross-origin XHR by default — server's Access-Control-Allow-* headers grant exceptions

CORS is a browser security mechanism that restricts JavaScript on origin A from reading responses from origin B unless origin B explicitly permits it via response headers. Origin = scheme + host + port. Defined in W3C/Fetch standard (2014). The browser distinguishes "simple" requests (GET, HEAD, POST with simple content-type) from preflighted requests (PUT, DELETE, custom headers, JSON content type) — the latter triggers an OPTIONS preflight asking "may this origin do this?". Server replies with Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, etc. Famous misconfiguration: Access-Control-Allow-Origin: * combined with Access-Control-Allow-Credentials: true is a security catastrophe (browser refuses, but the misconfig is widespread).

  • Defined inFetch standard (W3C)
  • Originscheme + host + port
  • PreflightOPTIONS request
  • Simple methodsGET, HEAD, POST
  • HeadersAccess-Control-Allow-*
  • Common misconfig* + credentials

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

Why CORS matters

  • SaaS APIs. Stripe, Twilio, Algolia, Auth0, OpenAI all serve developer-facing JS clients hosted on customer domains. Their API gateways respond with Access-Control-Allow-Origin echoes per request, plus Vary: Origin so CDNs cache per-caller correctly. Without CORS, every customer would have to proxy through their own backend.
  • Embedded JS widgets. Disqus comments, Intercom chat, Zendesk widgets, Segment analytics — these load JS on third-party sites and post events back to their own domains. Each widget operator must list customer origins (or use * for fully public events).
  • Third-party integrations. Google Calendar, GitHub Apps, Slack apps fetch data via REST APIs from browser-side OAuth flows. Tokens travel cross-origin via specific A-C-A-O echoes plus credentials.
  • Microservices behind a single brand. When app.example.com needs api.example.com, technically these are different origins (different host) and CORS applies. Most SaaS architectures spend hours wiring this up before realizing they could have used a path-based reverse proxy.
  • Web fonts and asset hosting. Self-hosted fonts on a CDN need Access-Control-Allow-Origin headers or browsers refuse to download them. Cloudflare, Cloudfront, Fastly all expose this as a one-toggle setting now.
  • Security boundary. CORS is what prevents evil.com's JavaScript from silently reading your bank's API responses, your Gmail inbox content, your internal admin tool data — all while logged-in cookies are present. The web's modern threat model assumes CORS is correctly enforced.
  • WebRTC and real-time. Even WebSocket upgrades, EventSource, and Service Worker fetches are subject to origin checks. Bypassing CORS is a frequent question in Stack Overflow's top 10 — the answer is "configure the server, not the client."

A concrete preflight example

Suppose https://app.example.com wants to fetch("https://api.partner.com/v1/items", { method: "PUT", headers: { "Content-Type": "application/json", "Authorization": "Bearer abc" }, body: ... }). Browser steps.

  • Step 1 — Send preflight. Browser sends OPTIONS /v1/items with Origin: https://app.example.com, Access-Control-Request-Method: PUT, Access-Control-Request-Headers: authorization, content-type. No body.
  • Step 2 — Server replies. 204 No Content (or 200 OK) with Access-Control-Allow-Origin: https://app.example.com, Access-Control-Allow-Methods: GET, PUT, DELETE, Access-Control-Allow-Headers: Authorization, Content-Type, Access-Control-Max-Age: 86400 (cache preflight 24h).
  • Step 3 — Browser caches preflight. If allowed, the next ~86400 seconds of identical preflights skip the OPTIONS round trip. Critical for performance — without max-age, every single PUT triggers two round trips.
  • Step 4 — Send actual request. PUT /v1/items with full headers and body, including Origin.
  • Step 5 — Server responds with data. Response includes Access-Control-Allow-Origin: https://app.example.com again. Browser exposes response to JS only if the header matches.
  • Latency cost. A preflight adds 1 RTT; on a 100 ms RTT connection that's 100 ms per uncached preflight. Setting Access-Control-Max-Age high amortizes this; Chrome caps at 7200 s, Firefox at 86400 s.

Simple vs preflighted requests

  • Simple request criteria (all must hold). Method is GET, HEAD, or POST. Headers are limited to CORS-safelisted: Accept, Accept-Language, Content-Language, Content-Type (with restricted values), Range. Content-Type is application/x-www-form-urlencoded, multipart/form-data, or text/plain. No ReadableStream body.
  • Examples of simple. A form POST that submits application/x-www-form-urlencoded; a GET to a JSON endpoint without custom headers; a HEAD to check resource existence.
  • Examples of preflighted. Any fetch(..., { method: "DELETE" }); any request with Authorization: Bearer ...; any POST with Content-Type: application/json (the universal modern API style); any request with a custom X-Csrf-Token header.
  • Why the distinction exists. Pre-CORS forms could already POST cross-origin (HTML forms have always done this). To avoid breaking the web, simple requests are sent without preflight — the new restriction is on what JS can READ, not what gets sent. Preflighted requests are types the web couldn't already do, so they get a strict opt-in.
  • The 'simple' headers list trap. If your API requires any custom header at all — a CSRF token, an API version selector, anything — you have unavoidable preflights. There's no way to "turn off" preflight from the client side.

Server response headers reference

  • Access-Control-Allow-Origin. The origin allowed to read the response. Either * (anyone, no credentials) or one specific origin echoed from the request (with credentials). Multi-origin: server logic must inspect the Origin header and reflect it conditionally.
  • Access-Control-Allow-Methods. Comma-separated method list (GET, POST, PUT, DELETE). Only checked on preflight responses.
  • Access-Control-Allow-Headers. Comma-separated header list (Authorization, Content-Type, X-Custom). Cannot be * when credentials mode is on.
  • Access-Control-Allow-Credentials. true permits cookies / HTTP auth / TLS certs. Only valid value is true; absence means no credentials.
  • Access-Control-Expose-Headers. Beyond the safelist (Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma), the browser hides response headers from JS unless explicitly exposed. Need to read X-Total-Count from a paginated API response? Add it here.
  • Access-Control-Max-Age. Seconds the preflight result may be cached. Browsers cap at 7200 (Chrome) or 86400 (Firefox); higher values are silently truncated.
  • Vary: Origin. Critical with reflective Allow-Origin: caches must key on Origin to avoid serving customer A's response to customer B. Forgetting this causes intermittent CORS failures behind shared CDNs.

Implementation patterns

  • Express middleware. The cors npm package (~10 million weekly downloads) is the canonical middleware. app.use(cors({ origin: ["https://app.example.com"], credentials: true })) handles preflights automatically.
  • NGINX. Add add_header Access-Control-Allow-Origin $http_origin always with an if guard for OPTIONS to return 204. Be careful: NGINX add_header is suppressed on non-2xx responses unless always is set.
  • API Gateway / CloudFront. AWS API Gateway has a "Enable CORS" toggle that auto-generates OPTIONS handlers and adds headers. CloudFront response policies (since 2021) ship CORS-with-credentials presets.
  • Cloudflare Workers / Edge functions. One pattern: response.headers.set('Access-Control-Allow-Origin', allowedOrigins.has(origin) ? origin : ''). Always include Vary: Origin if the value is reflective.
  • Server-side fetch as escape hatch. When CORS can't be configured (third-party API you don't control), the only path is server-side proxying: your backend calls the API and exposes a same-origin endpoint to your JS. This adds latency, server cost, and rate-limit risk.

Common misconceptions

  • "CORS is server-side enforcement." No — CORS is browser-enforced. Curl, Postman, native mobile apps, and server-to-server calls ignore CORS entirely. Only browser-context JavaScript subject to SOP cares about Access-Control-* headers. CORS is not a server access-control mechanism; it's a browser policy relaxation.
  • "CORS prevents CSRF." Orthogonal. CSRF attacks succeed by triggering a state-changing request; CORS only hides the response from cross-origin JS. A POST that transfers money can still execute and succeed even if the response is hidden.
  • "Wildcard is fine." True for genuinely public, unauthenticated APIs. But * with credentials is silently broken; Chrome devtools log the failure but the app sees a generic network error. Production rule: never combine * with credentials.
  • "Disabling CORS solves my error." Browser extensions like 'Allow CORS' or running Chrome with --disable-web-security bypass the check locally — useful for debugging only. Production fixes go on the server side.
  • "CORS applies to everything." No — <img>, <script>, <link rel=stylesheet>, classic <form> submissions are exempt by default. They can load cross-origin without CORS, but JS can't INSPECT their content (image data, script source) without crossorigin attribute.
  • "localhost is same-origin." http://localhost:3000 and http://localhost:8080 are different origins (different ports) → CORS applies. Same scheme, different port still triggers preflights.
  • "Subdomains share origin." No — foo.example.com and bar.example.com are different origins. document.domain hack used to bridge them is deprecated in modern browsers.

Performance impact

  • Preflight overhead. One extra round trip per non-simple, non-cached request. On 100 ms RTT, that's a 2x latency hit on every PUT/DELETE. Set Access-Control-Max-Age: 86400 to cache aggressively.
  • Vary: Origin and CDN cache fragmentation. Reflective Allow-Origin forces caches to key on Origin header, multiplying cache entries. A SaaS with 10000 customer domains stores 10000 copies of every cacheable response.
  • Browser preflight cache size. Chrome caps at 1024 entries per origin pair; entries evict LRU. Heavy multi-domain apps occasionally see surprising preflight storms.
  • Chrome PNA (Private Network Access). Since 2023 Chrome adds preflights for requests from public origins to private IPs (192.168.*, 10.*, etc.) — local-network apps now need explicit CORS-with-credentials acceptance from internal services. Major source of "it broke in Chrome 117" tickets.

Frequently asked questions

Why does the browser block cross-origin requests by default?

The Same-Origin Policy (SOP) — the foundational web security model since Netscape Navigator 2.0 (1995) — prevents JavaScript from one origin reading responses from another. Without SOP, a malicious page could fetch your bank's account-details API while you are logged in, since browsers send cookies automatically. CORS is the controlled relaxation: cross-origin requests are SENT by the browser, but the response is hidden from JavaScript unless the responding server includes Access-Control-Allow-Origin permitting the calling origin. SOP applies to XHR, fetch, and most modern APIs — but not to <img>, <script>, or <iframe> src loads, which is why JSONP existed before CORS and why CSRF is still distinct from CORS.

What triggers a preflight OPTIONS request?

The browser sends a preflight OPTIONS request before the actual request whenever any of these are true. Method is anything other than GET, HEAD, or POST (e.g. PUT, DELETE, PATCH). Content-Type is anything other than application/x-www-form-urlencoded, multipart/form-data, or text/plain (so application/json triggers preflight). Custom request headers are present (Authorization with bearer tokens, X-Requested-With, X-Custom-Header). The request uses ReadableStream as a body. The preflight asks the server: 'origin O wants to do method M with headers H, allowed?' Server replies with Access-Control-Allow-Methods, -Headers, -Origin. If allowed, the browser sends the real request; if not, the fetch promise rejects with a TypeError.

What does Access-Control-Allow-Origin: * mean?

It tells the browser: 'any origin may read the response.' Appropriate for truly public APIs (CDN-served data, public weather feeds, JSON over a static bucket). Browsers honor wildcard freely for unauthenticated requests. The catch: if the request includes credentials (cookies, HTTP auth, client certificates), wildcard is REJECTED by the browser — for credentialed requests, the response must echo the actual requesting origin (e.g. Access-Control-Allow-Origin: https://app.example.com) and include Access-Control-Allow-Credentials: true. Servers that try '*' with credentials accidentally lock themselves out — the browser refuses to expose the response.

Why can't you use * with credentials?

Security. If A-C-A-O: * worked with credentials, then evil.com's JavaScript could fetch yourbank.com/api/balance, the browser would attach your bank-session cookie, and the response would be readable cross-origin — exactly the attack SOP was invented to prevent. The Fetch standard explicitly disallows this combination: when credentials mode is 'include' and A-C-A-O is '*', the browser fails the request without exposing the response. To support credentialed cross-origin requests, the server MUST echo the specific Origin header value back, MUST set A-C-A-Credentials: true, MUST also use specific origins (not wildcards) in A-C-A-Methods, and SHOULD include Vary: Origin so caches don't serve the wrong origin's response.

How does CORS differ from CSRF protection?

Orthogonal threats. CSRF (Cross-Site Request Forgery) tricks a logged-in user's browser into MAKING an authenticated request to a target site without consent — e.g. a hidden form on evil.com that POSTs to bank.com/transfer. CORS does NOT prevent CSRF — the request still gets sent, cookies still attach, the server may still process it; CORS only hides the response from evil.com's JavaScript. CSRF mitigation needs anti-CSRF tokens, SameSite cookies (Lax by default in Chrome since 2020), or double-submit patterns. Confusingly, a strict CORS policy CAN incidentally block some CSRF (preflight is required for application/json, so JSON-API CSRF is harder), but you should never rely on CORS alone for CSRF protection.

What is Access-Control-Allow-Credentials?

A response header set to 'true' (the only valid value) when the server is willing to receive cookies, Authorization headers, or client TLS certificates from a cross-origin caller AND let JavaScript see the response. Required when the client uses fetch with credentials: 'include' or XHR with withCredentials = true. Mandatory paired with a specific A-C-A-Origin (never '*'). Side requirements: A-C-A-Headers cannot be '*' either when credentials mode is on — every header must be listed explicitly. Common rookie bug: A-C-A-Credentials: true with A-C-A-Origin: '*' in dev configs — Chrome silently drops the response, and devs spend hours wondering why their cookie-authed API call returns empty.