DEV Community

Sam Leung
Sam Leung

Posted on

From SOP to CORS: Understanding the Web’s Cross‑Origin Security Model

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.
Enter fullscreen mode Exit fullscreen mode

 

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

  1. The Same-Origin Policy
  2. What Is an Origin?
  3. What Is a Cross-Origin Request?
  4. What Is CORS (Cross-Origin Resource Sharing)?
  5. Simple CORS Requests
  6. Preflight Requests
  7. Key CORS Headers Explained
  8. CORS with Cookies and Credentials
  9. Practical Example: CORS in Rust (Axum)
  10. API Gateway and Backend: Where Should You Configure CORS?
  11. Loading Resources Cross‑Domain with the <script> Tag
  12. A Short History of CORS
  13. Summary
  14. 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
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

The browser automatically adds:

Origin: https://app.example.com
Enter fullscreen mode Exit fullscreen mode

If the API responds with:

Access-Control-Allow-Origin: https://app.example.com
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

 

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" });
Enter fullscreen mode Exit fullscreen mode

For XMLHttpRequest:

const xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.open("POST", "https://api.example.com/data");
xhr.send();
Enter fullscreen mode Exit fullscreen mode

Server‑side

The response must include:

Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://app.example.com
Enter fullscreen mode Exit fullscreen mode

⚠️ Important:
When Access-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'.
Enter fullscreen mode Exit fullscreen mode

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"] }
Enter fullscreen mode Exit fullscreen mode

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" })))
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Centralized Policy Management
    Manage all origins, headers, and methods in one place.

  2. Simplified Backends
    Microservices don’t have to duplicate CORS logic.

  3. Automatic Preflight Handling
    The Gateway can respond to OPTIONS requests itself.

  4. 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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 * when Access-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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

  1. Check whether your request is “simple” or requires preflight.
  2. Ensure your backend handles the OPTIONS call correctly.
  3. Validate Access-Control-Allow-Origin matches exactly.
  4. When using cookies, ensure both client and server specify credentials explicitly.

 

14. References

Top comments (0)