Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

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

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 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

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.