All posts

CORS Explained: A Practical Guide for Web Developers

July 18, 2026 · DevTools

cors
web-security
preflight
cross-origin
http

Your frontend is on https://app.example.com and your API is on https://api.example.com. The login button doesn't work. Open the devtools console and there's a red line: "Access to fetch at 'https://api.example.com/login' from origin 'https://app.example.com' has been blocked by CORS policy." Every developer hits this wall eventually. CORS is one of the most common sources of confusion in web development, partly because the error message looks like a server problem when it's actually a browser policy.

This guide explains what CORS really is, when the browser asks for permission, and how to read the headers. When you're ready to generate a policy, the CORS Policy Builder produces a ready-to-paste middleware snippet.

What CORS actually is

CORS — Cross-Origin Resource Sharing — is a browser-enforced security policy, not a server feature. Servers don't enforce CORS. Browsers do. The server simply declares, through HTTP headers, which origins are allowed to read its responses; the browser checks those headers before letting your JavaScript see the response.

If a script on https://app.example.com does:

fetch("https://api.example.com/me")

…the browser classifies https://app.example.com as a different origin from https://api.example.com (different host, even though both are under example.com). By default, the browser will refuse to expose the response to the page's JavaScript. The server has to opt in.

The same-origin policy, briefly

Browsers run every page in an origin: a combination of scheme + host + port. https://app.example.com:443 and http://app.example.com:80 are different origins. app.example.com and api.example.com are different origins. The same-origin policy says: scripts can read responses only from the same origin. CORS is the controlled escape hatch — a way to explicitly allow other origins.

Before and after: the actual headers

A request from https://app.example.com to https://api.example.com/users:

Browser sends:

GET /users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com

Server replies (with CORS enabled):

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin

[{"id":1,"name":"Ada"}, ...]

The browser sees Access-Control-Allow-Origin echoing the request's origin and lets the response through. If the server had replied without that header, or with Access-Control-Allow-Origin: https://other-site.com, the browser would have blocked the response and surfaced the CORS error in the console — even though the HTTP status was 200.

Step 1: Decide which origins are allowed

The first question is always the same: who is allowed to call this API?

  • Public, no auth, read-only — a static site or a CDN-style API. Access-Control-Allow-Origin: * is fine. The wildcard means "any origin."
  • Your own frontend on a known originAccess-Control-Allow-Origin: https://app.example.com. List the exact origin.
  • Multiple internal apps — list each, or echo the Origin header dynamically (and pair it with Vary: Origin so caches don't mix responses).
  • Server-to-server — CORS doesn't apply; the browser isn't involved.

Step 2: Handle simple requests

A "simple" request is one that uses a method from a small allowed list (GET, HEAD, POST) and only standard headers with simple values. For these, the browser sends the request and only checks the response headers:

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

If those line up, the browser hands the response to your code. If not, you get the CORS error.

Step 3: Handle preflight

Any request that doesn't fit the "simple" definition triggers a preflight: an automatic OPTIONS request sent before the real request, asking the server for permission.

The browser sends a preflight when you:

  • Use a method other than GET/HEAD/POST (e.g. PUT, DELETE, PATCH).
  • Set custom headers (Authorization, X-Request-ID, anything not in the safelist).
  • Use a content type other than form-urlencoded, multipart/form-data, or text/plain (so any JSON API).
  • Read a custom response header.

The browser sends:

OPTIONS /users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, Content-Type

The server must reply with the headers that will be valid for the real request:

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

If the preflight succeeds, the browser fires the real request. If it fails, you never see the real request go out.

Access-Control-Max-Age tells the browser how long to cache the preflight result. Ten minutes (600) is a reasonable default; shorter and you're paying an extra round trip on every page load.

Step 4: Add credentials carefully

When a request sends cookies or an Authorization header, the browser sets a special mode:

fetch("https://api.example.com/me", { credentials: "include" })

Two things change:

  1. The browser requires Access-Control-Allow-Credentials: true on the response.
  2. Wildcards are forbidden. Access-Control-Allow-Origin: * is no longer accepted — you must echo the exact origin.

This is the most common silent CORS bug. Someone adds cookies to a request, the server has been happily sending *, and suddenly everything breaks. The fix is to replace * with the specific origin (or echo Origin dynamically) and add Access-Control-Allow-Credentials: true.

Step 5: Expose response headers to JavaScript

By default, JavaScript can only read a small set of "CORS-safelisted response headers": Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma. If your code needs to read a custom header like X-Request-ID or X-RateLimit-Remaining, the server must add:

Access-Control-Expose-Headers: X-Request-ID, X-RateLimit-Remaining

Without this, the header is in the response but JavaScript sees null when it tries to read it — and there's no error to point you at the cause.

Reading the headers in practice

When debugging, look at the Network tab:

  • Request headers — confirm Origin is what you expect.
  • Response headers — confirm Access-Control-Allow-Origin matches Origin (or is *).
  • For preflights — confirm the response to OPTIONS has the right Access-Control-Allow-Methods and Access-Control-Allow-Headers, and a non-error status (204 or 200).

If the response is missing the header entirely, the server isn't configured for CORS at all. If the header is present but the values don't match, the server is configured but rejecting your origin.

Common mistakes

  • Access-Control-Allow-Origin: * with credentials. Browsers reject this combination. Use an explicit origin.
  • Forgetting Vary: Origin when echoing Origin dynamically. Without Vary, a shared cache can serve a response meant for origin A to origin B.
  • Allowing * with Access-Control-Allow-Headers: *. Modern browsers accept this, but older ones reject * here too. List the headers explicitly if you need to support older browsers.
  • Skipping the preflight handler. Many frameworks only register middleware for the documented routes, missing OPTIONS on every route.
  • CORS confused with authentication. CORS is the browser checking whether JavaScript is allowed to read the response. The server's own authentication runs regardless of CORS.

Everything stays in your browser

The CORS Policy Builder runs 100% client-side. Pick the allowed origin, the methods, the headers, and whether credentials are included — the tool produces a ready-to-paste middleware snippet for Express, Fastify, Koa, Hono, or a generic config. Nothing is uploaded.

Next: validate against a live API

Designing the policy is half the job. Once it's deployed, point the CORS Validator at your API endpoint, send an actual cross-origin request, and confirm the server returns the headers you expect — including preflight behavior and the wildcard-with-credentials edge case.

Tools mentioned in this post