Step 1 — Read the Error Message
Open DevTools → Console. CORS errors look like:
Access to fetch at 'https://api.example.com/data' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource. The key parts:
- Blocked origin — your frontend URL (
app.example.com) - Target URL — the API you're calling (
api.example.com) - Reason — what's missing or wrong
Step 2 — Identify the Request Type
CORS behaves differently for simple vs preflighted requests.
| Request type | Trigger | What to check |
|---|---|---|
| Simple | GET/POST with basic headers | Response has Access-Control-Allow-Origin |
| Preflighted | Custom headers, PUT/PATCH/DELETE, JSON body | OPTIONS response + actual response both need CORS headers |
Is it a preflight? Check the Network tab. If you see an OPTIONS request before your actual request, it's preflighted.
Step 3 — Check the Server Response
In DevTools → Network → click the failing request → Response Headers. Look for:
Access-Control-Allow-Origin: https://app.example.com - Missing entirely → Server is not configured for CORS. Add the header.
- Wrong origin → Server is returning a hardcoded origin that doesn't match. Fix the allowlist.
- Wildcard with credentials →
Access-Control-Allow-Origin: *cannot be used withcredentials: 'include'. Use a specific origin.
Step 4 — Fix by Framework
Express.js
const cors = require('cors') // Allow a single origin app.use(cors({ origin: 'https://app.example.com' })) // Allow multiple origins dynamically const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com']) app.use(cors({ origin(origin, cb) { if (!origin || ALLOWED.has(origin)) return cb(null, true) cb(new Error('Not allowed by CORS')) }, credentials: true }))Next.js App Router
// app/api/data/route.ts export async function GET(request: Request) { return Response.json({ data: [] }, { headers: { 'Access-Control-Allow-Origin': 'https://app.example.com', 'Access-Control-Allow-Credentials': 'true' } }) } export async function OPTIONS() { return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': 'https://app.example.com', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Max-Age': '86400' } }) }nginx
location /api/ { add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always; add_header 'Access-Control-Allow-Credentials' 'true' always; if ($request_method = 'OPTIONS') { add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE'; add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization'; add_header 'Access-Control-Max-Age' 86400; return 204; } }Step 5 — Common Mistakes
Mistake: Setting CORS headers on the wrong server. If your frontend is behind a reverse proxy (nginx, Cloudflare), the CORS headers must come from the API server, not the proxy — unless the proxy is the one handling the cross-origin request.
Mistake: Using
*with credentials.// ❌ This will fail when credentials: 'include' is set res.set('Access-Control-Allow-Origin', '*') res.set('Access-Control-Allow-Credentials', 'true') // ✅ Use specific origin res.set('Access-Control-Allow-Origin', req.headers.origin) res.set('Vary', 'Origin') res.set('Access-Control-Allow-Credentials', 'true')Mistake: Forgetting the Vary header. When you dynamically set
Access-Control-Allow-Originbased on the request origin, addVary: Originto prevent CDNs from caching the wrong origin's response.Mistake: CORS on the client side. CORS is enforced by the browser. You cannot bypass it from JavaScript. The fix must be on the server. If you control neither server, use a server-side proxy.
Checklist
- Server sends
Access-Control-Allow-Originmatching your frontend origin - If using credentials: specific origin (not
*) +Access-Control-Allow-Credentials: true - Preflight OPTIONS returns
Access-Control-Allow-MethodsandAccess-Control-Allow-Headers Vary: Originis set when origin is dynamic- CORS headers are on the API server, not just the proxy