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.
Imagine you've built a beautiful frontend.
Your frontend lives here:
https://my-awesome-app.comYour backend lives here:
https://api.my-awesome-app.comYour 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 + PortFor example:
https://example.com:443The three pieces are:
Protocol → https
Host → example.com
Port → 443If any of these change, you've got a different origin.
So:
https://example.com
https://example.comSame origin. ✅
But:
http://example.comDifferent protocol. ❌
And:
https://api.example.comDifferent host. ❌
And:
https://example.com:8080Different 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.comYour browser has a cookie:
session=ABC123Now you visit a malicious website:
https://evil.comThat 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.comThe 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 → APIThe browser says: "CORS policy! I need to check this."
But:
Postman → APIPostman says: "I'm not a browser. I do what I want."
Same with:
curl
Node.js
Python
Java
GoThey 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 OKwhile:
Browser → API → CORS ERRORAnd 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/123Instead, it might first send:
OPTIONS /users/123
Origin: https://myfrontend.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-typeThe 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-TypeBrowser: "Permission granted."
Then it sends:
PUT /users/123Why 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 /userswhich does nothing. And you could have:
GET /deleteEverythingwhich 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
HEADBut 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-LanguageContent-Type can also be simple, but only with certain values. Those values
are:
text/plain
application/x-www-form-urlencoded
multipart/form-dataSo this is fine:
Content-Type: text/plainBut this is not considered simple:
Content-Type: application/jsonAnd 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/jsonis 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-IDInterestingly, 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 preflightThings that commonly make it non-simple:
PUT
PATCH
DELETE
Authorization header
X-API-Key
Other non-safelisted headers
Content-Type: application/jsonThe 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-TypeThese 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: 86400That's:
86,400 seconds
= 24 hoursIt 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 + Authorizationand later make:
DELETE + AuthorizationThat'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.comYour browser has:
session=abc123Your 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: trueMeaning: "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: truewould 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: trueNow 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.jsto 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.comBackend:
https://api.company.comFrontend 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
+
credentialsBrowser: "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-typeServer 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: 86400Browser: "Excellent. Permission granted."
Then it sends:
POST /userswith 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
▼
ResponseAnd 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 CORS2. 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.
