You ship a new frontend, open the console, and there it is: a red CORS error telling you the request was “blocked by CORS policy.” Your API works fine in Apidog or curl, yet the browser refuses to hand your JavaScript the response. Frustrating? Yes. Mysterious? Not once you know where the error lives.
Here’s the core fact most tutorials bury: a CORS error is enforced by the browser but caused by the server. The browser blocks the response because your server didn’t send the right Access-Control-Allow-Origin headers. So the fix almost always happens in server config, not in your frontend code.
This guide walks through what CORS does, how the preflight request works, the six most common CORS error messages with the exact fix for each, and working config for Express, Spring Boot, and Nginx. You’ll also see how to debug from outside the browser, which is the fastest way to tell “server misconfigured” apart from “browser blocked.”
What a CORS error is (and what it isn’t)
CORS stands for Cross-Origin Resource Sharing. By default, browsers enforce the same-origin policy: JavaScript running on https://app.example.com can’t read responses from https://api.example.com, because the scheme, host, or port differs. CORS is the mechanism servers use to relax this rule on purpose. The full details live in the MDN CORS documentation, and the underlying algorithm is defined in the Fetch specification.
Three points clear up most confusion:
- The browser enforces it. Only browsers apply CORS checks. Server-to-server calls, curl, and desktop API clients ignore it entirely.
- The server configures it. The browser decides based on response headers your server sends. No headers, no access.
- The request usually still reaches the server. For simple requests, the server processes everything and responds. The browser then withholds the response from your JavaScript. CORS is not a security wall around your API; it protects users from malicious pages reading cross-origin data with their cookies.
So when you see a CORS error, don’t reach for a frontend workaround. Read the error message, then fix the missing or wrong header on the server.
Anatomy of the preflight request
Before certain cross-origin requests, the browser sends a scout: an OPTIONS request called the preflight. It fires when your request uses methods beyond GET, HEAD, or POST, sends custom headers like Authorization, or uses a Content-Type such as application/json.
The preflight looks like this:
OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
The browser is asking: “A page on app.example.com wants to POST here with these headers. Allowed?” A correct server answer:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Vary: Origin
If any piece is missing, the browser cancels the real request before it ever fires. Your API endpoint never runs, your logs show nothing but an OPTIONS hit, and the console shows a CORS error. Access-Control-Max-Age tells the browser to cache this verdict (86400 seconds here), so repeat requests skip the preflight.
Keep this two-step dance in mind. Half of all CORS debugging comes down to one question: did the preflight fail, or did the actual request fail?
The 6 most common CORS errors and how to fix each one
Browsers write surprisingly precise CORS error messages. Match yours to the list below.
1. No ‘Access-Control-Allow-Origin’ header is present
The classic. Your server sent a response with no CORS headers at all. The browser had nothing to evaluate, so it blocked access.
Fix: Configure the server to send Access-Control-Allow-Origin with either the specific requesting origin or * for public, credential-free APIs:
Access-Control-Allow-Origin: https://app.example.com
One trap: error responses often skip CORS headers even when success responses include them. If your API returns a 500 and the middleware only decorates 200s, the console shows a CORS error instead of the real server error. Make sure CORS headers are attached to every response, including 403 Forbidden and 500 pages.
2. Wildcard ‘*’ cannot be used with credentials
The message reads: “The value of the ‘Access-Control-Allow-Origin’ header must not be the wildcard ‘*’ when the request’s credentials mode is ‘include’.”
Your frontend sends cookies or auth headers with credentials: 'include', but the server answers with Access-Control-Allow-Origin: *. The Fetch spec forbids this pairing; a wildcard plus credentials would let any site on the internet read authenticated responses.
Fix: Echo the exact origin instead of the wildcard, and add the credentials header:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Validate the incoming Origin against an allowlist before echoing it. Reflecting arbitrary origins with credentials enabled defeats the entire protection.
3. Response to preflight request doesn’t pass access control check
Your server never handled the OPTIONS request. Maybe the route only defines POST, so OPTIONS returns a 404 or 405. Maybe an auth middleware rejected it with a 401 because the preflight carries no token (browsers never attach credentials to preflights).
Fix: Handle OPTIONS explicitly and return a 2xx with the full set of CORS headers before authentication runs. In most frameworks, mounting the CORS middleware first solves it. If you’re writing it by hand:
app.options('/v1/orders', (req, res) => {
res.set({
'Access-Control-Allow-Origin': 'https://app.example.com',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Authorization, Content-Type'
});
res.sendStatus(204);
});
4. The header value is not equal to the supplied origin
The server sends an Access-Control-Allow-Origin header, but it names the wrong origin. Common causes: a hardcoded production origin while you’re testing from http://localhost:5173, an allowlist comparison failing on http vs https, or a stray trailing slash (https://app.example.com/ is not a valid origin value).
Fix: Compare the request’s Origin header against your allowlist exactly, echo the match, and send Vary: Origin so caches and CDNs don’t serve one origin’s header to another:
const allowed = ['https://app.example.com', 'http://localhost:5173'];
if (allowed.includes(req.headers.origin)) {
res.set('Access-Control-Allow-Origin', req.headers.origin);
res.set('Vary', 'Origin');
}
5. Request header field or method is not allowed
Two sibling messages: “Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response” and “Method PUT is not allowed by Access-Control-Allow-Methods.”
The preflight succeeded, but its answer didn’t cover what your request needs. You added an Authorization header or an X-Request-Id, and the server’s allowlist never mentioned it.
Fix: Extend the preflight response to include every header and method your frontend sends:
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-Id
Header names here are case-insensitive. Methods are case-sensitive and uppercase.
6. Redirect is not allowed for a preflight request
The preflight hit a URL returning 301 or 302, and browsers refuse to follow redirects during preflight. Typical culprits: an http URL redirecting to https, a missing trailing slash your framework “helpfully” redirects, or a gateway bouncing /v1/orders to /v1/orders/.
Fix: Point your frontend at the final URL directly. Use https from the start, match the trailing-slash convention of your router, and confirm with a manual OPTIONS call to check whether the endpoint answers with a 2xx instead of a 3xx.
Server config examples
Here’s correct CORS setup in three common stacks.
Express
Use the official cors middleware instead of hand-rolling headers:
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors({
origin: ['https://app.example.com', 'http://localhost:5173'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type'],
credentials: true,
maxAge: 86400
}));
Mount it before your auth middleware so preflights never get rejected for missing tokens. Python developers get the same pattern from the Flask-CORS extension, which wraps identical header logic for Flask apps.
Spring Boot
Global configuration through WebMvcConfigurer:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/v1/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Authorization", "Content-Type")
.allowCredentials(true)
.maxAge(86400);
}
}
Using Spring Security? Call .cors(Customizer.withDefaults()) in your security filter chain too, or the security layer will block preflights before the MVC config ever sees them. See the Spring CORS documentation for the full option set.
Nginx
When Nginx terminates requests in front of your app, answer preflights at the edge:
location /v1/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Max-Age 86400 always;
return 204;
}
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Vary "Origin" always;
proxy_pass http://backend;
}
The always flag matters. Without it, Nginx drops add_header directives on 4xx and 5xx responses, which recreates error number one on every failed request. And pick one layer to own CORS: if both Nginx and your app add headers, browsers see duplicates like Access-Control-Allow-Origin: *, * and reject the response.
Debug CORS outside the browser with Apidog
The console error tells you the browser blocked something. It doesn’t tell you what the server sent. The fastest way to see the truth is to take the browser out of the loop.
Apidog is a desktop API client, so its requests aren’t subject to browser CORS checks at all. That gives you a clean experiment: send the same request from Apidog that your frontend was making. If it succeeds there, your API logic is fine and the problem is purely missing CORS headers. If it fails there too, you have an ordinary API bug wearing a CORS costume, and general API testing techniques apply.
A CORS debugging session in Apidog looks like this:
- Replay the real request. Copy the failing request from your browser’s Network tab and recreate it in Apidog with the same method, headers, and body. Check the status and body. A 500 here means CORS was never your problem.
- Test the preflight manually. Create a new request, set the method to
OPTIONS, and add the headers a browser would send:Origin: https://app.example.com,Access-Control-Request-Method: POST, andAccess-Control-Request-Headers: authorization, content-type. Send it. - Inspect the response headers. In the response pane, look for
Access-Control-Allow-Origin,Access-Control-Allow-Methods, andAccess-Control-Allow-Headers. Compare each value against what your frontend needs. A missing header, a wrong origin, or a 3xx status jumps out immediately, no console guesswork involved. - Verify the fix. After changing server config, resend the same saved
OPTIONSrequest and watch the headers update. No redeploying frontends, no cache-clearing rituals.
This workflow also settles the eternal “works in my API client, fails in the browser” argument in seconds, the same puzzle behind the Postman CORS test question. The client works because it skips CORS. The browser fails because your server hasn’t said the magic words. Download Apidog for free and keep the OPTIONS request saved next to your regular endpoint tests; future CORS fires get put out in one click.
A 30-second CORS checklist
Before you file the bug, run through this list:
- Does the failing response include
Access-Control-Allow-Originat all? - Does its value exactly match your page’s origin (scheme, host, port, no trailing slash)?
- Using cookies or auth? Confirm a specific origin plus
Access-Control-Allow-Credentials: true, never*. - Does
OPTIONSreturn a 2xx with methods and headers covering your request? - Any redirect on the preflight URL?
- Do error responses (401, 403, 500) carry the same CORS headers as success responses?
Nine times out of ten, one of those six lines is your answer. Verify it with a manual OPTIONS request in Apidog, patch the server config, and get back to building.
FAQ
Why do I get a CORS error only in the browser?
Because only browsers enforce CORS. The same-origin policy protects users from malicious pages reading their authenticated data, so browsers check Access-Control-Allow-Origin on every cross-origin response. curl, backend services, and desktop clients have no such rule. If a request succeeds everywhere except the browser, your server is missing or misconfiguring CORS headers; the API itself is healthy.
Does CORS apply to Postman or Apidog?
No. Postman and Apidog are desktop applications, not web pages running inside a browser sandbox, so their requests bypass CORS entirely. That’s precisely what makes them useful for CORS debugging: they show you the server’s raw response headers without the browser’s filtering. The Postman CORS test confusion usually starts here; a passing request in a desktop client proves nothing about browser behavior, but it does isolate the failing layer.
Is a CORS error a security feature or a bug?
A feature. CORS errors mean the browser is doing its job: refusing to expose cross-origin response data to scripts unless the server opts in. Disabling CORS in the browser with flags or extensions hides the symptom on your machine while every user still hits the wall. Fix the server headers instead.
Can I use Access-Control-Allow-Origin: * everywhere?
Only for public, read-only APIs with no cookies or authentication. The wildcard is rejected whenever credentials are included, and it announces your data is open to every origin on the web. For anything authenticated, maintain an origin allowlist, echo the matching origin, and send Vary: Origin so shared caches keep responses separated.



