Modern web applications are rarely confined to a single domain. Instead, they often operate across multiple subdomains, each responsible for a specific part of the system.
For example, your frontend could be hosted at https://app.example.com
, your API might live at https://api.example.com
, and files or media could be served from https://fs.example.com
. You might even have a dedicated authentication service at https://auth.example.com
to handle user logins, token issuance, and identity management.
That architecture is powerful, until you hit the dreaded error:
Access to fetch at 'https://api.example.com' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
If you’ve seen that, welcome — you’ve just met CORS (Cross-Origin Resource Sharing), the security mechanism browsers use to safely break the rules of the Same-Origin Policy (SOP).
This article walks you through what SOP is, what CORS really does, and how to configure your backend (with examples) to handle cross-origin requests properly.
Table of Contents
- The Same-Origin Policy
- What Is an Origin?
- What Is a Cross-Origin Request?
- What Is CORS (Cross-Origin Resource Sharing)?
- Simple CORS Requests
- Preflight Requests
- Key CORS Headers Explained
- CORS with Cookies and Credentials
- Practical Example: CORS in Rust (Axum)
- API Gateway and Backend: Where Should You Configure CORS?
- Loading Resources Cross‑Domain with the
<script>
Tag - A Short History of CORS
- Summary
- References
1. The Same Origin Policy
Before diving into CORS, you must understand its foundation, the Same-Origin Policy.
This rule is the browser’s built‑in firewall. It prevents malicious websites from freely interacting with another site you’re logged into.
In simple terms:
“A script loaded from one origin cannot freely read or modify data from another origin.”
For example, JavaScript running on https://app.example.com
cannot read the data or cookies from https://api.example.com
unless the server explicitly allows it.
Without SOP, any random site could load your webmail or banking page in the background and read sensitive data — a complete privacy disaster.
2. What Is an Origin?
An origin is defined as the combination of:
scheme (protocol) + host (domain) + port
Two pages share the same origin only if all three are identical.
URL | Same as https://app.example.com/a.html ? |
Why |
---|---|---|
https://app.example.com/b.html |
✅ Yes | Same scheme, domain, and port |
http://app.example.com/c.html |
❌ No | Different scheme |
https://sub.app.example.com/d.html |
❌ No | Different domain |
https://app.example.com:8080/e.html |
❌ No | Different port |
3. What Is a Cross Origin Request?
Whenever your JavaScript tries to fetch a resource from a different origin (different scheme, host, or port), it becomes a cross-origin HTTP request.
Example:
fetch("https://api.example.com/data");
If your page is served from https://app.example.com
, this is a cross-origin call.
When the server does not explicitly allow this, the browser blocks the response and logs:
Access to fetch at 'https://api.example.com/data'
from origin 'https://app.example.com' has been blocked by CORS policy
The browser enforces this for security — not because it can’t send the request, but because it refuses to reveal the response unless the server approves it.
4. What Is CORS (Cross Origin Resource Sharing)?
CORS is the official web standard that defines how a server can state:
“These specific sites are allowed to access my resources.”
CORS works through additional HTTP headers sent by the server to “grant permission” to specific origins.
Example server response:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type
When a browser sees this response, it verifies if the Origin
header of the request matches the allowed origin(s).
If they match ✅ — the JavaScript call succeeds.
If not ❌ — the response is hidden from JavaScript.
5. Simple CORS Requests
Some requests are so common and safe that browsers send them immediately — these are simple requests.
To qualify as a simple request, all of the following conditions must hold:
- The HTTP method is GET, HEAD, or POST.
- The only manually set headers are “CORS-safelisted” (
Accept
,Accept-Language
,Content-Language
,Content-Type
with restricted MIME types). - The allowed
Content-Type
values are:application/x-www-form-urlencoded
multipart/form-data
text/plain
Example
fetch("https://api.example.com/data");
The browser automatically adds:
Origin: https://app.example.com
If the API responds with:
Access-Control-Allow-Origin: https://app.example.com
the response is shared successfully with JavaScript.
If the header is missing or mismatched, the Fetch API rejects the promise with a CORS error, even though the network request technically went through.
6. Preflight Requests
When a request might affect server data or uses custom headers, non‑standard methods (DELETE
, PUT
, PATCH
, etc.), or a Content-Type
like application/json
, browsers perform an extra validation step called a Preflight Request.
The following diagram illustrates the sequence of a CORS preflight request between the browser at https://app.example.com
and the API server at https://api.example.com
. It shows how the browser first sends an OPTIONS
request to verify permissions before issuing the actual POST
request.
Step 1 — The OPTIONS Preflight & The Server’s Response
Before sending the actual cross‑origin request, the browser checks with the target domain using an OPTIONS
request:
OPTIONS /data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-CUSTOM-HEADER, Content-Type
If https://api.example.com
approves the request, it responds with:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: X-CUSTOM-HEADER, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
This response indicates:
- The origin
https://app.example.com
is allowed to access the resource. - The
POST
method and specified headers are permitted. - Credentials are allowed to be sent (e.g., cookies or tokens).
- The browser may cache this permission for 24 hours.
Step 2 — The Actual Request
Once validated, the browser proceeds with the intended request:
POST /data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Content-Type: application/json
X-CUSTOM-HEADER: 123
If the response includes a valid Access-Control-Allow-Origin
header that matches the origin, the browser allows the web application at https://app.example.com
to read the data returned from https://api.example.com
.
7. Key CORS Headers Explained
Header | Purpose |
---|---|
Access-Control-Allow-Origin | Specifies which origin(s) are allowed. Use * for any, or a specific domain like https://app.example.com . |
Access-Control-Allow-Methods | Declares which HTTP methods can be used. |
Access-Control-Allow-Headers | Lists the allowed non-simple headers the client can send. |
Access-Control-Max-Age | How long browsers can cache the preflight result (in seconds). |
Access-Control-Expose-Headers | Allows JavaScript to read additional response headers beyond the default six (Cache-Control , Content-Language , Content-Type , Expires , Last-Modified , Pragma ). |
Access-Control-Allow-Credentials | Indicates whether the server allows credentials (cookies, Authorization headers) for cross-origin requests. |
Example:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-My-Custom-Header
8. CORS with Cookies and Credentials
By default, cross-origin requests do not include cookies or credentials due to security risks.
To enable them:
Client‑side
For Fetch API:
fetch("https://api.example.com/data", { credentials: "include" });
For XMLHttpRequest:
const xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.open("POST", "https://api.example.com/data");
xhr.send();
Server‑side
The response must include:
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://app.example.com
⚠️ Important:
WhenAccess-Control-Allow-Credentials: true
is used,
Access-Control-Allow-Origin
cannot be*
— it must name the specific origin.
Otherwise, the browser will reject the response, showing:
The value of 'Access-Control-Allow-Origin' must not be '*' when the request’s credentials mode is 'include'.
This combination ensures sensitive authenticated requests are only allowed from known, trusted sites.
9. Practical Example: CORS in Rust (Axum)
Here’s how to configure multi-subdomain CORS support in your backend using the Rust web framework Axum.
Cargo.toml
[dependencies]
axum = "0.8.6"
serde_json = "1.0.145"
tokio = { version = "1.48.0", features = ["full"] }
tower-http = { version = "0.6.6", features = ["cors"] }
Main Application
use axum::{
Json, Router, http,
http::{HeaderValue, Method, StatusCode},
response::IntoResponse,
routing::get,
};
use serde_json::json;
use std::net::SocketAddr;
use tower_http::cors::CorsLayer;
#[tokio::main]
async fn main() {
let allowed_origins = vec![
"https://app.example.com",
"https://api.example.com",
"https://fs.example.com",
];
let cors = CorsLayer::new()
.allow_origin(
allowed_origins
.into_iter()
.filter_map(|o| o.parse::<HeaderValue>().ok())
.collect::<Vec<_>>(),
)
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
.allow_headers([http::header::CONTENT_TYPE, http::header::AUTHORIZATION])
.allow_credentials(true);
let app = Router::new()
.route("/api/data", get(get_data).post(post_data))
.layer(cors);
let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
println!("Server running at http://{addr}");
axum::serve(tokio::net::TcpListener::bind(addr).await.unwrap(), app)
.await
.unwrap();
}
async fn get_data() -> impl IntoResponse {
(StatusCode::OK, Json(json!({ "message": "GET OK" })))
}
async fn post_data() -> impl IntoResponse {
(StatusCode::OK, Json(json!({ "message": "POST OK" })))
}
Explanation
- Allows specific subdomains (
app
,api
,fs
) to share resources. - Enables all major methods and headers.
- Credentials (cookies/tokens) are explicitly allowed.
- Other unlisted origins are blocked at the browser level.
10. API Gateway and Backend: Where Should You Configure CORS?
When your backend is served behind an API Gateway — such as AWS API Gateway, NGINX, Kong, or Traefik — it can be confusing where to configure CORS (Cross-Origin Resource Sharing).
Let’s go through when and where to apply CORS: Gateway, Backend, or both.
First Principle
CORS is enforced by browsers, not servers.
The only thing that matters is that the final HTTP response reaching the browser includes valid CORS headers.
That means it doesn’t matter who adds them — as long as the browser sees the correct headers.
Option 1 — Handle CORS at the API Gateway (Recommended)
The API Gateway is the first server a browser talks to.
That makes it the perfect place for centralized CORS handling.
Advantages
Centralized Policy Management
Manage all origins, headers, and methods in one place.Simplified Backends
Microservices don’t have to duplicate CORS logic.Automatic Preflight Handling
The Gateway can respond toOPTIONS
requests itself.Faster Updates
Adjust CORS rules without rebuilding backend code.
Example — AWS API Gateway CORS Config
{
"CorsConfiguration": {
"AllowOrigins": ["https://app.example.com"],
"AllowMethods": ["GET", "POST", "OPTIONS"],
"AllowHeaders": ["Content-Type", "Authorization"],
"AllowCredentials": true
}
}
The Gateway:
- Responds automatically to
OPTIONS
preflight. - Injects the proper headers into responses.
- Hides the complexity from your backend APIs.
Option 2 — Handle CORS in the Backend (When Needed)
You might choose backend-level CORS if:
- Your Gateway acts as a transparent proxy (no header manipulation).
- Different services require different origins or rules.
- Certain API calls skip the Gateway entirely.
You must handle:
-
OPTIONS
preflight responses. - Matching origins exactly between request and header.
- No wildcard
*
whenAccess-Control-Allow-Credentials: true
is used.
Option 3 — Handle CORS in Both (Not Rcommanded)
In some environments, both Gateway and Backend add CORS headers — typically during a migration.
This is fine only if both configurations return identical headers.
Otherwise, conflicting values (for example, Access-Control-Allow-Origin
) cause browsers to discard the response.
Use this dual setup only temporarily, not as a permanent architecture.
When You Don’t Need CORS
CORS is purely a browser security mechanism.
If requests originate from:
- Another backend server
- Mobile apps using native HTTP clients
- Internal service networks (no browsers)
Then you don’t need CORS at all.
Best Practice Decision Table
Scenario | Who Should Handle CORS? | Why |
---|---|---|
Browser calls API through public gateway | Gateway | Centralized and simpler |
Gateway is transparent or not configurable | Backend | Backend must manage headers |
Gradual migration | Both | Temporary safety |
Internal or mobile-only access | None | Not needed |
- CORS belongs at the first browser-facing layer, ideally the API Gateway.
- The Backend should only handle CORS if the Gateway can’t or shouldn’t.
- Having both handle it can create conflicts unless headers match exactly.
- For consistent behavior, centralize CORS logic at the Gateway.
Testing Your Setup
Use curl
or a REST client to simulate preflight requests:
curl -i -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
https://api.example.com/data
If your configuration is correct, you should see:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
That means your Gateway or Backend CORS rules are properly aligned.
If browsers connect to your API via the Gateway, configure CORS on the Gateway.
Use backend CORS only when the Gateway can’t handle it.
11. Loading Resources Cross Domain with the Script Tag
Under the Same‑Origin Policy (SOP), web pages are generally restricted from reading data returned by requests to other domains. However, some HTML elements — such as <script>
, <img>
, and <link>
— are not limited by this rule.
The <script>
element was intentionally designed to allow loading and executing code from different origins. This design supports the reuse of external resources, such as shared libraries, analytics scripts, or frameworks hosted on content delivery networks (CDNs). The restriction under SOP applies mainly to reading data, not to loading and executing external code.
When a browser processes a tag like the following:
<script src="https://cdn.example.com/library.js"></script>
it sends an HTTP request to the external domain, retrieves the JavaScript file, and executes it in the context of the current page. The browser does not block this behavior because the script is not trying to read external data; it simply imports executable code that runs locally once loaded.
This capability forms the foundation of how websites include shared scripts, but it also introduces potential security risks. If a malicious or compromised external script is loaded, it gains full access to the page’s environment, including the Document Object Model (DOM), cookies (when accessible), and user interactions. For this reason, modern security practices recommend:
- Hosting critical scripts within the same origin when possible.
- Using Subresource Integrity (SRI) to ensure the script’s content matches an expected hash.
- Limiting external dependencies to trusted sources.
In modern browsers, this cross‑domain script inclusion remains allowed but is managed through stricter security mechanisms such as Content Security Policy (CSP) and SRI, which help reduce risks associated with externally loaded scripts.
12. A Short History of CORS
Web browsers originally enforced the Same‑Origin Policy (SOP), which restricted web pages from making requests to different domains. To work around this limitation, developers used a method known as JSONP (JSON with Padding). JSONP allowed limited cross‑domain data exchange through <script>
tags but was constrained to GET
requests and presented security concerns.
The World Wide Web Consortium (W3C) initiated work on a standardized solution in May 2006, when the first W3C Working Draft was published. In March 2009, the draft was renamed to Cross‑Origin Resource Sharing (CORS), and in January 2014, it became an official W3C Recommendation.
CORS introduced a standardized set of HTTP headers that enable controlled access to resources across different origins, defining how browsers and servers can interact securely across domains.
13. Summary
Scenario | Required Headers | Notes |
---|---|---|
Simple Request | Access-Control-Allow-Origin |
GET/POST/HEAD only |
Non‑Simple (Preflighted) |
Access-Control-Allow-Origin , Access-Control-Allow-Methods , Access-Control-Allow-Headers
|
Browser sends an OPTIONS request first |
Using Cookies | Same as above + Access-Control-Allow-Credentials: true
|
Access-Control-Allow-Origin must not use *
|
Expose Extra Headers | Add Access-Control-Expose-Headers
|
Allows JS to read non‑default headers |
Troubleshooting Flow:
- Check whether your request is “simple” or requires preflight.
- Ensure your backend handles the
OPTIONS
call correctly. - Validate
Access-Control-Allow-Origin
matches exactly. - When using cookies, ensure both client and server specify credentials explicitly.
Top comments (0)