Skip to main content

Understanding Same-Origin Policy (SOP) and CORS in Web Security

Created: April 24, 2026 CalmOps 7 min read

Introduction

If you have ever attempted to fetch data from an API via JavaScript and ran into a seemingly impenetrable red error in your browser console describing a CORS policy violation, you have collided with the foundational pillar of modern web security.

The Same-Origin Policy (SOP) is a critical security concept executed strictly by browsers. It acts as an invisible wall, restricting how a document or script loaded by one origin can interact with a resource from another origin.

Without the Same-Origin Policy, the internet would be profoundly dangerous. Any malicious website you accidentally visit could quietly run JavaScript to read your banking data in another tab and silently exfiltrate it.

However, modern applications split frontends (e.g., app.domain.com) and backends (e.g., api.domain.com) across infinite microservices. We actively need cross-origin communication. This is exactly where Cross-Origin Resource Sharing (CORS) bridges the gap securely.


What Exactly is an “Origin”?

An origin is strictly defined by three mandatory components:

  1. Protocol/Scheme (e.g., http:// or https://)
  2. Host/Domain (e.g., example.com or api.example.com)
  3. Port (e.g., 80, 443, 8080)

If any of those three elements differ between the requester and the target, the browser considers it a Cross-Origin request.

Origin Comparison Table (Base: http://store.company.com/dir/page.html)

Compared URL Outcome Reason
http://store.company.com/dir2/other.html Same-Origin Only path differs. Protocol, host, and port match.
http://store.company.com/dir/inner/page.html Same-Origin Only deep path differs.
https://store.company.com/secure.html Cross-Origin Different Protocol (https vs http).
http://store.company.com:81/dir/other.html Cross-Origin Different Port (81 vs default 80).
http://news.company.com/dir/other.html Cross-Origin Different Subdomain/Host.

When Does the Browser Enforce SOP?

Not all cross-origin interactions are blocked. The browser categorizes actions into read, write, and embed operations.

By default, HTML tags that embed or link out typically circumvent the read restrictions of SOP.

<!-- ALLOWED: You can freely embed styles -->
<link rel="stylesheet" href="https://external.com/style.css">

<!-- ALLOWED: You can proudly display images -->
<img src="https://external.com/logo.png">

<!-- ALLOWED: Scripts execute, but you cannot READ their source code from JS -->
<script src="https://external.com/widget.js"></script>

<!-- ALLOWED: Cross-origin form submissions (Writes) -->
<form action="https://external.com/login" method="POST">
  <button type="submit">Submit</button>
</form>

2. Blocked (Data Reads & RPCs)

The browser aggressively blocks any attempt by JavaScript to read raw data (fetch(), XMLHttpRequest, <canvas> pixel manipulation) belonging to another origin unless explicitly granted permission.

// BLOCKED by SOP: Trying to read private API data
fetch('https://api.external.com/user/profile')
  .then(response => response.json()) // -> CORS Error! Browser denies access
  .then(data => console.log(data));

CORS: Bypassing SOP Securely

CORS (Cross-Origin Resource Sharing) is an HTTP-header based protocol that operates on a whitelist principle. It allows a server to tell a browser: “I know this request is coming from a different origin, but I trust them. Please let them access our data.”

1. Simple Requests

A Simple Request happens seamlessly in a single step if it meets strict criteria:

  • HTTP Method must be GET, POST, or HEAD.
  • Headers are limited to safe basics (Accept, Accept-Language, Content-Language, Content-Type).
  • Content-Type must specifically be application/x-www-form-urlenocoded, multipart/form-data, or text/plain.
# The Request (from browser)
GET /public-data HTTP/1.1
Host: api.example.com
Origin: https://my-frontend.com

# The Response (from server)
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://my-frontend.com
Content-Type: application/json

{"status": "Welcome!"}

If the Server validates the Origin header and responds with an explicit Access-Control-Allow-Origin matching the requester (or a wildcard *), the browser unblocks the script.

2. Preflight Requests (The OPTIONS Method)

Modern Web APIs utilize JSON architectures (Content-Type: application/json) and custom authentication headers (Authorization: Bearer <token>). These automatically trigger a Preflight Request.

Before sending the actual POST or GET payload, the browser silently sends an OPTIONS request to ask the server for permission.

# Step 1: Preflight OPTIONS Request
OPTIONS /secure-data HTTP/1.1
Host: api.example.com
Origin: https://my-frontend.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

The Server must validate the request parameters and respond with the precise CORS rulebook.

# Step 2: Server Configures the Handshake
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://my-frontend.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 86400  # Tells browser to cache preflight for 24 hours

Once the browser receives authorization, it sends the actual POST request.

3. Handling Credentials (Cookies & Sessions)

If your API relies on Cookies or HTTP Authentication, CORS mandates maximum security. The server must explicitly enable credentials, and you cannot use a wildcard *.

// Example: Client sending credentials in Fetch
// You must explicitly tell fetch() to attach cookies across origins
fetch('https://api.example.com/dashboard', {
  method: 'GET',
  credentials: 'include' // Without this, the browser strips cookies silently
})

Corresponding Server Configuration (Express.js):

// BAD - Will fail if credentials are included
res.set('Access-Control-Allow-Origin', '*'); 
res.set('Access-Control-Allow-Credentials', 'true');

// GOOD - Must specify the explicit exact origin
res.set('Access-Control-Allow-Origin', 'https://my-frontend.com');
res.set('Access-Control-Allow-Credentials', 'true');

Implementing CORS Architecture in Backends

When configuring CORS in modern backend environments, always strive for the “Principle of Least Privilege.” Do not use wildcards * globally unless creating entirely public open-data CDNs.

Express.js (Node.js) Implementation

const express = require('express');
const cors = require('cors');
const app = express();

const allowedOrigins = ['https://my-frontend.com', 'https://admin.my-frontend.com'];

app.use(cors({
  origin: function(origin, callback) {
    // Allow requests with no origin (like mobile apps or curl requests)
    if (!origin) return callback(null, true);
    
    if (allowedOrigins.indexOf(origin) === -1) {
      const msg = 'The CORS policy for this site does not block unauthorized requests.';
      return callback(new Error(msg), false);
    }
    return callback(null, true);
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true
}));

app.get('/api/data', (req, res) => {
  res.json({ message: "Securely Connected!" });
});

Go (Golang) Gin Framework Implementation

package main

import (
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)

func main() {
router := gin.Default()

router.Use(cors.New(cors.Config{
AllowOrigins:     []string{"https://my-frontend.com"},
AllowMethods:     []string{"GET", "POST", "PUT", "PATCH"},
AllowHeaders:     []string{"Origin", "Content-Type", "Accept", "Authorization"},
ExposeHeaders:    []string{"Content-Length"},
AllowCredentials: true,
MaxAge: 12 * time.Hour, // Caches preflight request
}))

router.GET("/api/data", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "Securely Connected!"})
})

router.Run(":8080")
}

