All posts

CORS: The Browser's Very Paranoid Security Guard

A humorous, intuitive guide to Same-Origin Policy, preflight requests, simple headers, credentials, and why Postman does not care about your CORS problems.

10 min readCORSBrowsersWeb Security

Imagine you've built a beautiful frontend.

Your frontend lives here:

https://my-awesome-app.com

Your backend lives here:

https://api.my-awesome-app.com

Your JavaScript says:

fetch("https://api.my-awesome-app.com/users");

And suddenly...

🚨 CORS ERROR

You're sitting there thinking:

Bro. It's MY API. It's MY frontend. Why are you stopping me?

Because the browser doesn't know that.

The browser doesn't know whether you're a professional developer, a beginner, or a guy who named his production server final-final-v3-really-final.

The browser's attitude is:

I don't trust you. Prove it.

And that's basically where CORS begins.

First, meet the security guard: Same-Origin Policy

Before we talk about CORS, we need to understand something more fundamental.

The browser follows a security rule called the Same-Origin Policy (SOP). It basically says:

A webpage should not freely access resources belonging to another origin.

But what exactly is an origin?

An origin consists of:

Protocol + Host + Port

For example:

https://example.com:443

The three pieces are:

Protocol → https
Host     → example.com
Port     → 443

If any of these change, you've got a different origin.

So:

https://example.com
https://example.com

Same origin. ✅

But:

http://example.com

Different protocol. ❌

And:

https://api.example.com

Different host. ❌

And:

https://example.com:8080

Different port. ❌

So even though humans might say "they're all basically example.com", the browser says:

I see three different situations. Please stop trying to make me emotionally understand your architecture.

Why does the browser need this?

Let's imagine CORS didn't exist.

You're logged into your bank:

https://mybank.com

Your browser has a cookie:

session=ABC123

Now you visit a malicious website:

https://evil.com

That website runs:

fetch("https://mybank.com/api/transfer", {
    method: "POST",
    body: JSON.stringify({
        amount: 100000,
        account: "attacker"
    })
});

If browsers allowed websites to freely read responses from other origins, evil.com could potentially interact with your bank while you're logged in.

That's obviously bad. So the browser says:

Hold on. Why is this random website trying to talk to your bank?

This is one of the reasons browsers enforce the Same-Origin Policy.

Enter CORS

CORS stands for Cross-Origin Resource Sharing. The name sounds complicated, but the basic idea is surprisingly simple.

The browser asks the server:

Hey, this request is coming from https://myfrontend.com. Are you okay with that?

The server can respond:

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

The browser looks at that and says:

Okay. The server explicitly allows this origin.

And JavaScript gets access to the response.

Important: who actually enforces CORS?

This is probably the most important thing to understand.

The server does NOT enforce CORS. The browser enforces CORS.

The server simply provides information through HTTP headers. Think of it like this:

JavaScript

Browser

"Can I access this?"

Server

"CORS headers say yes."

Browser

"Okay, JavaScript can have the response."

The browser is the security guard. The server is basically showing the security guard its guest list.

So why doesn't Postman have CORS errors?

Because Postman isn't a browser.

Suppose:

Browser → API

The browser says: "CORS policy! I need to check this."

But:

Postman → API

Postman says: "I'm not a browser. I do what I want."

Same with:

curl
Node.js
Python
Java
Go

They don't enforce the browser's Same-Origin Policy. Therefore CORS is primarily a browser security mechanism.

This is why you can have:

Postman → API → 200 OK

while:

Browser → API → CORS ERROR

And you're sitting there thinking: "But Postman works!"

Yes. Because Postman doesn't have the browser's security guard standing outside.

Now let's talk about preflight

This is where CORS gets interesting.

Sometimes the browser looks at your request and says:

Hmm... this isn't a basic request.

So before sending the real request, it sends another request first. This is called a preflight request, and it's an HTTP OPTIONS request.

The browser essentially asks: "Can I send this request?"

Example: a PUT request

Suppose:

fetch("https://api.example.com/users/123", {
    method: "PUT",
    headers: {
        "Authorization": "Bearer abc",
        "Content-Type": "application/json"
    },
    body: JSON.stringify({
        name: "Harshit"
    })
});

The browser doesn't immediately send:

PUT /users/123

Instead, it might first send:

OPTIONS /users/123
 
Origin: https://myfrontend.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type

The browser is basically asking:

Dear server, may I please send a PUT request from this origin using these headers?

The server responds:

Access-Control-Allow-Origin: https://myfrontend.com
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: Authorization, Content-Type

Browser: "Permission granted."

