CORS Protects the Browser User, Not the API Server: The Reflected-Origin Hole Is Worse Than `*`
CORS 到底在保护谁?90% 的前端理解错了跨域的本质
Every time a new person joins, takes over a project, and starts wiring up APIs, the console pops that red error:
Their first move? They message the backend: 'Bro, the API is crossed-origin, please add a CORS header.'
The backend replies: 'OK.'
Five minutes later the API works. Neither person thinks any deeper about what it means.
In most frontend developers' minds, CORS is a roadblock, a security restriction that makes development annoying, something the backend can configure away and then forget.
This understanding is wrong, and dangerously wrong.
CORS does not protect the server. The server doesn't need CORS to protect itself; anyone can call your API with curl, Postman, or backend code, and CORS cannot stop that.
CORS protects the user in the browser. It prevents a malicious web page you opened from secretly using your identity to call another site's API.
If you don't get this distinction, you will never understand why 'just add Access-Control-Allow-Origin: * on the backend' can be dangerous in some scenarios.
What Would Happen Without CORS?
Suppose the browser has no same-origin policy and no CORS mechanism. You open a page that looks normal: baidu.com.
What you don't know is that baidu.com's page hides some JavaScript:
// Malicious code on the baidu.com page
// You just logged into bank.com, and the browser stores bank.com's login Cookie
fetch('https://bank.com/api/transfer', {
method: 'POST',
credentials: 'include', // include bank.com's Cookie
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: 'attacker-account',
amount: 50000,
}),
});
If there were no same-origin policy, this code would send a real request to the bank's transfer API with your bank.com login Cookie. The bank server receives a request with a valid Cookie, believes it is you operating, and the money is gone.
You did nothing except open a web page.
That is what the browser's same-origin policy and CORS are designed to prevent. Its security model is simple:
From A's page, you cannot casually use the user's identity to call B's API. Unless B explicitly says A is allowed.
It restricts behavior in the browser, not on the server. Calling the API directly with curl is unaffected, because curl is not a browser and doesn't carry the risk of the user's identity being stolen.
Why Does Adding * on the Backend Work?
Access-Control-Allow-Origin: * means: any website can call this API.
For public APIs that require no user identity, such as weather APIs, exchange-rate lookups, or public CDN resources, * is totally fine. Everyone calls these the same way; no user privacy is involved.
But for APIs that require user identity, such as login, payment, personal information, or admin management, * creates a huge security hole.
The browser has a hard rule: Access-Control-Allow-Origin: * cannot be used together with credentials: include. If the API returns *, the browser refuses to attach the Cookie to the request.
This is the browser's safety net.
But many backends, for convenience, don't use *; instead they reflect the request header's Origin value back exactly:
# Dangerous backend pattern: reflects the request Origin as-is
@app.after_request
def add_cors(response):
origin = request.headers.get('Origin')
if origin:
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
return response
The effect of this code: any website's request is allowed to access the API carrying the user's Cookie. It bypasses the browser's restriction on * and opens as wide a hole as * would, actually worse, because it allows credentials.
Under this configuration, the bank-transfer attack above becomes fully feasible.
CORS Is the Browser Making Security Decisions for You
Once you understand who it protects, the CORS mechanism becomes clear.
The whole flow splits into two cases:
Simple Requests: Browser Sends First, Asks Later
If your request meets these conditions (method is GET/HEAD/POST, no custom Header, and Content-Type is only form-urlencoded, multipart/form-data, or text/plain), the browser sends the request directly, but checks whether the response header contains Access-Control-Allow-Origin.
If it doesn't, or the value doesn't match the current page's origin, the browser blocks the response so your JavaScript cannot read it.
Note: the request was already sent; the server has already received and processed it. The browser only prevents the frontend code from receiving the response. This is why CORS does not protect the server, the request already reached the server and was acted on.
Preflight Request: Browser Asks First, Then Sends
If your request does not meet the simple-request conditions, for example it uses PUT/DELETE, adds an Authorization header, or uses application/json as Content-Type, the browser first sends an OPTIONS request asking the server: am I allowed to do this?
OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://my-app.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, Content-Type
The server answers:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://my-app.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Only when the server explicitly allows it does the browser send the real request. Otherwise it blocks it directly.
Access-Control-Max-Age: 86400 is an easy-to-miss but important field; it tells the browser to cache this preflight result for 24 hours. Without this value, every non-simple request sends an OPTIONS first, doubling request volume.
What Does a Correct Production Configuration Look Like?
With the principle explained, here is a production-ready CORS configuration you can use directly, using Cloudflare Workers as an example:
// CORS middleware: production allowlist config
const ALLOWED_ORIGINS = new Set([
'https://my-app.com',
'https://admin.my-app.com',
'https://staging.my-app.com',
]);
// Handle local development separately
function isAllowedOrigin(origin: string | null): boolean {
if (!origin) return false;
if (ALLOWED_ORIGINS.has(origin)) return true;
// Allow localhost in development
if (origin.startsWith('http://localhost:')) return true;
return false;
}
function handleCORS(request: Request, response: Response): Response {
const origin = request.headers.get('Origin');
// Preflight request
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': isAllowedOrigin(origin) ? origin! : '',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400', // Cache preflight results for 24 hours
},
});
}
// Normal requests: only set CORS headers for allowlisted origins
const headers = new Headers(response.headers);
if (isAllowedOrigin(origin)) {
headers.set('Access-Control-Allow-Origin', origin!);
headers.set('Access-Control-Allow-Credentials', 'true');
}
// Response varies by Origin header, so it must be cached separately
headers.append('Vary', 'Origin');
return new Response(response.body, {
status: response.status,
headers,
});
}
A few easy mistakes to make:
Which Attacks Can't CORS Block?
Once you understand CORS's protection mechanism, you also need to know its boundary: CORS is not a universal security solution; it defends cross-origin reading, not cross-origin writing.
Form Submissions Are Not Restricted by CORS
CORS only restricts XMLHttpRequest and fetch. Native form submissions are completely free from the same-origin policy:
<!-- This code lives on baidu.com and auto-submits when the user opens the page -->
<form action="https://bank.com/api/transfer" method="POST" id="attack-form">
<input type="hidden" name="to" value="attacker-account" />
<input type="hidden" name="amount" value="50000" />
</form>
<script>document.getElementById('attack-form').submit();</script>
This is the classic CSRF (cross-site request forgery) attack. The browser submits the form with bank.com's Cookie, and the server cannot distinguish whether it was actively initiated by the user or triggered by a malicious page.
CORS cannot defend against CSRF. Defending against CSRF needs an additional mechanism:
// Example server-side CSRF defense
function validateRequest(request: Request): boolean {
const origin = request.headers.get('Origin');
const referer = request.headers.get('Referer');
// For state-changing requests (POST/PUT/DELETE), the source must be checked
if (['POST', 'PUT', 'DELETE'].includes(request.method)) {
const source = origin || (referer ? new URL(referer).origin : null);
if (!source || !ALLOWED_ORIGINS.has(source)) {
return false; // Reject requests with unknown source
}
}
return true;
}
img, script, and link Are Not Restricted by CORS Either
When the browser loads images, scripts, and stylesheets, it does so cross-origin by default and without same-origin restrictions. That is why you can reference jQuery from someone else's CDN in your own page.
But it also means: a malicious page can trigger your GET API by loading an image tag such as <img> requesting https://your-api.com/api/user?callback=steal, carrying the user's Cookie. If your GET API has side effects (like a m-read marker, or data encoded into a redirect URL), it can be exploited.
Therefore GET endpoints should never perform state-changing operations. That is not a convention; it is a security red line.
One-Sentence Summary
CORS is not protecting your server; anyone with curl or backend code can ignore it.
CORS protects the user sitting in front of the browser. It prevents a malicious web page from silently using the user's identity to operate on other websites.
Next time a backend colleague says **'I'll just add *'**, you should ask: 'Does this API need a logged-in session? If it does, * won't work; it must be an allowlist.'
That sentence isn't technically deep, but the security awareness behind it is worth remembering for every frontend engineer🫡
OK, that's today's share. If you like it, please like and bookmark it😃
If you like my articles, welcome to follow my WeChat public account: 【前端技术官】.
Main topics: Frontend Architecture · AI Programming · Workplace Cognition · Developer Growth
Scan with WeChat to follow 👆
Irregular updates, no spam, only genuinely useful stuff.