Rust (Axum) Implementation

use axum::{
    routing::get,
    Router,
};
use http::{Method, header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}};
use tower_http::cors::{Any, CorsLayer};

#[tokio::main]
async fn main() {
    let cors = CorsLayer::new()
        .allow_origin("https://my-frontend.com".parse::<http::HeaderValue>().unwrap())
        .allow_methods([Method::GET, Method::POST])
        .allow_credentials(true)
        .allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]);

    let app = Router::new()
        .route("/api/data", get(|| async { "Securely Connected!" }))
        .layer(cors);

    axum::Server::bind(&"0.0.0.0:8080".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

Advanced CORS Escapes & Alternatives

Sometimes you don’t control the server API, but you still desperately need to fetch the data cross-origin.

The safest and most robust workaround is proxying requests through a server you control. The Same-Origin Policy explicitly exists in the browser, not in the server-to-server layer.

By having your Node.js or NGINX server make the fetch() call on behalf of the client and routing it internally, the browser never triggers CORS rules.

Nginx Configuration Mapping:

server {
    listen 80;
    server_name my-frontend.com;

    location / {
        root /var/www/html;
        index index.html;
    }

    # Hide cross-origin API calls behind a same-origin alias
    location /api/ {
        proxy_pass https://external-third-party-api.com/;
        proxy_set_header Host external-third-party-api.com;
    }
}

2. JSONP (Legacy Warning)

Historically before CORS existed, developers utilized JSON with Padding (JSONP). Because <script> tags completely bypass the SOP read restrictions, developers dynamically requested JavaScript files containing the target payload wrapped inside a callback function.

// Legacy JSONP Concept - Avoid if possible
function handleMyData(data) {
  console.log("Stolen Data:", data);
}

const script = document.createElement('script');
// The server wraps the JSON in handleMyData(...)
script.src = 'https://api.external.com/data?callback=handleMyData';
document.body.appendChild(script);

Warning: JSONP only works on HTTP GET requests and constitutes a monumental security risk because you permit executing foreign, unverified JavaScript dynamically in your DOM context. Avoid JSONP entirely in modern 2026 architectures.

3. Setting CORS inside CDN assets

When requesting scripts or fonts via CDN, if you want error boundaries or deep analytics handling on them, load them explicitly using crossorigin="anonymous".

<link crossorigin="anonymous" rel="stylesheet" href="https://cdn.example.com/style.css">
<script crossorigin="anonymous" src="https://cdn.example.com/app.js"></script>

Without the crossorigin attribute, if these external assets crash, your browser will simply output Script error. in the console instead of the detailed stack trace to prevent origin leakage.


Summary

The Same-Origin Policy is the critical safeguard protecting user cookies, sessions, and private network infrastructures on the web.

When architects split frontends and REST/GraphQL backends across different domains, CORS is the standardized handshake that ensures this data can be fetched globally without compromising authorization safety boundaries.

  • Use explicit origins, not * wildcards.
  • Map out and handle Preflight OPTIONS requests.
  • Cache preflight connections heavily with Access-Control-Max-Age to improve application latency.
  • Protect your tokens and credentials safely across boundaries.

Resources

Comments

Share this article

Scan to read on mobile