Prologue: "But It Works on Postman..."
My first frontend project. I tested the backend API with Postman and it worked perfectly. Data came through, 200 OK status, clean JSON response. "Great, now I just need to make the same request from React," I thought.
I made the exact same request using fetch() in React:
fetch("http://localhost:8080/api/users")
.then(res => res.json())
.then(data => console.log(data));
The result? My browser console was painted red with error messages:
Access to fetch at 'http://localhost:8080/api/users'
from origin 'http://localhost:3000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
"Why? It works on Postman!"
I messaged the backend developer on Slack.
"I'm getting a CORS error when calling the API. Did you block something on the backend?"
Backend developer's reply:
"The API is working fine. I tested it with Postman and it works. This isn't a backend issue."
I posted the question in a frontend community:
"CORS error? Backend didn't add the header? Tell your backend developer to add the Access-Control-Allow-Origin header."
I messaged the backend developer again:
"Please add the header."
Backend:
"What header?"
Me:
"Access-Control-Allow-Origin."
Backend:
"What's that?"
I spent 3 hours going in circles, not knowing whose fault it was.
Why I Studied This: The Senior's 5-Minute Fix
Eventually, I asked a senior developer for help. The senior looked at the backend code and added just one line:
// Spring Boot
@CrossOrigin(origins = "http://localhost:3000")
The error vanished.
I stared at the screen blankly, then asked.
"What is this?"
Senior:
"CORS configuration. Browsers block requests from different Origins by default for security reasons. The server needs to explicitly allow specific Origins."
Me:
"Then why does Postman work?"
Senior:
"Postman isn't a browser. Only browsers enforce this rule."
That moment, I understood. CORS wasn't a "backend problem" or a "frontend problem." It was a browser security policy.
What Confused Me Initially
When I first encountered CORS, these things didn't make sense:
- What's Same-Origin? (What does "same origin" even mean?)
- Why does the browser block it but Postman doesn't? (Aren't they both making HTTP requests?)
- What's the OPTIONS method? (I only sent one POST, why are there two requests flying?)
- What does "No 'Access-Control-Allow-Origin' header" mean? (Why does the absence of this header block requests?)
Most importantly: "Why make it so complicated? Can't we just send requests?"
The Aha Moment: "Malicious Sites Can Steal My Cookies?"
The senior gave me an example. After hearing this, I finally understood why CORS exists.
"Imagine you logged into
mybank.com(a banking site). Your browser has saved a login cookie (session=abc123).While logged in, you visit
hacker.com(a site made by a hacker). This site has hidden JavaScript code:fetch('https://mybank.com/api/transfer', { method: 'POST', credentials: 'include', // Include cookies body: JSON.stringify({ to: 'hacker', amount: 10000 }) });When you visit
hacker.com, this code executes. The browser automatically includes themybank.comcookie.Without CORS? The bank server thinks 'Oh, this request has a valid cookie? Processing transfer!' and sends your money to the hacker's account."
"Oh, the browser is protecting me!"
After hearing this example, I accepted it. CORS wasn't an annoying obstacle but a shield protecting me. The browser's process of checking "Hey, this site wants to make a request to that site. Did that site give permission?" was CORS.
1. SOP (Same-Origin Policy): "Trust Same Neighborhood Only"
The foundational principle of the web ecosystem is SOP (Same-Origin Policy). It's the rule that "only same Origins can freely communicate with each other."
What Is Origin?
https://example.com:443/api/users?id=1
└─────┬─────┘ └┬┘ └─┬──┘ └──┬───┘ └┬┘
Scheme Port Host Path Query
Origin = Scheme + Host + Port
https://example.com:443
If any one of these three differs, it's a different Origin.
Understanding Through Examples
| URL | Origin | Same Origin? |
|---|---|---|
https://example.com/api | https://example.com:443 | ✅ Same (port 443 is default) |
https://example.com:8080/api | https://example.com:8080 | ❌ Different port |
http://example.com/api | http://example.com:80 | ❌ Different scheme (http vs https) |
https://api.example.com/ | https://api.example.com:443 | ❌ Different host (subdomain) |
SOP Rules
// Running on https://example.com
fetch('https://example.com/api/users') // ✅ OK (Same Origin)
fetch('https://api.example.com/users') // ❌ Blocked (Different Host)
fetch('http://example.com/users') // ❌ Blocked (Different Scheme)
Requests to different Origins are blocked by default.
When I first accepted this rule, I thought "How can we develop anything if we can't even call APIs in modern times?" That's why CORS was created.
2. CORS (Cross-Origin Resource Sharing): "Show Your Passport and Pass"
But how do we develop without APIs in modern times? The frontend (localhost:3000) and backend (localhost:8080) have different ports from the start. In microservices architecture, even domains are different.
That's why CORS was created. It's like a Visa system. The concept is "You're from a different country (Origin), but if you have a visa, I'll let you through."
How It Works: Simple Request vs Preflight
Let me organize how CORS operates. Not every request requires a visa check (Preflight).
1. Simple Request - "Just Send It"
If the following conditions are met, the actual request is sent immediately without preliminary inspection:
- Method: One of
GET,HEAD,POST - Header:
Content-Typeis one of:text/plainmultipart/form-dataapplication/x-www-form-urlencoded
Note: JSON (application/json) is NOT included here! That's why most API requests go through Preflight.
2. Preflight Request - "Ask First, Send Later"
If the above conditions aren't met (e.g., it's a PUT request, has an Authorization header, or Content-Type: application/json), the browser meticulously sends a Scout (OPTIONS) first.
Understanding Through Actual HTTP Flow
Step 1: Preflight Request (Browser → Server)
OPTIONS /api/users HTTP/1.1
Host: localhost:8080
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type
The browser asks:
- "I'm coming from
localhost:3000," - "Can I send a POST request?"
- "Can I include the Content-Type header?"
Step 2: Preflight Response (Server → Browser)
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 86400
The server answers:
- "Okay! I allow requests from
localhost:3000." - "You can use GET, POST, PUT methods."
- "You can include the Content-Type header."
- "This permit is valid for 24 hours (86400 seconds). You don't need to ask again today."
Step 3: Actual Request (Browser → Server)
POST /api/users HTTP/1.1
Host: localhost:8080
Origin: http://localhost:3000
Content-Type: application/json
{"name": "John"}
The browser says:
- "I got permission! Sending the actual request!"
What If the Server Refuses?
HTTP/1.1 403 Forbidden
(No Access-Control-Allow-Origin header present)
Browser: "Didn't get permission. Won't send the actual request." → CORS Error
When I understood this flow, it clicked: "Oh, the browser checks on behalf of the user first and only sends the request if it's safe."
3. Real-World Solutions: Three Ways to Fix It
Let me organize the solutions I've actually used.
Method 1: Backend CORS Configuration (The Right Way)
The server explicitly declares "I allow this Origin."
Spring Boot
Controller level:
@RestController
@CrossOrigin(origins = "http://localhost:3000")
public class UserController {
@GetMapping("/api/users")
public List<User> getUsers() {
return userService.findAll();
}
}
Global configuration (recommended):
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
NestJS
// main.ts
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: 'http://localhost:3000',
credentials: true,
});
Express.js
const cors = require('cors');
app.use(cors({
origin: 'http://localhost:3000',
credentials: true
}));
Method 2: Proxy Server (Dev Environment Trick)
This method tricks the browser into thinking "Oh, same Origin?"
React (package.json)
{
"proxy": "http://localhost:8080"
}
Now in your code:
// Before (CORS error)
fetch("http://localhost:8080/api/users")
// After (using proxy)
fetch("/api/users") // Automatically proxied to localhost:8080
From the browser's perspective:
- Origin:
http://localhost:3000 - Request URL:
http://localhost:3000/api/users
"Oh? Same Origin!" → Passes
But in reality:
- React Dev Server forwards the request to
localhost:8080 - Server-to-server communication has no CORS check
Next.js
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://localhost:8080/api/:path*',
},
];
},
};
Warning: This only works in development. When deployed, there's no proxy server, so CORS errors will appear again. I didn't know this and struggled for a while when it worked locally but failed after deploying to Vercel.
Method 3: CORS Anywhere (Temporary Testing Only)
// NEVER use in production!
fetch("https://cors-anywhere.herokuapp.com/http://api.example.com/data")
CORS Anywhere adds headers in the middle. But it's security risk and slow. Use only for testing.
4. My Real-World Mistakes (Don't Do This)
Mistake 1: Overusing Access-Control-Allow-Origin: *
Initially, I was lazy and did this:
@CrossOrigin(origins = "*") // ❌ Allow all Origins
The senior pointed it out in code review:
"You can't deploy this to production. You're opening the door to hackers. Even
hacker.comcan call our API."
This was it: * means "anyone can come." It might be convenient in development, but absolutely forbidden in production.
Fix:
@CrossOrigin(origins = {
"https://my-app.vercel.app", // Production
"http://localhost:3000" // Development
})
Separate with environment variables:
@CrossOrigin(origins = "${app.cors.allowed-origins}")
# application.properties
app.cors.allowed-origins=https://my-app.vercel.app,http://localhost:3000
Mistake 2: Credentials Issue
I sent a request including cookies:
fetch("http://localhost:8080/api/users", {
credentials: "include" // Include cookies
})
Backend:
@CrossOrigin(origins = "*") // ❌ Error
Error:
The value of 'Access-Control-Allow-Origin' must not be '*'
when the request's credentials mode is 'include'
It clicked: When sending cookies, you can't use *. You must explicitly specify the Origin.
Fix:
@CrossOrigin(
origins = "http://localhost:3000", // Explicit Origin
allowCredentials = "true"
)
Mistake 3: Preflight Not Cached
Every request was sending OPTIONS, making things slow. I opened the Network tab and saw:
OPTIONS /api/users(200ms)POST /api/users(200ms)
Total 400ms
"Why does one request take twice as long?"
Fix:
registry.addMapping("/api/**")
.maxAge(3600); // Cache for 1 hour
Now the browser caches Preflight for 1 hour. Only the first request sends OPTIONS; subsequent requests go straight to the actual request.
5. Troubleshooting Guide
"I definitely configured it, why doesn't it work?"
Let me organize the three most common causes I've encountered.
1. Browser Cache
A previously failed Preflight response (403) might be cached in the browser.
Fix: Developer Tools → Network → Check Disable cache → Refresh
2. Server Error (500)
If a 500 or 401 error occurs before the CORS middleware (e.g., in an authentication filter), the CORS headers won't be attached and only the error page comes down.
The browser sees "No CORS header?" and shows a CORS error.
The real cause must be found in server logs.
3. CloudFront/Nginx
The backend might allow it, but the CDN or reverse proxy in front might block the OPTIONS method or strip the headers.
You need to allow the OPTIONS method and CORS headers in CloudFront settings.
6. Myth vs Reality: Is CORS a Security Technology?
Half true, half false.
CORS is a technology that protects browser users, not the server (API).
- Hackers don't use browsers; they send requests with
curlor Python scripts. These don't perform CORS checks at all. - Therefore, thinking "We enabled CORS, so our API is safe" is a big mistake.
- API security needs to be handled separately with authentication (JWT/OAuth) and firewalls.
CORS only serves the role of "preventing malicious sites in the browser from stealing user cookies."
7. Summary: CORS Checklist
Development Environment
- Backend: Allow
localhost:3000 - Frontend: Proxy configuration (optional)
Production Environment
- Backend: Allow only actual domain (e.g.,
https://my-app.vercel.app) - Prohibit using
Access-Control-Allow-Origin: * - When using
credentials: true, explicit Origin is required - Configure Preflight caching (
maxAge)
Final Thoughts: "The Grateful Shield"
CORS errors are annoying. When I first saw them, I thought "Why did they make it so inconvenient?"
But now I've accepted it. CORS is a grateful shield that protects users.
If CORS didn't exist?
- Hacker sites could drain my bank account.
- Malicious ads could steal my personal information.
- Sites I visit could use my cookies to make requests to other sites.
If you develop with "security disable plugins," you'll taste hell when you deploy.
When you encounter a CORS error:
- Configure allowance on the backend (the right way)
- Use proxy in development environment (the trick)
- Never overuse
*
This was it: CORS is not an enemy but a friend.