CORS errors explained, and how to fix them

What the same-origin policy and CORS actually do, why an API can work in curl but fail in the browser, and how to fix the most common CORS errors properly.

What CORS is, in one paragraph

Browsers enforce the same-origin policy: a script on one origin can send requests to another origin, but it cannot read the response unless that other origin says it is allowed. An origin is the combination of scheme, host, and port, so https://app.example.com, https://api.example.com, and http://localhost:3000 are three different origins. Cross-Origin Resource Sharing (CORS) is the set of HTTP headers a server uses to grant that permission.

CORS is enforced by browsers, not by servers. That is why the same request can succeed in curl, a desktop API client, or server-side code, and still fail on a web page.

Reading the error message

CORS failures appear in the browser console, not in your JavaScript error object, which only reports a generic network failure. The console message tells you which check failed. Two of the most common, in Chrome’s wording:

Access to fetch at 'https://api.example.com/orders' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on
the requested resource.

Access to fetch at 'https://api.example.com/orders' from origin 'https://app.example.com'
has been blocked by CORS policy: Response to preflight request doesn't pass access
control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

The first means the actual response came back without permission. The second means the browser never sent your real request, because the preflight check that precedes it failed.

Simple requests and preflight requests

Some requests are considered simple: GET, HEAD, or POST using only a short list of safe headers, with a Content-Type of text/plain, multipart/form-data, or application/x-www-form-urlencoded. The browser sends these directly and then checks the response headers before handing the response to your code.

Everything else triggers a preflight: an automatic OPTIONS request that asks the server whether the real request is allowed. Sending JSON with Content-Type: application/json, adding an Authorization header, or using PUT, PATCH, or DELETE all cause a preflight.

A preflight request and a successful response
OPTIONS /orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

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

Common causes and fixes

SymptomLikely causeFix on the server
No Access-Control-Allow-Origin headerThe API does not send CORS headers for your originReturn Access-Control-Allow-Origin with your exact origin
Preflight fails with 401 or 403Authentication middleware rejects the OPTIONS requestAnswer OPTIONS before authentication runs
Preflight fails, header not allowedA custom header is missing from the allow listAdd it to Access-Control-Allow-Headers
Works without cookies, fails with themWildcard origin used with credentialsReturn the specific origin and Access-Control-Allow-Credentials: true
Fails only from the HTTPS siteMixed content: an http:// API called from an https:// pageServe the API over HTTPS

Credentials deserve special attention. When a request includes cookies or uses credentials: 'include', the server must name the exact origin; Access-Control-Allow-Origin: * is rejected in that case. It must also send Access-Control-Allow-Credentials: true.

Debugging CORS step by step

  1. Open your browser’s developer tools, select the Network tab, and reproduce the failing request.
  2. Look for an OPTIONS request to the same URL just before your real request. If it is there, the browser sent a preflight; check its status code and response headers first.
  3. Compare the Origin request header with the Access-Control-Allow-Origin response header. They must match exactly, including scheme and port, unless the server returns * for a request without credentials.
  4. For preflights, confirm that every method and header your request uses appears in Access-Control-Allow-Methods and Access-Control-Allow-Headers.
  5. If cookies or credentials are involved, confirm Access-Control-Allow-Credentials: true and a specific origin.
  6. Make the same request with curl. If it fails there too, the problem is not CORS at all: the server is returning an error, and the missing headers are a side effect.

That last step is worth remembering. Many servers only add CORS headers to successful responses, so a 500 error or a crash can show up in the browser as a CORS failure. Fix the underlying error, and configure CORS headers on error responses too, so the browser can show you the real status code.

Server configuration examples

In Node.js with Express, the cors package handles both preflight and actual responses. List allowed origins explicitly instead of reflecting whatever origin arrives.

import express from 'express';
import cors from 'cors';

const app = express();

app.use(cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  credentials: true,
}));

With nginx in front of your API, add the headers and answer preflight requests directly:

location /api/ {
    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;

    if ($request_method = OPTIONS) {
        return 204;
    }

    proxy_pass http://backend;
}

Fixing CORS during local development

The cleanest development setup avoids cross-origin requests entirely. Most front-end dev servers can proxy API calls, so the browser talks to one origin and the dev server forwards requests to the backend. In Vite, for example:

vite.config.js
export default {
  server: {
    proxy: {
      '/api': 'http://localhost:3001',
    },
  },
};

Avoid browser flags or extensions that disable web security altogether. They hide real configuration problems until production and make everyday browsing less safe.

Localhost, VPNs, and private networks

Requests from a public website to localhost, a company VPN host, or a private IP address face extra hurdles. Many internal services do not send CORS headers at all, an https:// page cannot call plain http:// addresses other than localhost because of mixed-content rules, and browsers are adding further protections for requests from public sites to local networks, which can appear as permission prompts or additional preflight requirements.

When you control the internal service, add proper CORS headers and serve it over HTTPS. When you are only testing an API you cannot change, use a tool that sends requests outside the page context. The UtiliStudio REST Client sends requests directly from your browser when the target allows it, and its optional browser extension relays requests from your own machine for localhost, VPN, and CORS-restricted endpoints.

Security mistakes to avoid

  • Reflecting any origin. Copying the incoming Origin header into Access-Control-Allow-Origin without checking it against an allow list effectively disables the protection.
  • Wildcards with credentials. Browsers reject * with credentials for good reason; do not work around it by reflecting origins.
  • Treating CORS as authentication. CORS only controls which web pages can read responses. Non-browser clients ignore it, so every endpoint still needs real authentication and authorization.
  • **Allowing null as an origin.** Sandboxed iframes and local files send Origin: null; allowing it opens the door to any such page.
Try it: REST ClientSend HTTP requests, inspect responses and test APIs without leaving UtiliStudio.
Open REST Client