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-Origin based on the request origin, add Vary: Origin to 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