CORS Explained: A Practical Guide for Web Developers
July 18, 2026 · DevTools
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 origin —
Access-Control-Allow-Origin: https://app.example.com. List the exact origin. - Multiple internal apps — list each, or echo the
Originheader dynamically (and pair it withVary: Originso 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, ortext/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:
- The browser requires
Access-Control-Allow-Credentials: trueon the response. - 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
Originis what you expect. - Response headers — confirm
Access-Control-Allow-OriginmatchesOrigin(or is*). - For preflights — confirm the response to
OPTIONShas the rightAccess-Control-Allow-MethodsandAccess-Control-Allow-Headers, and a non-error status (204or200).
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: Originwhen echoingOrigindynamically. WithoutVary, a shared cache can serve a response meant for origin A to origin B. - Allowing
*withAccess-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
OPTIONSon 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.