
You've seen it. You write a perfectly fine fetch call, your backend returns 200 OK, and then the browser throws this at you:
Access to fetch at 'https://api.example.com/users' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
Most developers read this and think: "CORS is blocking my request." That's understandable — but it's not quite right. To fix it reliably, you need to understand what's actually happening under the hood.
Before CORS even enters the picture, there's Same-Origin Policy (SOP) — the real gatekeeper.
SOP is a browser security rule that restricts how JavaScript on one origin can interact with resources from another origin. An origin is defined by three things:
| Component | Example |
|---|---|
| Protocol | https:// |
| Domain | api.example.com |
| Port | :443 |
All three must match. If any one of them differs, you're dealing with a cross-origin request and SOP kicks in.
Some examples:
| From | To | Same origin? |
|---|---|---|
http://localhost:3000 | http://localhost:8080 | ❌ Different port |
https://app.com | https://api.app.com | ❌ Different subdomain |
https://app.com | http://app.com | ❌ Different protocol |
Why does SOP exist? Imagine you're logged into your bank. A malicious site you visit in another tab tries to make a fetch request to https://yourbank.com/account/transfer. Without SOP, the browser would happily send your session cookies along and expose your data. SOP prevents that.
Not all cross-origin requests are handled the same way. The browser splits them into two categories, and this distinction matters a lot for debugging.
A request is "simple" if it meets all of the following:
GET, POST, or HEADAccept, Content-Type with value text/plain, multipart/form-data, or application/x-www-form-urlencoded, etc.)AuthorizationSimple requests are sent directly — no extra handshake.
Anything outside those constraints is non-simple. Common examples:
PUT, DELETE, PATCHContent-Type: application/jsonAuthorization, X-Api-Key, etc.)Non-simple requests trigger a preflight — the browser sends an OPTIONS request first to ask the server for permission before sending the real request.
Understanding which category your request falls into is the first step when debugging. We'll come back to this in the practical guide.
Here's the part most developers don't fully internalize: the browser doesn't block the request from leaving your machine. The request goes out. The server processes it. The server responds.
What the browser controls is whether your JavaScript can read the response.
The flow differs depending on your request type:
Browser → sends request directly → Server responds
Browser checks Access-Control-Allow-Origin in response
✅ Match → JS can read the response
❌ No match → browser blocks access
Browser → sends OPTIONS preflight → Server responds
Browser checks CORS headers in preflight response
❌ Missing or wrong → stops here, actual request never sent
✅ OK → Browser sends the actual request → Server responds
Browser checks CORS headers again on actual response
✅ Match → JS can read the response
❌ No match → browser blocks access
Notice that for non-simple requests, the browser checks CORS headers twice — once on the preflight, and again on the actual response. Both must pass.
Your backend worked. Your browser just refused to show you the result.
CORS — Cross-Origin Resource Sharing — is the mechanism that lets a server tell browsers: "It's okay, this origin is allowed to read my responses."
It doesn't block anything. It relaxes the Same-Origin Policy.
When a browser makes a cross-origin request, it automatically attaches an Origin header:
Origin: http://localhost:3000
The server can then respond with:
Access-Control-Allow-Origin: http://localhost:3000
If that header is present and matches, the browser allows your JavaScript to access the response. If it's missing or doesn't match, SOP blocks it — regardless of what HTTP status code the server returned.
Access-Control-Allow-Origin: https://app.com
Or to allow any origin (use carefully — avoid with credentials):
Access-Control-Allow-Origin: *
For non-simple requests, the OPTIONS preflight response must also include:
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
If your request includes cookies or an Authorization header, you cannot use *. You must specify the exact origin:
Access-Control-Allow-Origin: https://app.com
Access-Control-Allow-Credentials: true
Let's put it plainly:
"CORS blocked my request" — what devs say
"SOP blocked access to the response, because CORS headers were missing" — what actually happened
The request reached your server. Your server responded. The browser saw no Access-Control-Allow-Origin header (or the wrong value), and blocked your JavaScript from reading it.
CORS is not an attacker. It's just the server's way of opting in to cross-origin access — and if the server hasn't opted in, the browser enforces the default deny.
If you've ever tested an API in Postman or curl and it worked fine, but it broke in the browser — this is why.
SOP is a browser-only rule. When curl sends a request, there's no browser enforcing same-origin checks. The response comes back and you see it directly.
If it works in PostPilot but not in the browser → you have a CORS misconfiguration, not a backend bug.
This is a useful first signal. It tells you your API is healthy, and the fix belongs on the backend (adding the right CORS headers) or in your server config.
Check your request: what method are you using? Do you have Authorization or Content-Type: application/json? If yes — it's non-simple and a preflight is involved. This determines which steps below apply to you.
Not sure? Jump back to Simple vs Non-Simple Requests.
Simple requests: skip this step — no preflight is sent.
For non-simple requests, manually fire the preflight to see exactly what the server returns.
curl -X OPTIONS https://api.example.com/users \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: GET" \
-v
Origin — your frontend's originAccess-Control-Request-Method — the method of your actual request-v — verbose, so you can see response headersIf you're sending custom headers in the real request, add them too:
curl -X OPTIONS https://api.example.com/users \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: Authorization" \
-v
Paste the curl command directly into PostPilot — it parses the command and builds the request for you. You can then inspect response headers in a clean table view without squinting at terminal output.
You must see:
Access-Control-Allow-Origin: http://localhost:3000
Depending on your request, you may also need:
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Authorization, Content-Type
| Problem | Symptom |
|---|---|
Missing Access-Control-Allow-Origin | Header not present in response |
| Origin mismatch | http:// vs https://, wrong port |
| Backend not handling OPTIONS | Returns 404 or 405 for OPTIONS |
| Custom header not whitelisted | Authorization not in Access-Control-Allow-Headers |
Using * with credentials | Browser rejects wildcard when cookies are involved |
Once the preflight looks good (or if you're dealing with a simple request), send the actual request and check its response headers too.
This is important: the browser checks Access-Control-Allow-Origin on the actual response as well — not just the preflight. Both must return the correct header.
You can do this with curl:
curl https://api.example.com/users \
-H "Origin: http://localhost:3000" \
-H "Authorization: Bearer your-token" \
-v
Or in PostPilot, build out the full request with your real headers and inspect the response. Look for the same Access-Control-Allow-Origin header you confirmed in Step 2.
If the actual response is missing it — that's your bug. The preflight can pass and the real request can still be blocked.
Once you've confirmed what's missing, here's how to add it.
quarkus.http.cors=true
quarkus.http.cors.origins=http://localhost:3000
quarkus.http.cors.methods=GET,POST,PUT,DELETE
quarkus.http.cors.headers=Authorization,Content-Type
@CrossOrigin(origins = "http://localhost:3000")
@GetMapping("/users")
public List<User> getUsers() { ... }
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*");
}
}
const cors = require('cors');
app.use(cors({
origin: 'http://localhost:3000',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type'],
}));
When something breaks, run through this checklist in your head:
"CORS doesn't block — your browser does. CORS is how your server tells the browser to stand down."
The next time you see that CORS error, don't reach for a Chrome extension to disable CORS checks. Instead:
Access-Control-Allow-Origin appears on both the preflight and actual responseCORS errors are predictable once you understand the model. The browser is doing exactly what it's supposed to do — your job is to configure the server to grant permission explicitly.
Tools like PostPilot make this faster: send the OPTIONS request, inspect the headers visually, confirm the actual request also returns the right headers — all without leaving your browser.
Once it clicks, CORS stops feeling like a random wall and starts feeling like a system you're in control of.