Then it sends:

PUT /users/123

Why does the browser do this?

Here's an important misconception. You might think:

PUT, PATCH and DELETE cause preflight because they can modify the database.

Not exactly. The browser has absolutely no idea what your API does.

You could have:

DELETE /users

which does nothing. And you could have:

GET /deleteEverything

which deletes your entire company. The browser doesn't know. It only knows the request characteristics.

So the correct explanation is: PUT, PATCH and DELETE are classified as non-simple methods, so cross-origin requests using them require preflight. It's not about whether they actually modify a database.

What is a "simple" request?

The CORS specification has a category of requests called simple requests. A simple request can be sent cross-origin without a preflight, provided it meets the relevant conditions.

Common simple methods are:

GET
POST
HEAD

But the method alone isn't enough. The headers and content type matter too. And that's where things get interesting.

Simple headers vs non-simple headers

The browser has a small list of headers it considers safe for simple cross-origin requests. Examples include:

Accept
Accept-Language
Content-Language

Content-Type can also be simple, but only with certain values. Those values are:

text/plain
application/x-www-form-urlencoded
multipart/form-data

So this is fine:

Content-Type: text/plain

But this is not considered simple:

Content-Type: application/json

And therefore... 🎺 preflight!

Wait... JSON causes preflight?

Yes. This catches many developers.

You might write:

fetch("https://api.example.com/users", {
    method: "POST",
    headers: {
        "Content-Type": "application/json"
    }
});

You might think: "It's just POST. Why are you sending OPTIONS?"

Because:

POST
+
application/json

is not a simple CORS request. So the browser sends a preflight.

What about custom headers?

Suppose you write:

fetch("https://api.example.com/users", {
    headers: {
        "X-API-Key": "abc123"
    }
});

X-API-Key isn't one of the simple request headers. Therefore, for a cross-origin request, the browser needs to perform a preflight.

Common examples of non-simple headers include:

Authorization
X-API-Key
X-Request-ID
X-Correlation-ID

Interestingly, Authorization is a standard HTTP header, but it is still not a CORS-safelisted request header. So:

Custom header ≠ exactly the same thing as non-simple header.

A custom header is something your application introduces. A non-simple header is one that isn't on the browser's CORS safelist.

The easy rule for preflight

When you're debugging CORS, think:

Cross-origin?

Is it a simple request?

   YES → No preflight

    NO

OPTIONS preflight

Things that commonly make it non-simple:

PUT
PATCH
DELETE
 
Authorization header
X-API-Key
Other non-safelisted headers
 
Content-Type: application/json

The server's response to preflight

The server can respond with headers such as:

Access-Control-Allow-Origin: https://myfrontend.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type

These headers tell the browser:

  • Access-Control-Allow-Origin — which origins are allowed?
  • Access-Control-Allow-Methods — which HTTP methods are allowed?
  • Access-Control-Allow-Headers — which request headers are allowed?

The browser checks these before allowing the actual request.

But do we really need to preflight every time?

Imagine your application makes hundreds of API calls. If the browser sent:

OPTIONS
PUT
OPTIONS
PUT
OPTIONS
PUT
...

every time, that would be a little annoying. So CORS gives us Access-Control-Max-Age:

Access-Control-Max-Age: 86400

That's:

86,400 seconds
= 24 hours

It tells the browser: "You can cache this successful preflight result." So the browser doesn't necessarily need to ask again for matching requests until that cached permission expires.

Is it a global CORS permission?

No. This is a very important mental model.

Don't think: "The browser now trusts this API for 24 hours."

Instead think: "The browser can reuse this preflight result for requests that match the cached permission."

For example, you might have:

PUT + Authorization

and later make:

DELETE + Authorization

That's a different request method. The browser cannot simply assume that the exact same permission applies to everything.

So you should understand preflight caching as permission for matching request characteristics, not a blanket trust relationship with the server.

Now let's talk about credentials

Here's where cookies enter the party.

Suppose you're logged into:

https://bank.com

Your browser has:

session=abc123

Your frontend makes a cross-origin request. By default, cross-origin fetch doesn't send credentials such as cookies. You can explicitly request them:

fetch("https://api.example.com/profile", {
    credentials: "include"
});

Now the browser needs the server to explicitly allow credentialed CORS. The server can respond:

Access-Control-Allow-Credentials: true

Meaning: "Yes, browser, I'm okay with credentials being used for this cross-origin request."

The famous * problem

You might think this:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

would mean "everyone is allowed, including cookies!"

Nope. The browser doesn't allow that combination.

