Your frontend calls an API and the console shows 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 request often works in Postman or curl, which makes it more confusing. This guide explains what is actually happening and how to fix a CORS error properly on the server, or with a proxy when you don’t control the API.
Quick fix
You fix CORS on the server that receives the request, not in your frontend code. That server must:
- Return
Access-Control-Allow-Originwith your exact origin, for examplehttps://app.example.com. - Answer the
OPTIONSpreflight request with a204and the allowed methods and headers. - If you send cookies or use
credentials: 'include', returnAccess-Control-Allow-Credentials: trueand a specific origin, never*.
In Express, that’s:
import express from 'express';
import cors from 'cors';
const app = express();
app.use(cors({
origin: ['https://app.example.com', 'http://localhost:5173'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
app.get('/data', (req, res) => res.json({ ok: true }));
app.listen(3000);
Register cors() before your routes and before any auth middleware, so preflight requests are answered before something rejects them.
What CORS actually is (and isn’t)
The browser enforces CORS, not the server. By default, the same-origin policy stops JavaScript on one origin (scheme + host + port) from reading responses from another. CORS is how a server opts in, by sending response headers that say “this origin may read my responses”.
That explains the usual confusion:
- Postman, curl and server-side code are not browsers, so they ignore CORS entirely.
- The request often does reach the server. The browser just refuses to give your JavaScript the response.
- CORS is not a security feature for your API. It protects users’ browsers. Anyone can still call your API from outside a browser, so you still need authentication.
http://localhost:3000andhttp://localhost:5173are different origins, because the port differs.
Simple vs preflight requests
A “simple” request, such as a GET or a form-style POST with only basic headers, goes straight to the server. The browser then checks the response for Access-Control-Allow-Origin.
Anything else triggers a preflight: an OPTIONS request sent first to ask permission. That includes PUT, PATCH or DELETE, a Content-Type: application/json body, or an Authorization header. If the preflight fails, the real request is never sent, and you see:
Response to preflight request doesn't pass access control check
The server must answer the OPTIONS request with a 2xx status and these headers:
Access-Control-Allow-Origin: your origin.Access-Control-Allow-Methods: every method you use.Access-Control-Allow-Headers: every custom header you send, includingAuthorizationandContent-Type.- Optionally
Access-Control-Max-Age, so the browser caches the preflight result.
Credentials and the wildcard trap
If the request includes cookies or HTTP auth (fetch(url, { credentials: 'include' }) or axios with withCredentials: true), you’ll see this if the server replies with *:
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'.
Echo back the request’s Origin only if it’s in your allow-list, add Access-Control-Allow-Credentials: true, and add Vary: Origin so caches don’t serve one origin’s response to another. Never echo back any origin without checking it. With credentials, that lets any website make authenticated requests as your users.
Fixing it on your server
Next.js route handlers
// app/api/data/route.ts
const ALLOWED = new Set(['https://app.example.com']);
function corsHeaders(origin: string | null): Record<string, string> {
if (!origin || !ALLOWED.has(origin)) return {};
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
Vary: 'Origin',
};
}
export async function OPTIONS(req: Request) {
return new Response(null, { status: 204, headers: corsHeaders(req.headers.get('origin')) });
}
export async function GET(req: Request) {
return Response.json({ ok: true }, { headers: corsHeaders(req.headers.get('origin')) });
}
PHP
<?php
$allowed = ['https://app.example.com'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowed, true)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
header('Vary: Origin');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Access-Control-Max-Age: 86400');
http_response_code(204);
exit;
}
Nginx
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
}
server {
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true 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-Max-Age 86400 always;
add_header Vary Origin always;
return 204;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Vary Origin always;
proxy_pass http://127.0.0.1:3000;
}
}
Two Nginx details matter. Without always, headers aren’t added to 4xx and 5xx responses, so a server error shows up in the browser as a CORS error. And add_header in a nested block replaces the parent’s headers instead of adding to them, which is why the if block repeats them. On Apache, use Header always set from mod_headers for the same reason.
Cloudflare Workers
const ALLOWED = new Set(['https://app.example.com']);
export default {
async fetch(request) {
const origin = request.headers.get('Origin');
const cors = ALLOWED.has(origin)
? { 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Credentials': 'true', Vary: 'Origin' }
: {};
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
...cors,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
},
});
}
const response = await fetch(request); // pass through to origin
const out = new Response(response.body, response);
for (const [key, value] of Object.entries(cors)) out.headers.set(key, value);
return out;
},
};
Less obvious causes
- The real error is a 404, 401 or 500. If the error response doesn’t carry CORS headers, the browser reports CORS and hides the status. Check the Network tab for the actual status code.
- Auth middleware rejects the preflight. Browsers never send
Authorizationor cookies onOPTIONS, so a guard that demands a token returns 401 and the preflight fails. - Redirects. A preflight that gets a 301/302, for example from HTTP to HTTPS or from a missing trailing slash, fails with
Redirect is not allowed for a preflight request. Call the final URL directly. - A CDN or proxy strips or caches headers. A cached response without
Vary: Origincan be served to another origin. Some CDNs and S3 buckets need CORS configured in their own settings, not just on your origin server. - Duplicate headers. If both the app and Nginx add
Access-Control-Allow-Origin, the browser sees two values and rejects them. Set it in one place only.
The no-cors misconception
fetch(url, { mode: 'no-cors' }) doesn’t bypass CORS. It returns an opaque response: status 0 and an empty body that your code can’t read. It’s only useful for fire-and-forget requests. Browser extensions that disable CORS only change your own browser, so your users still get the error.
Fonts and WebGL textures
CORS applies to more than fetch. Web fonts loaded from another origin need Access-Control-Allow-Origin on the font files. Images used as WebGL textures in Three.js need both the header and a CORS-mode request (img.crossOrigin = 'anonymous', which Three.js’s TextureLoader sets by default). Otherwise you get The image element contains cross-origin data, and may not be loaded. The same applies to glTF models on a CDN. If a model fails to load, also see fixing a Three.js model that isn’t showing.
When you don’t control the API: use a same-origin proxy
If a third-party API doesn’t send CORS headers, call it from your own server. Server-to-server requests aren’t subject to CORS, and your frontend only talks to its own origin. This also keeps API keys out of the browser.
// next.config.js
module.exports = {
async rewrites() {
return [
{ source: '/api/weather/:path*', destination: 'https://api.weather.example/:path*' },
];
},
};
In development, Vite’s server.proxy option does the same job. Avoid public “CORS proxy” services in production. They see all your traffic and can disappear at any time.
Step-by-step diagnosis
- Open DevTools, go to Network, and find the failing request. Look for a separate
OPTIONSrequest before it. - Check the status code of the preflight and of the real request. A non-2xx status points to a server or auth problem.
- Inspect the response headers. Is
Access-Control-Allow-Originpresent, exactly matching, and set only once? - Replay the preflight with curl to see the raw response:
curl -i -X OPTIONS https://api.example.com/data
-H "Origin: https://app.example.com"
-H "Access-Control-Request-Method: POST"
-H "Access-Control-Request-Headers: content-type, authorization"
Prevention
- Keep one explicit origin allow-list per environment in config, and include your local dev origins.
- Handle CORS in exactly one layer: the app, the proxy or the CDN.
- Add a curl preflight check like the one above to your deploy smoke tests.
- Serve frontend and API from the same origin (for example
/api) when you can. Then there’s no CORS at all.
Key takeaways
- CORS is enforced by browsers and fixed on the server that receives the request.
- Non-simple requests send an
OPTIONSpreflight that must succeed without auth. - With credentials, return a specific origin, never
*, plusVary: Origin. - Many “CORS errors” are really 404, 401 or 500 responses without headers.
no-corsdoesn’t fix anything. Use a same-origin proxy for APIs you don’t control.
Frequently asked questions
Can I fix a CORS error from the frontend?
No. The server you are calling must send the CORS headers. If you don’t control that server, route the request through your own backend or a same-origin proxy.
Why does my request work in Postman but not in the browser?
Postman isn’t a browser, so it doesn’t apply the same-origin policy or send preflight requests. The browser blocks your JavaScript from reading the response unless the server explicitly allows your origin.
Why can’t I use Access-Control-Allow-Origin * with cookies?
Browsers reject the wildcard for credentialed requests, to stop any site from making authenticated calls as your users. Echo back an allow-listed origin and set Access-Control-Allow-Credentials: true.
Does mode: ‘no-cors’ fix CORS errors?
No. It makes the request but gives you an opaque response with no readable status or body. It’s only useful when you don’t need the response.
Fighting CORS between a frontend, an API and a CDN you only half control? I set up these stacks regularly and can sort it out quickly. Get in touch. If your site also shows insecure-content warnings, read how to fix mixed content errors next.