Skip to content

CORS Explained: How to Fix CORS Errors in Your Web App

If you have ever opened the browser console and seen the dreaded red error Access to fetch at 'https://api.example.com' from origin 'https://app.example.com' has been blocked by CORS policy, you are not alone. CORS is one of the most common sources of confusion for web developers, and the error messages are rarely actionable. This guide explains what CORS actually does, why the browser is enforcing it, and shows you how to fix every common CORS error with real configuration examples.

What CORS Actually Is

CORS stands for Cross-Origin Resource Sharing. It is a browser security mechanism that controls when a web page can make requests to a domain different from the one that served the page. Without CORS, any random website you visit could silently make authenticated requests to your bank, your email, or any other service you are logged into, and read the responses. That would be a disaster, so browsers block cross-origin responses by default.

An origin is the combination of scheme + host + port. So https://app.example.com and https://api.example.com are different origins even though they share a parent domain. http://localhost:3000 and http://localhost:3001 are also different origins because the ports differ. The browser decides whether to allow a cross-origin response based on HTTP headers sent by the server, not by anything in your JavaScript.

CORS is Enforced by the Browser, Not the Server

This is the single most important thing to understand. The server happily answers the request. The browser receives the response, looks at the Access-Control-Allow-Origin header, and if the origin does not match, it refuses to pass the body to your JavaScript. Your fetch or XMLHttpRequest promise rejects, but the request itself was made and processed by the server.

This has two practical consequences. First, CORS does not protect your server from being called, it only protects the user's browser from leaking the response to malicious pages. Second, tools like curl, Postman and server-to-server requests completely ignore CORS because they are not browsers. If your API works in Postman but fails in the browser, CORS is almost certainly the reason.

Simple Requests vs Preflighted Requests

CORS divides cross-origin requests into two categories. A simple request can be sent directly and the browser only checks the response. A preflighted request triggers an extra OPTIONS request first to ask the server for permission.

A request is simple if it uses GET, HEAD, or POST, uses only a short allowlist of headers (Accept, Content-Language, Content-Type) and the Content-Type is one of application/x-www-form-urlencoded, multipart/form-data or text/plain.

The moment you use Content-Type: application/json, add a custom header like Authorization, or use PUT / DELETE, the request becomes preflighted. The browser fires an OPTIONS request with Access-Control-Request-Method and Access-Control-Request-Headers asking the server what it allows. Only if the OPTIONS response says yes does the real request go through.

The Headers That Control CORS

Your server controls CORS entirely through response headers. Here are the ones that matter in practice:

  • Access-Control-Allow-Origin — the single allowed origin, or * for any origin. You cannot list multiple origins here, you have to echo back the request origin if you want to allow several.
  • Access-Control-Allow-Methods — which HTTP methods are allowed for preflighted requests. Only sent in response to OPTIONS.
  • Access-Control-Allow-Headers — which custom request headers are allowed. Must list every non-simple header the client sends, like Authorization or X-Requested-With.
  • Access-Control-Allow-Credentials — set to true if the browser should send and accept cookies on cross-origin requests. When this is true, you cannot use the * wildcard for origin.
  • Access-Control-Expose-Headers — which response headers your JavaScript is allowed to read. By default only a handful of safe headers are exposed, so if you want to read X-Total-Count or a rate-limit header, list it here.
  • Access-Control-Max-Age — how many seconds the browser can cache the preflight response. Set this high (86400) in production to avoid an OPTIONS request on every API call.

The Five Most Common CORS Errors and Their Fixes

1. No 'Access-Control-Allow-Origin' Header is Present

This is the classic. Your server is not sending any CORS headers at all. The browser blocks the response because it has no way to know the request is allowed.

Fix (Express):

import cors from "cors";
app.use(cors({ origin: "https://app.example.com" }));

Fix (Nginx):

add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;

Note the always flag in Nginx. Without it, Nginx does not add the header on 4xx or 5xx responses, which leads to CORS errors that mask the real error from your API.

2. The 'Access-Control-Allow-Origin' Header Contains Multiple Values

The spec only allows a single origin or * in this header. If you try to send Access-Control-Allow-Origin: https://a.com, https://b.com, the browser rejects it.

The correct pattern is to maintain an allowlist, check the incoming Origin request header, and echo it back only if it matches.

const allowed = ["https://a.example.com", "https://b.example.com"];
app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (allowed.includes(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
  }
  next();
});

The Vary: Origin header tells caches and CDNs that the response depends on the request origin, otherwise a cached response for one allowed origin might be served to another origin.

3. Credentials Flag is True but Origin is '*'

This combination is illegal. If your fetch sets credentials: "include", or your XHR has withCredentials = true, the server must return an explicit origin and Access-Control-Allow-Credentials: true. The wildcard is not accepted, for obvious security reasons.

Wrong:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Fixed:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

4. Preflight Response Not Handled

Symptom: GET works but POST fails. Cause: your server returns a 404 or 405 for OPTIONS because you only have a POST route defined. The preflight never succeeds, so the real POST never runs.

The cors middleware for Express handles this automatically. If you are rolling your own or using a framework where OPTIONS is not auto-handled, add an explicit handler that returns 204 with the right headers.

app.options("*", (req, res) => {
  res.setHeader("Access-Control-Allow-Origin", "https://app.example.com");
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
  res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
  res.setHeader("Access-Control-Max-Age", "86400");
  res.status(204).end();
});

5. Request Header Field is Not Allowed

You add a custom header like X-API-Key, and the browser complains:

Request header field X-API-Key is not allowed by
Access-Control-Allow-Headers in preflight response.

The fix is to add that header name to the Access-Control-Allow-Headers response. Header names are case-insensitive. If you are dynamic, echo back the full Access-Control-Request-Headers value the browser sent in the preflight.

Next.js API Routes and Route Handlers

For Next.js App Router route handlers, return a standard Response with CORS headers and export an OPTIONS handler:

const CORS = {
  "Access-Control-Allow-Origin": "https://app.example.com",
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
};

export async function OPTIONS() {
  return new Response(null, { status: 204, headers: CORS });
}

export async function POST(req: Request) {
  const data = await req.json();
  return Response.json({ ok: true, data }, { headers: CORS });
}

The Development Proxy Trick

During local development you often cannot modify third-party APIs. The cleanest solution is to proxy the request through your dev server so the browser sees a same-origin request. Vite, Next.js rewrites, and webpack-dev-server all support this natively.

// next.config.js
export default {
  async rewrites() {
    return [
      { source: "/api/:path*", destination: "https://api.example.com/:path*" },
    ];
  },
};

Now your frontend calls /api/users and Next.js forwards it server-side. CORS does not apply because the browser never makes a cross-origin request.

Common Pitfalls

  • Fixing CORS by disabling browser security. Flags like --disable-web-security only help one developer on one machine. Fix the server, not the browser.
  • Trusting the Origin header. Browsers send Origin honestly, but curl and attacker tools can set it to anything. CORS is a browser-side guardrail, not a server-side auth check. Always validate auth tokens independently.
  • Mixing wildcard with credentials. If you want cookies, you need a specific origin. Accept the small extra complexity of an allowlist.
  • Forgetting Vary: Origin. Without it, CDN caches will return the wrong Allow-Origin header to a different client, breaking CORS intermittently.
  • Assuming 200 OK means CORS passed. A failing CORS check still hits the server. Check the network tab carefully, the response may have been received but blocked from your JavaScript.

Need an HTTP status code reference?

Our HTTP Status Codes reference lists every status code with its meaning, use cases and common pitfalls. Perfect when you are debugging API responses.

Open HTTP Status Codes Reference