Understanding CORS (and why your API calls fail)
If you have built a web app that calls an API on a different domain, you have seen this error in your browser console:
Access to fetch at 'https://api.example.com/data' from origin
'http://localhost:3000' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
You google it. You find a Stack Overflow answer telling you to add Access-Control-Allow-Origin: * to your server. You do that. It works. You move on without understanding what just happened.
I did this for an embarrassingly long time. So let me explain what CORS actually is and why it exists, because it will save you hours of debugging when the wildcard trick stops working.
The same-origin policy
Browsers enforce something called the same-origin policy. It prevents scripts on one page from reading data from a different origin. An origin is defined by three things: the scheme, the host, and the port.
https://example.comandhttps://example.com/pageare the same origin.https://example.comandhttp://example.comare different (scheme differs).https://example.comandhttps://api.example.comare different (host differs).http://localhost:3000andhttp://localhost:8080are different (port differs).
This policy exists for a good reason. Without it, any website you visit could make requests to your bank's API using your cookies and read the response. The browser blocks this by default. Cross-Origin Resource Sharing (CORS) is the mechanism that lets servers opt in to allowing cross-origin requests from specific origins.
How CORS works
CORS is not something the browser blocks and your server bypasses. It is a collaboration between the browser and the server.
When your frontend JavaScript makes a cross-origin request, the browser checks the response for specific headers. The most important one is Access-Control-Allow-Origin. If the server's response includes this header and it matches your origin, the browser lets the response through. If not, the browser blocks it and you get that error.
The key thing to understand: the request still reaches the server. The server still processes it and sends back a response. The browser just refuses to let your JavaScript read that response. CORS is a browser-level protection, not a server-level one.
Preflight requests
Not every cross-origin request is simple. If your request uses a method other than GET, HEAD, or POST, or if it sets custom headers like Authorization or Content-Type: application/json, the browser sends a preflight request first.
A preflight is an OPTIONS request that asks the server: "Is this cross-origin request allowed?" The server responds with headers listing what it permits. Only if the preflight response passes does the browser send the actual request.
sequenceDiagram
participant Browser
participant Server
Browser->>Server: OPTIONS /api/data (preflight)
Note right of Server: Checks allowed origins,<br/>methods, and headers
Server-->>Browser: 204 + CORS headers
Note left of Browser: Preflight passed.<br/>Send actual request.
Browser->>Server: POST /api/data (actual request)
Server-->>Browser: 200 + data + CORS headers
The preflight response includes headers like:
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Simple requests skip the preflight entirely. A request is "simple" if it uses GET, HEAD, or POST with standard headers and a content type of application/x-www-form-urlencoded, multipart/form-data, or text/plain. The moment you set Content-Type: application/json (which is almost every modern API call), you trigger a preflight.
Common scenarios and fixes
Your API is on a different subdomain. Your frontend is at app.example.com and your API is at api.example.com. These are different origins. Your API server needs to return Access-Control-Allow-Origin: https://app.example.com in its responses.
Local development with a dev server. Your React or Next.js app runs on localhost:3000 and your API runs on localhost:8080. Different ports, different origins. Most frameworks have a proxy config for this. In Next.js, you can add a rewrites entry in next.config.js:
module.exports = {
async rewrites() {
return [
{
source: "/api/:path*",
destination: "http://localhost:8080/api/:path*",
},
];
},
};This proxies API requests through the dev server, so the browser sees them as same-origin. No CORS headers needed.
Setting CORS headers in Express. The cors package handles this cleanly:
const express = require("express");
const cors = require("cors");
const app = express();
app.use(
cors({
origin: "https://myapp.com",
methods: ["GET", "POST", "PUT", "DELETE"],
})
);
app.get("/api/data", (req, res) => {
res.json({ message: "This works cross-origin" });
});
app.listen(8080);You can also pass an array of origins if you need to allow multiple:
cors({
origin: ["https://myapp.com", "https://staging.myapp.com"],
});Why * is not always the answer
Setting Access-Control-Allow-Origin: * allows any origin. That works for public APIs with no authentication. But the moment you need to send cookies or an Authorization header, the wildcard breaks.
The CORS spec explicitly forbids combining Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. Browsers will reject the response. You have to specify the exact origin instead of the wildcard.
Beyond the technical limitation, a wildcard on an authenticated API is a security problem. It means any website can make credentialed requests to your API and read the responses. That is exactly the attack the same-origin policy was designed to prevent.
If you need credentials, set the origin explicitly:
cors({
origin: "https://myapp.com",
credentials: true,
});The reverse proxy solution
There is a cleaner architectural solution to CORS: make everything same-origin by putting a reverse proxy in front of both your frontend and your API.
If your frontend is at example.com and your reverse proxy routes example.com/api/* to your backend service, the browser never sees a cross-origin request. No CORS headers needed. No preflight requests. No debugging browser console errors.
This is one of the practical reasons I use reverse proxies for everything I self-host. I wrote about Cloudflare's tools for handling this at the edge, and I go deeper into how reverse proxies work in a follow-up post. If CORS is a recurring headache in your setup, a reverse proxy might be the real fix.
Sources
Related posts
Why I built Omnibase: a universal database MCP server
I got tired of copy-pasting query results between DataGrip and AI agents. So I built an MCP server that gives AI agents secure, direct access to any database.
Delta libraries: how diffing works and which library to use
What delta libraries do, how diff algorithms work under the hood, and a practical comparison of the most popular options in the JavaScript ecosystem.
Offline-first apps: harder than it sounds
Building apps that work without internet is one of those things that seems straightforward until you actually try it. Here is what makes it hard and how to approach it.
Enjoying the blog? Subscribe via RSS to get new posts in your reader.
Subscribe via RSS