
CORS: The Arch-Enemy of Frontend Devs
Panic over Red Error message? Browser isn't bullying you; it's protecting you.

Panic over Red Error message? Browser isn't bullying you; it's protecting you.
Why does my server crash? OS's desperate struggle to manage limited memory. War against Fragmentation.

A deep dive into Robert C. Martin's Clean Architecture. Learn how to decouple your business logic from frameworks, databases, and UI using Entities, Use Cases, and the Dependency Rule. Includes Screaming Architecture and Testing strategies.

Two ways to escape a maze. Spread out wide (BFS) or dig deep (DFS)? Who finds the shortest path?

A comprehensive deep dive into client-side storage. From Cookies to IndexedDB and the Cache API. We explore security best practices for JWT storage (XSS vs CSRF), performance implications of synchronous APIs, and how to build offline-first applications using Service Workers.

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:
I spent 3 hours going in circles, not knowing whose fault it was."What's that?"
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.
When I first encountered CORS, these things didn't make sense:
Most importantly: "Why make it so complicated? Can't we just send requests?"
The senior gave me an example. After hearing this, I finally understood why CORS exists.
"Oh, the browser is protecting me!""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."
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.
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."
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.
| 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) |
// 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.
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."
Let me organize how CORS operates. Not every request requires a visa check (Preflight).
If the following conditions are met, the actual request is sent immediately without preliminary inspection:
GET, HEAD, POSTContent-Type is one of:
text/plainmultipart/form-dataapplication/x-www-form-urlencodedNote: JSON (application/json) is NOT included here! That's why most API requests go through Preflight.
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.
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:
localhost:3000,"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:
localhost:3000."POST /api/users HTTP/1.1
Host: localhost:8080
Origin: http://localhost:3000
Content-Type: application/json
{"name": "John"}
The browser says:
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."
Let me organize the solutions I've actually used.
The server explicitly declares "I allow this Origin."
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);
}
}
// main.ts
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: 'http://localhost:3000',
credentials: true,
});
const cors = require('cors');
app.use(cors({
origin: 'http://localhost:3000',
credentials: true
}));
This method tricks the browser into thinking "Oh, same Origin?"
{
"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:
http://localhost:3000http://localhost:3000/api/users"Oh? Same Origin!" → Passes
But in reality:localhost:8080// 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.
// 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.
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.
@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
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.
@CrossOrigin(
origins = "http://localhost:3000", // Explicit Origin
allowCredentials = "true"
)
Every request was sending OPTIONS, making things slow. I opened the Network tab and saw:
OPTIONS /api/users (200ms)POST /api/users (200ms)"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.
"I definitely configured it, why doesn't it work?"
Let me organize the three most common causes I've encountered.
A previously failed Preflight response (403) might be cached in the browser.
Fix: Developer Tools → Network → Check Disable cache → Refresh
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.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.
Half true, half false.
CORS is a technology that protects browser users, not the server (API).
curl or Python scripts. These don't perform CORS checks at all.CORS only serves the role of "preventing malicious sites in the browser from stealing user cookies."
localhost:3000https://my-app.vercel.app)Access-Control-Allow-Origin: *credentials: true, explicit Origin is requiredmaxAge)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?
If you develop with "security disable plugins," you'll taste hell when you deploy.
When you encounter a CORS error:
*This was it: CORS is not an enemy but a friend.