CORS Without the Guesswork: Express, FastAPI and Next.js
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 wro
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.
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.
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.ListAuthorizationexplicitly. -
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.YourOPTIONShandling is missing. Express answers an unhandledOPTIONSwith a bare200and anAllowheader, 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
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.
