You call your API from the frontend and the console shows:
Access to fetch at 'https://api.example.com/data' from origin 'http://localhost:5173'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
The request often reached your server and succeeded. The browser just refused to hand the response to your JavaScript. CORS is enforced by the browser, so the fix almost always lives on the server, not in the fetch call.
Step 1: Is it a preflight?
The browser sends an OPTIONS request first if any of these are true:
- the method isn't GET, HEAD or POST
- you send a Content-Type other than
text/plain,multipart/form-dataorapplication/x-www-form-urlencoded(e.g.application/json) - you send custom headers such as
Authorization
Open DevTools > Network, tick "All", and look for an OPTIONS request. If it returns 404, 401 or 405, that is your bug: your router or auth middleware rejects OPTIONS before CORS headers are added.
Step 2: Check the four headers
| Header | Needed when |
|---|---|
Access-Control-Allow-Origin |
always; exact origin, or *
|
Access-Control-Allow-Methods |
preflight |
Access-Control-Allow-Headers |
preflight with custom headers |
Access-Control-Allow-Credentials: true |
cookies / credentials: 'include'
|
You can test without a browser:
curl -i -X OPTIONS https://api.example.com/data \
-H "Origin: http://localhost:5173" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type,authorization"
Step 3: The classic gotchas
-
*with credentials.Allow-Origin: *is ignored when the request carries cookies. Echo the specific origin and addVary: Origin. -
Trailing slash or port mismatch.
http://localhost:5173≠http://localhost:5173/≠http://127.0.0.1:5173. - Headers only on success. Your 500 error page has no CORS headers, so a server crash looks like a CORS error. Check the server log.
-
Duplicate headers. Nginx and the app both add
Allow-Origin; browsers reject two values. - Auth before CORS. Move CORS middleware above auth so OPTIONS gets answered.
Copy-paste fixes
Express
import cors from "cors";
app.use(cors({ origin: ["http://localhost:5173", "https://app.example.com"], credentials: true }));
Next.js route handler
const H = { "Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization" };
export async function OPTIONS() { return new Response(null, { status: 204, headers: H }); }
export async function POST(req) { return Response.json({ ok: true }, { headers: H }); }
Flask
from flask_cors import CORS
CORS(app, origins=["https://app.example.com"], supports_credentials=True)
FastAPI
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(CORSMiddleware, allow_origins=["https://app.example.com"],
allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
Nginx
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials true always;
return 204;
}
add_header Access-Control-Allow-Origin $http_origin always;
add_header Vary Origin always;
proxy_pass http://app;
}
(Use a map allowlist instead of blindly echoing $http_origin in production.)
Dev-only shortcut: a proxy
In Vite, server.proxy = { '/api': 'http://localhost:3000' } makes the request same-origin, so CORS never applies locally.
Disclosure: I drafted this article with help from an AI assistant and checked the code and claims by hand.
If you'd rather not walk through this by hand every time, I packaged a small diagnosis script plus tested configs for 10+ frameworks as the CORS Fix Kit ($9). The checklist above is free to use either way.
Independent write-up by the author; not affiliated with or endorsed by Stripe, Shopify, GitHub, Gumroad or any other vendor mentioned.
Top comments (0)