devnotes

CORS preflight request returns 403: how do you fix it?

A 403 on the preflight almost always means your authentication middleware is rejecting the OPTIONS request before your CORS handler ever runs. Browsers send a preflight OPTIONS without cookies or an Authorization header, by design, per the Fetch standard the CORS protocol is defined in. So any middleware that demands credentials treats the preflight as an unauthenticated request and returns 401 or 403, and the real request never gets sent. The fix is to let OPTIONS through: mount your CORS handling before your auth check, or explicitly exempt OPTIONS from authentication. A correct preflight response is a 204 or 200 with Access-Control-Allow-Origin, -Allow-Methods, and -Allow-Headers that cover what the browser asked for in Access-Control-Request-Method and Access-Control-Request-Headers. If the 403 comes from a gateway (API Gateway, an S3 bucket policy, an ingress) instead of your app, the same rule holds: the preflight is being authorized like a real call when it shouldn't be.

Why the preflight has no credentials

A preflight fires only for requests the browser considers non-simple: a method other than GET/POST/HEAD, a custom header, or a Content-Type outside application/x-www-form-urlencoded, multipart/form-data, and text/plain. Add an Authorization header or send JSON and you've triggered one. The browser sends that OPTIONS probe with no cookies and no auth header on purpose, so your server can approve the actual request before the browser reveals anything. Guarding OPTIONS with auth breaks that handshake every time.

The fix, by framework

Order matters: CORS must run before auth. In Express, register the CORS middleware first and it will short-circuit OPTIONS with a 204 before your auth middleware sees it.

// Express — cors BEFORE any auth middleware
app.use(cors({ origin: "https://app.example.com", credentials: true }));
app.use(requireAuth);            // never runs for OPTIONS now

// If you can't reorder, exempt preflights explicitly:
app.use((req, res, next) =>
  req.method === "OPTIONS" ? next() : requireAuth(req, res, next));

Same idea elsewhere. In FastAPI/Starlette, add CORSMiddleware so it wraps the auth dependency. In Spring Security, permit OPTIONS with .requestMatchers(HttpMethod.OPTIONS).permitAll(). Behind Nginx or an API gateway, answer OPTIONS at the proxy with a 204 and the CORS headers, and don't route it through the auth backend.

If it's still 403 after that

Two things left to check. First, the response headers have to match the request: if the browser's Access-Control-Request-Headers lists authorization, content-type and your Access-Control-Allow-Headers omits one, the preflight fails even at 200. Second, with credentials: true you cannot reflect Access-Control-Allow-Origin: *; you must echo the exact origin. A wildcard plus credentials is rejected by the browser, not the server, so the network tab shows the preflight succeeding while the request still blocks.

Notes from fernforge. Preflight and credentials behavior per the MDN CORS reference and the Fetch standard.