Fix CORS Errors: Where the Wildcard Stops Being a Wildcard
The server already ran your handler. What is left is a permission header, and the star you pasted into it means four different things depending on the request. Next.js, Express, FastAPI and Django defaults compared, plus the failure no header can fix.
The console line is specific, and almost nobody reads it as specific: has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. That is a sentence about a missing response header. It is not a sentence about your request being rejected. The Express cors package’s own README puts it bluntly: your server receives and processes every request, and the headers only decide whether JavaScript gets to read the answer.
Which is why the first fix everyone reaches for, pasting * into the header, works right up until it does not. The star stops behaving like a star in at least four places, and three of them produce error messages that look identical to the one above. Everything below was checked against the current framework docs and package sources on 22 August 2026.
Four places the star is not a star
The Fetch standard treats * as a wildcard only for requests without credentials. Once cookies or an Authorization header enter the picture, the rules change in ways that are individually documented and collectively surprising.
Access-Control-Allow-Origin: * cannot be combined with Access-Control-Allow-Credentials: true. This is the one most people eventually learn, usually after the header worked for a week and then stopped when auth was added. Echo the specific requesting origin back instead.
Access-Control-Allow-Headers: * is treated as the literal header name * in credentialed requests, not as a wildcard. Same for Access-Control-Expose-Headers: *.
Authorization is never covered by the wildcard, credentials or not. MDN is explicit: it “doesn’t accept wildcard and always needs to be listed explicitly.” A preflight for a bearer-token request against a handler that answers Access-Control-Allow-Headers: * fails, and the error text says nothing about Authorization.
And the moment you stop sending * and start echoing the origin, the response varies by request, so it needs Vary: Origin. Skip it and a CDN or any shared cache can hand the origin A copy of the response to a browser on origin B, which then blocks it. That bug appears in production and never in development, because there is no cache in development.
What your framework does before you configure anything
Defaults differ more than the tutorials suggest. These come from the current sources rather than from memory.
| Stack | Out of the box | The part that surprises people |
|---|---|---|
| Next.js 16 route handlers | No CORS headers at all | OPTIONS is auto-implemented, but with an Allow header, not CORS headers |
Express with cors() | origin: "*", methods GET,HEAD,PUT,PATCH,POST,DELETE | credentials is off, so adding cookies later breaks the wildcard |
FastAPI CORSMiddleware | No origins, allow_methods=("GET",), max_age=600 | A wildcard plus credentials does not error. It reflects. |
| django-cors-headers 4.9 | CORS_ALLOWED_ORIGINS = [], nothing allowed | Middleware order decides whether the headers exist at all |
FastAPI: the combination that fails open
The advice you will read everywhere is that allow_origins=["*"] with allow_credentials=True does not work. Read Starlette’s middleware instead, since FastAPI re-exports it. The relevant branch, comment included:
# If credentials are allowed, then we must respond with the specific origin instead of '*'.
if self.allow_all_origins and self.allow_credentials:
self.allow_explicit_origin(headers, origin)
It reflects whatever origin asked, and adds Vary: Origin while doing it. So the combination works perfectly, and what you have built is an API that returns credentialed responses to any site on the internet that asks. That is the classic CORS misconfiguration, arrived at by following a tutorial that told you the setting would simply fail.
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com", "http://localhost:3000"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type", "Authorization"],
)
Two more Starlette details worth knowing. allow_methods defaults to ("GET",), so a preflight for POST fails with a 400 whose body reads Disallowed CORS method, which is far more useful than the browser message and is visible in the Network tab. And allow_headers=["*"] mirrors back whatever the browser requested rather than sending a literal star, which is why the Authorization rule above does not bite here.
Express: the package is fine, the default is not
app.use(cors({
origin: ["https://app.example.com", "http://localhost:3000"],
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true,
}));
app.use(cors()) with no arguments is origin: "*" and no credentials. When you pass an array or a function it reflects the matched origin and sets Vary: Origin for you, which is most of the value of using the package.
Register it above your routes. If preflight is what fails, look for anything that runs earlier and can produce a response, including a body parser meeting an empty OPTIONS body. And if you use the function form of origin, return callback(null, false) for a rejected origin rather than an error: an error becomes a 500, the 500 carries no CORS headers, and you spend the afternoon debugging CORS instead of reading the stack trace.
The package itself published 2.8.6 in January 2026, its first release since 2018. It is documentation and build housekeeping rather than new behavior, so old advice about the options still holds.
Django: it is nearly always the ordering
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
]
CORS_ALLOWED_ORIGINS = ["https://app.example.com", "http://localhost:3000"]
CORS_ALLOW_CREDENTIALS = True
The README says CorsMiddleware “should be placed as high as possible, especially before any middleware that can generate responses such as Django’s CommonMiddleware or Whitenoise’s WhiteNoiseMiddleware.” A response generated above it never gets the headers, so the report is “I installed the package and half my endpoints still fail,” which reads like a routing problem and is not one. CORS_URLS_REGEX = r"^/api/.*$" keeps the headers off everything else.
Next.js 16: middleware moved, and the docs example is missing a header
Next.js 16 renamed the middleware file convention to proxy, with the function renamed too. There is a codemod:
npx @next/codemod@canary middleware-to-proxy .
The CORS example in those docs checks the incoming origin against an allow list and echoes it back, which is right. It does not set Vary: Origin, which is wrong for anything sitting behind a cache, and it sets no Access-Control-Allow-Credentials, so cookies will not travel until you add it. Copy the shape, add both lines.
One more thing to fix while copying: Access-Control-Max-Age: 86400 is a number browsers do not honor. Chromium caps preflight caching at 7200 seconds and Firefox at 86400, and the default when the header is absent is 5 seconds. Sending a day gets you two hours in Chrome, which is still the difference between one preflight and thousands.
When it says CORS and it is not CORS
Three failures wear the CORS message.
An unhandled exception thrown before your CORS middleware finishes produces a 500 with no CORS headers, and the browser reports the missing header rather than the 500. If cross-origin calls only fail on the error path, read the server log, not the header config.
A CDN, API gateway or load balancer sitting in front can strip or overwrite the headers your app sets correctly. The Network tab shows what arrived, which is the only version that matters.
And then there is the one no response header fixes. Chrome 142 put requests from public sites to private IP ranges, .local names and loopback behind a user permission prompt, replacing the older Private Network Access preflight with its Access-Control-Allow-Private-Network header. If your web app talks to an agent on 127.0.0.1, no amount of server configuration restores it. The user grants it or it stays blocked.
The version with no headers to maintain
If the frontend and the API deploy together, the cheapest fix is to stop being cross-origin. A rewrite in next.config.js, a location /api/ block in Nginx, or server.proxy in Vite makes the browser see one origin, and then there is no preflight, no allow list, and nothing to get wrong at 2am.
I reach for that whenever the two halves ship together. It costs a network hop, and it does nothing for anyone else’s client: if third parties call your API from their own domains, they need real headers on your side. Decide by who is calling, and check the actual response in the Network tab afterward rather than trusting the config, because those two disagree more often than they should.