Understanding CORS: What Actually Blocks Your API Requests

Understanding CORS for API Developers

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.


What is Same-Origin Policy (SOP)?

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:

ComponentExample
Protocolhttps://
Domainapi.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:

FromToSame origin?
http://localhost:3000http://localhost:8080❌ Different port
https://app.comhttps://api.app.com❌ Different subdomain
https://app.comhttp://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.


Simple vs Non-Simple Requests

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.

Simple Requests

A request is "simple" if it meets all of the following:

  • Method is GET, POST, or HEAD
  • Headers are limited to a safe set (Accept, Content-Type with value text/plain, multipart/form-data, or application/x-www-form-urlencoded, etc.)
  • No custom headers like Authorization

Simple requests are sent directly — no extra handshake.

Non-Simple Requests

Anything outside those constraints is non-simple. Common examples:

  • Methods like PUT, DELETE, PATCH
  • Content-Type: application/json
  • Any custom header (Authorization, 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.


How Browsers Enforce Same-Origin Policy

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:

Simple Request Flow

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

Non-Simple Request Flow

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.


What is CORS?

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.


How CORS Headers Work

The Essential Header

Access-Control-Allow-Origin: https://app.com

Or to allow any origin (use carefully — avoid with credentials):

Access-Control-Allow-Origin: *

Preflight-Specific Headers

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

A Note on Credentials

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

The Biggest Misunderstanding

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.


Why Postman, curl, and PostPilot Don't Have This Problem

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.


Practical Guide: Debugging CORS Issues

Before You Start: Simple or Non-Simple?

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.


Step 1: Send the OPTIONS Preflight Request

Simple requests: skip this step — no preflight is sent.

For non-simple requests, manually fire the preflight to see exactly what the server returns.

Using curl:

curl -X OPTIONS https://api.example.com/users \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: GET" \
  -v
  • Origin — your frontend's origin
  • Access-Control-Request-Method — the method of your actual request
  • -v — verbose, so you can see response headers

If 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

Prefer a UI?

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.


Step 2: What to Look For in the Preflight Response

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

Common reasons the preflight fails:

ProblemSymptom
Missing Access-Control-Allow-OriginHeader not present in response
Origin mismatchhttp:// vs https://, wrong port
Backend not handling OPTIONSReturns 404 or 405 for OPTIONS
Custom header not whitelistedAuthorization not in Access-Control-Allow-Headers
Using * with credentialsBrowser rejects wildcard when cookies are involved

Step 3: Send the Actual Target Request

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.


Configuring CORS in Your Backend

Once you've confirmed what's missing, here's how to add it.

Quarkus

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

Spring Boot

Per-endpoint with annotation:

@CrossOrigin(origins = "http://localhost:3000")
@GetMapping("/users")
public List<User> getUsers() { ... }

Global configuration:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins("http://localhost:3000")
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .allowedHeaders("*");
    }
}

Express (Node.js)

const cors = require('cors');

app.use(cors({
  origin: 'http://localhost:3000',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Authorization', 'Content-Type'],
}));

Mental Model Recap

When something breaks, run through this checklist in your head:

  1. SOP is the default — browsers block cross-origin response access unless told otherwise
  2. CORS is the opt-in — your server adds headers to grant permission
  3. Non-simple requests have a preflight — OPTIONS runs first, and must succeed before the real request is sent
  4. Both responses need CORS headers — preflight response and actual response
  5. API clients bypass this entirely — no browser, no SOP, no CORS checks

"CORS doesn't block — your browser does. CORS is how your server tells the browser to stand down."


Conclusion

The next time you see that CORS error, don't reach for a Chrome extension to disable CORS checks. Instead:

  1. Check if it's a simple or non-simple request
  2. For non-simple requests, manually fire the OPTIONS preflight and read the response
  3. Confirm Access-Control-Allow-Origin appears on both the preflight and actual response
  4. Fix the missing headers on your backend

CORS 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.