Same-Origin Policy and CORS
The Wall You Did Not Know Existed
You have probably seen this error in your console:
Access to fetch at 'https://api.example.com/data' from origin
'https://myapp.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
And you probably Googled the error, added Access-Control-Allow-Origin: * to your server, and moved on. That works. But you just poked a hole in one of the most important security boundaries on the web without understanding what it protects or what you exposed.
Let's fix that.
Think of the browser as an apartment building with strict security. Each apartment (origin) has its own locked door. Tenants inside one apartment can do whatever they want with their own stuff. But if a tenant in apartment A wants to borrow something from apartment B, the security guard (browser) checks whether apartment B left a note on their door saying "apartment A is allowed in." Without that note, the guard blocks entry. The note is CORS. The locked-door policy is the Same-Origin Policy.
What Is an "Origin"?
An origin is defined by three parts: scheme (protocol), host (domain), and port. All three must match exactly for two URLs to be considered same-origin.
https://example.com:443/path/page
|_____| |__________|___|
scheme host port
| URL A | URL B | Same origin? | Why |
|---|---|---|---|
https://example.com | https://example.com/about | Yes | Path does not matter |
https://example.com | http://example.com | No | Different scheme |
https://example.com | https://api.example.com | No | Different host (subdomain counts) |
https://example.com | https://example.com:8080 | No | Different port |
https://example.com:443 | https://example.com | Yes | 443 is the default HTTPS port |
The port comparison is the one that trips people up most. https://example.com and https://example.com:443 are the same origin because 443 is the default port for HTTPS. But http://example.com (port 80) and http://example.com:3000 are different origins.
The Same-Origin Policy
The Same-Origin Policy (SOP) is a browser security mechanism that restricts how a document or script from one origin can interact with resources from another origin. It has been part of browsers since Netscape 2.0 in 1995.
What SOP Blocks
- Reading responses from cross-origin
fetch/XMLHttpRequestcalls - Accessing the DOM of a cross-origin
iframe - Reading cross-origin
canvasdata after drawing external images - Reading cross-origin
localStorageorsessionStorage
What SOP Allows
This is the part that surprises people. SOP does not block everything cross-origin:
- Embedding cross-origin resources: images via
img, scripts viascript, stylesheets vialink, media viavideo/audio, iframes viaiframe - Sending cross-origin form submissions (the browser sends the request, you just cannot read the response)
- Sending cross-origin requests via
fetchorXMLHttpRequest(the request leaves the browser, but the response is blocked unless CORS headers allow it)
That last point is critical. The request still reaches the server. SOP blocks the response from being read by your JavaScript, not the request from being sent. This is a common misconception.
SOP does not prevent the request from being sent to the server. It prevents the browser from exposing the response to your JavaScript. If a cross-origin POST request has side effects on the server (like deleting data), that side effect still happens even though the browser blocks your code from reading the response. This is why CSRF protection exists as a separate concern.
Enter CORS
CORS (Cross-Origin Resource Sharing) is the mechanism that lets servers opt into allowing cross-origin access. It is not a security feature you add to your frontend. It is a set of HTTP headers the server sends to tell the browser: "I am okay with this origin reading my responses."
Simple Requests
Not every cross-origin request triggers CORS preflight. A request is "simple" if it meets all of these conditions:
- Method is
GET,HEAD, orPOST - Headers are only the CORS-safelisted ones:
Accept,Accept-Language,Content-Language,Content-Type(with value limited toapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain) - No
ReadableStreambody - No event listeners on
XMLHttpRequest.upload
For a simple request, the browser sends it directly and checks the response for the Access-Control-Allow-Origin header:
GET /api/data HTTP/1.1
Host: api.example.com
Origin: https://myapp.com
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.com
Content-Type: application/json
{"data": "here"}
If the Access-Control-Allow-Origin header matches the requesting origin (or is *), the browser lets your JavaScript read the response. If not, the response is blocked.
Preflighted Requests
If a request is not "simple" (uses PUT, DELETE, PATCH, sends Content-Type: application/json, includes custom headers like Authorization), the browser sends a preflight request first.
The preflight is an OPTIONS request that asks the server: "Would you accept the real request I am about to send?"
Here is what the preflight exchange looks like on the wire:
OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://myapp.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, PUT, POST, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
The Access-Control-Max-Age header tells the browser to cache this preflight result for 86400 seconds (24 hours). Without it, the browser sends a preflight before every single non-simple request to the same endpoint, which adds latency.
CORS Headers Deep Dive
Response Headers (Server → Browser)
| Header | Purpose | Example |
|---|---|---|
Access-Control-Allow-Origin | Which origin can read the response | https://myapp.com or * |
Access-Control-Allow-Methods | Which HTTP methods are allowed | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers | Which request headers are allowed | Content-Type, Authorization |
Access-Control-Expose-Headers | Which response headers JS can read | X-Request-Id, X-RateLimit-Remaining |
Access-Control-Max-Age | How long to cache preflight (seconds) | 86400 |
Access-Control-Allow-Credentials | Allow cookies/auth headers | true |
The Credentials Trap
By default, cross-origin fetch does not send cookies. You need both sides to opt in:
// Client: include credentials
fetch("https://api.example.com/data", {
credentials: "include"
});
// Server: allow credentials
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Credentials: true
Here is the rule that catches everyone: when Access-Control-Allow-Credentials: true is set, Access-Control-Allow-Origin cannot be *. You must specify the exact origin. This is a deliberate security constraint. Allowing credentials from any origin would let any website make authenticated requests on behalf of your users.
Why wildcard plus credentials is banned
Imagine you are logged into your bank at https://bank.com. If bank.com responded with Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true, then any website you visit could use fetch with credentials: "include" to make requests to bank.com using your session cookies. The evil site could read your balance, initiate transfers, anything. By forcing the server to name a specific origin when credentials are involved, the browser ensures the server is making a conscious decision about which site to trust.
Exposing Custom Response Headers
By default, JavaScript can only read a limited set of response headers from cross-origin responses (the "CORS-safelisted response headers"): Cache-Control, Content-Language, Content-Length, Content-Type, Expires, and Pragma.
If your API returns custom headers like X-Request-Id or X-RateLimit-Remaining and your frontend needs to read them, the server must explicitly expose them:
Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining
Without this, response.headers.get("X-Request-Id") returns null even though the header exists in the response.
Common CORS Errors and How to Fix Them
Error 1: No Access-Control-Allow-Origin header
Access to fetch at '...' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the
requested resource.
Cause: The server is not sending CORS headers at all.
Fix: Add the header on the server. In Express:
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "https://myapp.com");
next();
});
Error 2: Preflight response is not OK
Response to preflight request doesn't pass access control check:
It does not have HTTP ok status.
Cause: The server is not handling OPTIONS requests, or is returning a non-2xx status for them.
Fix: Handle OPTIONS explicitly:
app.options("/api/*", (req, res) => {
res.header("Access-Control-Allow-Origin", "https://myapp.com");
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.sendStatus(204);
});
Error 3: Wildcard with credentials
The value of the 'Access-Control-Allow-Origin' header in the response
must not be the wildcard '*' when the request's credentials mode is 'include'.
Cause: Server sends Access-Control-Allow-Origin: * but the request uses credentials: "include".
Fix: Replace the wildcard with the specific origin. If you need to support multiple origins, read the Origin request header and echo it back after validating it against an allowlist:
const allowedOrigins = [
"https://myapp.com",
"https://staging.myapp.com"
];
app.use((req, res, next) => {
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.header("Access-Control-Allow-Origin", origin);
res.header("Access-Control-Allow-Credentials", "true");
res.header("Vary", "Origin");
}
next();
});
Notice the Vary: Origin header. This tells caches (CDNs, proxies) that the response varies based on the Origin request header. Without it, a cached response for one origin could be served to another.
Error 4: Header not allowed
Request header field authorization is not allowed by
Access-Control-Allow-Headers in preflight response.
Cause: The preflight response does not include the custom header in Access-Control-Allow-Headers.
Fix: Add the missing header name to Access-Control-Allow-Headers.
Proxy Patterns for Development
During development, your frontend at http://localhost:3000 often needs to talk to an API at http://localhost:8080 or a remote server. Instead of configuring CORS on the API (which you might not control), you can proxy requests through your dev server.
Next.js Rewrites
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: "/api/:path*",
destination: "https://api.example.com/:path*"
}
];
}
};
Your frontend calls /api/data and the dev server proxies it to https://api.example.com/data. Since the browser sees the request going to the same origin, no CORS is involved.
Vite Proxy
// vite.config.js
export default {
server: {
proxy: {
"/api": {
target: "https://api.example.com",
changeOrigin: true
}
}
}
};
Why Proxies Work
The proxy works because it moves the cross-origin request from the browser to the server. Your browser talks to localhost:3000, which is same-origin. The dev server then makes the request to the API server. Server-to-server requests are not subject to SOP because SOP is a browser security policy. Servers do not have it.
Dev proxies solve CORS in development only. In production, you need a real solution: either configure CORS on your API server, or deploy a reverse proxy (nginx, Cloudflare Workers, API Gateway) that sits in front of your API on the same origin as your frontend.
The Danger of Access-Control-Allow-Origin: *
Setting Access-Control-Allow-Origin: * is safe in specific situations and dangerous in others.
When the Wildcard Is Safe
- Public, read-only APIs that serve non-sensitive data (weather data, open datasets, public CDN resources)
- Static assets (fonts, images, public JS/CSS files)
- Responses that do not vary by user and contain no sensitive information
When the Wildcard Is Dangerous
The wildcard becomes a security problem when:
- The response contains user-specific data (profile info, account details, private content)
- The endpoint performs actions (creating, updating, deleting resources)
- The endpoint relies on ambient authority (cookies, session tokens) for authentication
Even though * prevents credentials: "include", some authentication mechanisms use URL parameters or custom headers that are not classified as "credentials." An API that authenticates via API keys in query strings and returns Access-Control-Allow-Origin: * is wide open to any website stealing data.
- 1An origin is scheme + host + port. All three must match for same-origin.
- 2SOP blocks reading cross-origin responses, not sending requests. The request still reaches the server.
- 3CORS is a server-side opt-in. The server sends headers telling the browser what to allow.
- 4Preflight (OPTIONS) fires for non-simple requests. Cache it with Access-Control-Max-Age to avoid latency.
- 5Wildcard (*) with credentials is banned. Always specify the exact origin when using Access-Control-Allow-Credentials.
- 6Always set Vary: Origin when dynamically setting Access-Control-Allow-Origin based on the request.
- 7Dev proxies bypass CORS by moving the request server-side. Use rewrites in Next.js or proxy in Vite.
| What developers do | What they should do |
|---|---|
| Adding CORS headers on the frontend (meta tags, fetch headers) The browser controls SOP enforcement. There is no client-side header or meta tag that bypasses it. Any solution must be server-side. | CORS headers must come from the server in the response. The browser enforces them, but the server sets them. |
| Using Access-Control-Allow-Origin: * with credentials: include The browser explicitly rejects wildcard origins when credentials are involved to prevent any-site-can-act-as-you attacks. | Specify the exact origin and add Vary: Origin when supporting credentials |
| Thinking SOP blocks the request from being sent This misconception leads developers to skip CSRF protection, thinking SOP is enough. | SOP blocks reading the response, not sending the request. The server still receives and processes the request. |
| Forgetting Access-Control-Max-Age on preflight responses Without caching, every non-simple request sends two HTTP requests (preflight + actual), doubling latency. | Set Access-Control-Max-Age to cache preflight results (e.g., 86400 for 24 hours) |
| Echoing the Origin header without validation Blindly reflecting the Origin header is functionally identical to wildcard * but also works with credentials, making it strictly worse. | Validate the Origin against an allowlist before echoing it back |
CORS and Caching: The Vary Header Gotcha
When your server dynamically sets Access-Control-Allow-Origin based on the request's Origin header, you must also send Vary: Origin. Without it, here is what can go wrong:
- User visits from
https://app-a.com. CDN caches the response withAccess-Control-Allow-Origin: https://app-a.com - User visits from
https://app-b.com. CDN serves the cached response withAccess-Control-Allow-Origin: https://app-a.com - Browser blocks the response because the origin does not match
The Vary: Origin header tells intermediate caches to store separate cached copies for each unique Origin value.
What CORS Does Not Protect Against
CORS is not a general-purpose security solution. It specifically controls which origins can read cross-origin responses in a browser.
It does not protect against:
- Server-to-server requests (curl, Postman, backend services). SOP is browser-only
- CSRF attacks where the attacker does not need to read the response (form submissions, image tags that trigger GET requests with side effects)
- Requests from browser extensions with appropriate permissions
- Requests from non-browser environments (mobile apps, desktop apps, scripts)
CORS is one layer in a defense-in-depth strategy. You still need CSRF tokens, proper authentication, input validation, and Content Security Policy.
Q: A candidate says "CORS prevents unauthorized servers from accessing our API." What is wrong with this statement?
CORS does not protect the server at all. It is a browser-enforced policy that protects the user. Without CORS, a malicious website could use the user's browser to make authenticated requests to your API and read the responses (because the browser automatically attaches cookies). CORS prevents the malicious site from reading those responses. But the server still receives every request. CORS is about controlling which browser-based origins can read responses, not about protecting the server from unauthorized access. Server-side authentication and authorization handle that.