Why? Because this would essentially mean any website on the internet can make credentialed cross-origin requests. That would be a terrible idea.

Instead, the server needs to explicitly identify the origin:

Access-Control-Allow-Origin: https://myfrontend.com
Access-Control-Allow-Credentials: true

Now the browser knows exactly who is allowed.

CORS is NOT authentication

This is another extremely important distinction.

Imagine your API has /api/users and you configure CORS perfectly. That does not mean your API is secure. Someone can still use:

curl
Postman
Python
Node.js

to call your API. CORS isn't authentication.

  • Authentication answers: who are you?
  • Authorization answers: are you allowed to do this?
  • CORS answers: is browser JavaScript from this origin allowed to access this cross-origin resource?

Different problems.

Does CORS actually stop the request?

Here's a subtle part. People often say "CORS blocks the request." That's not always the most accurate way to think about it.

The important guarantee is that the browser prevents unauthorized browser JavaScript from reading the cross-origin response.

For some requests, particularly non-simple requests, a failed preflight means the browser won't proceed with the actual request. But with simple cross-origin requests, the request can reach the server even when the response isn't made available to JavaScript.

That's why you should never think of CORS as a firewall protecting your API. It's not.

The entire CORS story in one example

Let's put everything together.

Frontend:

https://app.company.com

Backend:

https://api.company.com

Frontend sends:

fetch("https://api.company.com/users", {
    method: "POST",
    credentials: "include",
    headers: {
        "Authorization": "Bearer xyz",
        "Content-Type": "application/json"
    },
    body: JSON.stringify({
        name: "Harshit"
    })
});

Browser notices:

Different origin
        +
application/json
        +
Authorization
        +
credentials

Browser: "This isn't a simple request. I'm not sending that yet."

It sends:

OPTIONS /users
 
Origin: https://app.company.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

Server responds:

Access-Control-Allow-Origin: https://app.company.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400

Browser: "Excellent. Permission granted."

Then it sends:

POST /users

with the appropriate credentials. And JavaScript receives the response.

The mental model you should keep

Don't memorize 20 headers. Keep this picture in your head:

                 ┌─────────────────────┐
                 │      JavaScript     │
                 └──────────┬──────────┘


                 ┌─────────────────────┐
                 │       Browser       │
                 │                     │
                 │ "Is this cross-     │
                 │  origin?"           │
                 └──────────┬──────────┘

                       Cross-origin


                    Is it simple?
                      /         \
                    YES          NO
                     │            │
                     │            ▼
                     │       OPTIONS
                     │      (Preflight)
                     │            │
                     │            ▼
                     │          Server
                     │            │
                     │      CORS headers
                     │            │
                     └──────┬─────┘

                     Browser checks

                  ┌─────────┴─────────┐
                  │                   │
               Allowed            Not allowed
                  │                   │
                  ▼                   ▼
             Actual request       Browser blocks
                  │              JS access

               Response

And sitting somewhere in the middle is our browser security guard wearing sunglasses:

🕶️ I don't care who you say you are. Show me the CORS headers.

The 30-second interview answer

If an interviewer says "explain CORS", you can say:

CORS is a browser-enforced mechanism that controls whether JavaScript from one origin can access resources from another origin. It exists because of the browser's Same-Origin Policy. For simple cross-origin requests, the browser can send the request directly, but for non-simple requests — such as PUT, PATCH, DELETE, requests with non-safelisted headers like Authorization, or requests using application/json — the browser first sends an OPTIONS preflight request. The server responds with CORS headers specifying allowed origins, methods, and headers. The browser validates those headers and, if permitted, sends the actual request or exposes the response to JavaScript. Preflight responses can be cached using Access-Control-Max-Age. CORS is a browser security mechanism, not an authentication or API security mechanism, which is why Postman and cURL don't encounter CORS restrictions.

If you can explain that naturally rather than recite it, you've got a very solid CORS foundation.

The three things to never forget

If everything else disappears from your brain during the interview, remember these.

1. CORS is a browser security mechanism.

Browser → CORS
Postman → No CORS

2. Preflight is permission checking.

OPTIONS
 
"Can I make this cross-origin request
with this method and these headers?"

3. CORS ≠ authentication.

CORS           → Browser access control
Authentication → Who are you?
Authorization  → What can you do?

Once those three ideas are firmly in your head, most CORS questions stop feeling like random rules and start feeling like logical consequences of the browser trying very hard not to let evil.com ruin your afternoon.

Delhi skyline rendered as a halftone dot matrix — India Gate, Qutub Minar and the Delhi Metro at dusk