Welcome to HowToShipIt — practical how-to guides for developers: code, AI tools, and servers, explained step by step.

How to Fix CORS Errors: The Complete Guide for Every Stack (2026)

Last verified: October 14, 2026. Every claim and code snippet in this guide was checked against official documentation today.

Your API works in Postman. Your API works with curl. But the moment your frontend calls it from a browser, you get the red wall of death:

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

If you want to know how to fix CORS errors properly — not with a browser extension or a “disable web security” flag — this guide is for you. You will learn what CORS actually is, how to read the error message like a debugger, and the exact fix for Express, Laravel, FastAPI, Go, and Nginx. The same rule governs all of them: CORS is fixed on the server, not in the browser.

What CORS actually is (30 seconds)

An origin is scheme + host + port. https://app.example.com and https://api.example.com are different origins, and so are http://localhost:5173 and http://localhost:8000. The browser’s same-origin policy says JavaScript on one origin cannot read responses from another origin — unless the server explicitly opts in. That opt-in mechanism is CORS (Cross-Origin Resource Sharing): a set of HTTP headers the server sends to tell the browser “yes, this origin may read my response.”

This is why your API works in curl and Postman but not in the browser. CORS is enforced only by browsers. Non-browser tools ignore it entirely. So when you see a CORS error, the API is almost certainly fine — it is just not telling browsers that your frontend is allowed to read the response.

Rule #1: fix it on the server (except one dev-only exception)

Nothing you do in your frontend JavaScript can grant your own page permission to read another origin’s response — that would defeat the entire security model. The fix always lives on the API side: send the right Access-Control-* headers. The one exception is the dev proxy trick in the section on Vite’s server proxy, which only exists in local development and disappears in production.

How to read the CORS error message

For security reasons, JavaScript itself can only see that a network error happened — MDN notes that the specifics are deliberately hidden from your code. The real detail lives in the browser console and the Network tab. Here are the four messages you will actually see, decoded:

“No ‘Access-Control-Allow-Origin’ header is present on the requested resource”

The most common one. The server answered but sent no CORS headers at all. Fix: configure CORS on the API (sections below).

“The ‘Access-Control-Allow-Origin’ header has a value ‘X’ that is not equal to the supplied origin”

The server does send CORS headers, but the value does not match your frontend’s origin — often a trailing slash, http vs https, or the wrong port (localhost:3000 vs localhost:5173). Origins must match exactly, scheme and port included.

“Response to preflight request doesn’t pass access control check”

The browser sent an OPTIONS preflight request (see the next section) and the server answered it wrong — or never answered it. Open DevTools → Network, find the failed OPTIONS request, and look at its response headers. Missing Access-Control-Allow-Methods or Access-Control-Allow-Headers is usually the culprit.

“The ‘Access-Control-Allow-Origin’ header value must not be ‘*’ when the request’s credentials mode is ‘include'”

You are sending cookies or HTTP auth with the request, and the server answered with a wildcard origin. Browsers forbid that combination — the server must echo back your exact origin instead. See the credentials section.

Preflight requests, in plain English

Some cross-origin requests are risky enough that the browser asks permission before sending them. It sends an OPTIONS request with two tell-tale headers — Access-Control-Request-Method (what method the real request will use) and Access-Control-Request-Headers (which headers it will carry) — and only sends the real request if the server approves.

When does a request get preflighted? When it is anything other than a “simple” request. Per the Fetch spec, simple means: method is GET, HEAD, or POST; headers are limited to the CORS-safelisted set (Accept, Accept-Language, Content-Language, Content-Type, Range); and if Content-Type is set, it must be application/x-www-form-urlencoded, multipart/form-data, or text/plain. So the everyday POST with Content-Type: application/json is not simple — it gets preflighted. So does anything with an Authorization header or a PUT/PATCH/DELETE method.

The server must answer the preflight with matching Access-Control-Allow-Methods and Access-Control-Allow-Headers values. It can also send Access-Control-Max-Age (in seconds) so the browser caches the preflight result instead of repeating it on every call — MDN notes the spec default is 5 seconds, and each browser enforces its own internal maximum, so set an explicit value like 600.

The fix in Express (Node.js)

Use the cors package — hand-rolled header middleware is how subtle bugs are born. Two rules: register it before your routes (middleware placed after route handlers never runs for those routes), and never combine origin: '*' with credentials: true — browsers forbid it. If you are writing Express middleware for the first time, this builds on the middleware ordering rules covered in our Express middleware guide.

// npm install cors
const express = require('express');
const cors = require('cors');
const app = express();

const allowedOrigins = [
  'https://app.example.com',
  'http://localhost:5173', // Vite dev server — drop in prod if you prefer
];

