Your fetch call fails in the browser, the same URL works in curl, and the API logs show a happy 200. That gap is CORS: the browser is withholding a response the server already sent. The fix lives on the server, and I wrote a longer walkthrough with every config tested on DevToolLab.

Everything here uses one setup: a frontend on http://localhost:3000 calling http://localhost:4000/api/orders. Different port means different origin.

Who actually enforces it

The server does not block anything. I pointed a request with Origin: https://evil.example at an Express app that only allows localhost:3000, and it still returned the full JSON body. The browser looks at the response headers afterward and decides whether your JavaScript may read it.

A cross-origin request needs an Access-Control-Allow-Origin that matches the page's origin. Some requests also trigger a preflight first. Per MDN, anything beyond safelisted methods and headers qualifies, and so does a Content-Type outside form-encoded, multipart or plain text. In practice a PUT, an Authorization header or a JSON body means the browser sends OPTIONS before the real call.

A browser at localhost:3000 sends an OPTIONS preflight to an Express API at localhost:4000, receives a 204 with Access-Control-Allow headers, then sends the PUT and gets a 200

Generate the config instead of typing it

If you would rather not hand-write headers, the CORS Header Generator produces middleware for Express, FastAPI, Nginx and Django, plus a curl command for testing the preflight. To check a server that already exists, the CORS Tester fires a real cross-origin fetch from your browser and explains a block.

The CORS Header Generator showing configuration options beside generated Express middleware and a curl preflight test

Express

The cors package (2.8.6) covers both the headers and the preflight. I ran this on Express 5.2.1:

import express from "express" import cors from "cors" const app = express() app.use(cors({ origin: "http://localhost:3000", methods: ["GET", "POST", "PUT", "DELETE"], allowedHeaders: ["Content-Type", "Authorization"], credentials: true, maxAge: 600, })) 

How you set origin changes the behavior. Calling cors() with no options sends *. A single string goes out on every response whether the caller matches or not. An array reflects the caller's origin only when it is listed, adds Vary: Origin, and sends nothing to anyone else. If you skip the package, a ten-line middleware that checks a Set of allowed origins and answers OPTIONS with 204 does the same job.

FastAPI

Starlette's CORSMiddleware is available straight from FastAPI. Tested on FastAPI 0.143.0 with Starlette 1.7.0:

from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:3000", "https://app.example.com"], allow_credentials=True, allow_methods=["GET", "POST", "PUT", "DELETE"], allow_headers=["Content-Type", "Authorization"], max_age=600, ) 

It adds the safelisted header names to whatever you list. A preflight from an origin outside the list comes back as 400 with the text Disallowed CORS origin.

Next.js

For one fixed origin, an async headers() entry in next.config.mjs matching /api/:path* is enough, and a preflight to a route handler returned 204 with the headers attached on 15.5.20. The catch is that the value is static, so every caller gets the same origin back.

For a list of origins, check the Origin header yourself in middleware:

import { NextRequest, NextResponse } from "next/server" const allowed = ["http://localhost:3000", "https://app.example.com"] export function middleware(req: NextRequest) { const origin = req.headers.get("origin") ?? "" const ok = allowed.includes(origin) const res = req.method === "OPTIONS" ? new NextResponse(null, { status: 204 }) : NextResponse.next() if (ok) res.headers.set("Access-Control-Allow-Origin", origin) if (ok && req.method === "OPTIONS") { res.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE") res.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization") } res.headers.set("Vary", "Origin") return res } export const config = { matcher: "/api/:path*" } 

I ran the fuller version of this on 15.5.20. Next.js 16 renamed the file convention to proxy.ts with a proxy function, and there is a codemod for the move. The full article has the complete middleware, including Access-Control-Max-Age.

Cookies, wildcards and caching

Once the frontend sends credentials, the server has to name one explicit origin. MDN says the wildcard is off limits for the allow-origin, allow-headers, allow-methods and expose-headers values on a credentialed response, and Authorization must always be listed by name even when you use * elsewhere.

Access-Control-Max-Age tells the browser how long to reuse a preflight result. The default is 5 seconds, Chromium caps it at 2 hours, and Firefox at 24 hours. A value of 600 is a sensible choice.

The six errors Chrome prints

I triggered each of these on purpose in Chrome 154 and copied the text. They all begin with has been blocked by CORS policy: after the request and origin.

  • No 'Access-Control-Allow-Origin' header is present on the requested resource. No CORS headers came back at all. Make sure the middleware runs before your routes.
  • The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. Replace * with the exact origin.
  • Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response. List Authorization explicitly.
  • Method PUT is not allowed by Access-Control-Allow-Methods in preflight response. Add the method.
  • Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. Your OPTIONS handling is missing. Express answers an unhandled OPTIONS with a bare 200 and an Allow header, so add middleware ahead of auth and routing.
  • The 'Access-Control-Allow-Origin' header contains multiple values '*, *', but only one is allowed. Two layers, usually the app and a proxy, both set it. Pick one.

When CORS is the wrong tool

CORS is not authorization. A wildcard origin is fine for a public, read-only API with no cookies. Reflecting any Origin while also sending Allow-Credentials: true is the dangerous combination, because it lets any site read a signed-in user's data. If you control both ends, proxying /api through the frontend's own origin avoids the whole problem.

References