app.use(cors({
  origin: (origin, callback) => {
    // Non-browser tools (curl, Postman) may send no Origin at all
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,   // only when your frontend sends cookies
  maxAge: 600,          // cache preflight results for 10 minutes
  optionsSuccessStatus: 200, // some legacy browsers choke on 204
}));

app.get('/api/users', (req, res) => res.json([]));
app.listen(8000);

The fix in Laravel

Laravel 11+ ships first-party CORS support through the HandleCors middleware. The config file is not published by default — generate it, then edit the origins:

php artisan config:publish cors
// config/cors.php
return [
    'paths' => ['api/*', 'sanctum/csrf-cookie'],
    'allowed_methods' => ['*'],
    'allowed_origins' => ['https://app.example.com', 'http://localhost:5173'],
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['*'],
    'exposed_headers' => [],
    'max_age' => 600,
    'supports_credentials' => true, // set false if you don't use cookies
];

Then clear the config cache (php artisan config:clear) and re-test. Two gotchas: paths controls which routes get CORS headers at all — if your API lives under /api/* you are covered, but a route outside those patterns will still fail. And if you use Sanctum SPA authentication, you need supports_credentials => true plus an explicit origin (never '*') — the full Sanctum setup, including the CORS-adjacent stateful config, is in our Laravel Sanctum setup guide.

The fix in FastAPI (Python)

FastAPI ships CORSMiddleware (from Starlette) — you configure it, you never hand-write the headers. Its defaults are restrictive, so you must opt in explicitly. This pairs with the API-building patterns in our FastAPI REST API guide.

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com", "http://localhost:5173"],
    allow_credentials=True,   # cookies / auth — requires explicit origins
    allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
    allow_headers=["Authorization", "Content-Type"],
    max_age=600,
)

The trap everyone hits exactly once:

# Looks reasonable. Browsers will silently reject it.
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,   # incompatible with a wildcard origin
)

If your frontend and API share a parent domain and you want to allow all subdomains, use allow_origin_regex (e.g. r"https://.*\.example\.com") instead of trying to list every subdomain by hand.

The fix in Go (net/http)

Go’s standard library has no built-in CORS middleware, so the minimal version is a small wrapper around your handler. Answer OPTIONS directly, pass everything else through:

func corsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "https://app.example.com")
        w.Header().Set("Vary", "Origin")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
        w.Header().Set("Access-Control-Max-Age", "600")

        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusNoContent)
            return
        }
        next.ServeHTTP(w, r)
    })
}

// mux.Handle("/api/", corsMiddleware(apiHandler))

Note the Vary: Origin header: when the allowed origin varies per request, caches must key on it, otherwise a CDN can serve one origin’s response to another. Community packages like rs/cors wrap this pattern if you want the edge cases handled for you.

The fix at the Nginx layer

Sometimes the API itself is fine but the reverse proxy strips or never adds the headers — or you want one CORS policy for several backends. Add it at the proxy layer (see our Nginx reverse proxy guide for the surrounding setup):

location /api/ {
    if ($request_method = 'OPTIONS') {
        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' 'Content-Type, Authorization' always;
        add_header 'Access-Control-Max-Age' 600 always;
        add_header 'Content-Length' 0 always;
        return 204;
    }
    add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    proxy_pass http://127.0.0.1:8000;
}

The always parameter matters: without it, Nginx only adds the header on “successful” responses, so error responses (4xx/5xx) go out without CORS headers and your frontend cannot even read the error body. That is one of the most confusing failure modes in production.

Cookies and credentials: the two-sided contract

When your frontend sends cookies or HTTP authentication cross-origin, both sides must opt in, and the combination has one hard rule:

  • Frontend: fetch(url, { credentials: 'include' }) — or withCredentials: true in Axios. Without this, the browser never sends the cookie at all.
  • Server: Access-Control-Allow-Credentials: true — and an explicit origin in Access-Control-Allow-Origin, never *.

If only one side is configured, the request still fails. And the wildcard-with-credentials combination is rejected by the spec itself — no framework flag can override it. When debugging a credentialed CORS failure, check both sides before touching anything else.

The dev proxy escape hatch (local development only)

If you control the frontend dev server, you can sidestep CORS entirely during development: proxy /api requests through the dev server so the browser only ever talks to one origin. In Vite:

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,                 // rewrite the Host header for the target
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
})

Now your frontend calls fetch('/api/users') (a relative URL, same origin — no CORS involved) and Vite forwards it to the real API server-to-server. This is the single biggest quality-of-life fix for local development. But it only exists in the dev server: production still needs real CORS headers, either on the API or in your production reverse proxy. Shipping the proxy config and assuming production “just works” is the classic way this trick bites you.

What NOT to do

  • mode: 'no-cors' as a fix. It does not bypass CORS — it tells the browser to return an opaque response whose headers and body JavaScript cannot read at all. Useful for fire-and-forget beacons, useless for APIs you need data from.
  • Disabling browser web security. Launching Chrome with security flags off “fixes” it on your machine and on no one else’s. It is a debugging aid, not a deployment strategy.
  • CORS browser extensions as a solution. They modify headers for your browser only. Your users do not have the extension.
  • * in production. Fine for a public, unauthenticated API in development. In production with any kind of auth, enumerate your real origins — a wildcard means any website on the internet can read your API’s responses from a logged-in user’s browser.

The 60-second CORS debug checklist

Run through this in order before changing any code:

  • Is it really CORS? curl the endpoint — if curl works, the API is fine and the browser is enforcing policy.
  • What does the server actually send? curl -s -D - -o /dev/null https://api.example.com/users -H "Origin: https://app.example.com" | grep -i access-control — if nothing comes back, CORS is not configured.
  • Is the preflight answered? Repeat the curl with -X OPTIONS plus -H "Access-Control-Request-Method: POST" and -H "Access-Control-Request-Headers: Content-Type, Authorization". Look for Access-Control-Allow-Methods and Access-Control-Allow-Headers in the response.
  • Is the middleware actually running? In Express, app.use(cors()) must come before your routes. In Laravel, the route must match paths in config/cors.php and you must have run php artisan config:clear after editing.
  • Credentials on both sides? Frontend credentials: 'include' and server Access-Control-Allow-Credentials: true with an explicit origin. One side is not enough.
  • Exact origin match? Scheme, host, and port must all match — http://localhost:3000 and http://localhost:5173 are different origins.

Further Reading & References

Leave a Comment