What is the Same-Origin Policy?
The Same-Origin Policy (SOP) is one of the browser's most fundamental security mechanisms. It dictates that scripts can only access resources that share the same origin as the current page. An "origin" requires three components to match simultaneously:
- Scheme:
httpsandhttpare different origins - Host:
api.example.comandexample.comare different origins - Port:
:8080and:3000are different origins
For example, when a page at https://example.com/page accesses these resources:
| Target URL | Same Origin? | Reason |
|---|---|---|
https://example.com/api |
✅ | Same scheme, host, port |
http://example.com/api |
❌ | Different scheme |
https://api.example.com/data |
❌ | Different host (subdomains count) |
https://example.com:8080/api |
❌ | Different port |
While SOP improves security, it also blocks legitimate cross-origin requests — modern web apps with separated frontends/backends and third-party API calls are the norm. CORS is the standard designed to resolve this tension.
What is CORS?
CORS (Cross-Origin Resource Sharing, RFC 6454 / Fetch standard) is a set of HTTP header mechanisms that lets a server declare which external origins may access its resources through a browser. It is enforced by the browser on the client side, not by the server.
This is crucial to understand: CORS protects the browser user, not the server. The server always processes the request; the browser just decides based on response headers whether to hand the result to JavaScript.
Simple Requests vs. Preflight Requests
CORS classifies cross-origin requests into two categories with different flows.
Simple Requests
A request is sent "directly" without a preflight if it meets ALL of these:
- Method is
GET,HEAD, orPOST - Only uses safe headers:
Accept,Accept-Language,Content-Language,Content-Type Content-Typeis limited to:text/plain,multipart/form-data,application/x-www-form-urlencoded- No
ReadableStreamobject is used - No event listeners registered on
XMLHttpRequestUpload
The browser sends the request directly; the server must include Access-Control-Allow-Origin in the response or the browser refuses to deliver it to the script.
Preflight Requests
Requests that don't qualify as simple (typical cases: POST with application/json, PUT/DELETE methods, custom headers like Authorization) trigger a preliminary OPTIONS request first.
Preflight flow:
Browser Server
│ │
│── OPTIONS /api ──────▶│ (preflight: asking permission)
│ │
│◀─ 200 + CORS headers ─│
│ │
│── POST /api ─────────▶│ (actual request)
│ │
│◀─ 201 + CORS headers ─│
The preflight carries these headers:
OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
Core Response Headers Explained
Access-Control-Allow-Origin
The most critical header, specifying the allowed origin:
# Allow a single origin
Access-Control-Allow-Origin: https://app.example.com
# Allow all origins (use with caution, incompatible with credentials)
Access-Control-Allow-Origin: *
Note: If the request carries cookies (credentials: 'include'), you cannot use * — you must specify the exact origin.
Access-Control-Allow-Methods
Declares allowed HTTP methods in the preflight response:
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS
Access-Control-Allow-Headers
Declares allowed request headers in the preflight response:
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With
Access-Control-Expose-Headers
By default, JavaScript can only read a handful of "safe" response headers (Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma). To expose others, declare them:
Access-Control-Expose-Headers: X-Total-Count, X-Request-Id
Access-Control-Allow-Credentials
Allows requests to carry cookies, HTTP auth, or client SSL certificates:
Access-Control-Allow-Credentials: true
The frontend must also opt in:
fetch('https://api.example.com/data', {
credentials: 'include' // send credentials
});
Access-Control-Max-Age
How long (in seconds) to cache the preflight result, avoiding repeated OPTIONS:
Access-Control-Max-Age: 86400 // cache for a day
Browsers cap this (Chrome ~2 hours, Firefox ~24 hours); excess is truncated.
Common Configuration Patterns
Pattern 1: Nginx Reverse Proxy (Recommended)
Making frontend and backend same-origin is the simplest solution. Nginx forwards API requests to the backend:
server {
listen 80;
server_name example.com;
location / {
root /var/www/frontend;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
The frontend accesses /api/... same-origin — no CORS config needed at all.
Pattern 2: Dynamic Whitelist (Node.js Express)
const express = require('express');
const cors = require('cors');
const app = express();
const whitelist = [
'https://app.example.com',
'https://admin.example.com'
];
const corsOptions = {
origin: (origin, callback) => {
// Allow whitelisted, or non-browser requests (origin undefined, e.g. curl)
if (!origin || whitelist.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400
};
app.use(cors(corsOptions));
Pattern 3: Spring Boot Global Config
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
Pattern 4: Wildcard Subdomains
To allow all subdomains:
# Can't use a literal wildcard — must match via regex
Access-Control-Allow-Origin: https://*.example.com
In practice, the backend reads the Origin header and validates with a regex:
const origin = req.headers.origin;
if (/^https:\/\/[a-z]+\.example\.com$/.test(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
Debugging CORS Issues
Step 1: Read the Browser Console
CORS error messages are quite explicit. Common ones:
No 'Access-Control-Allow-Origin' header is present: server didn't return the headerThe value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when credentials mode is 'include': used*with credentialsMethod PUT not allowed:Access-Control-Allow-Methodsdoesn't includePUT
Step 2: Inspect the Preflight Request
Open DevTools → Network, filter by OPTIONS, and check:
- The request carries
Access-Control-Request-MethodandAccess-Control-Request-Headers - The response returns 200/204 with complete CORS headers
Access-Control-Allow-Originmatches the request'sOrigin
Step 3: Common Pitfalls
- Response is 200 but browser still errors: CORS checks happen after the response arrives. Even with a 200 status, the browser will block the response if headers are missing. Backend logs show success because the request "succeeded."
- Nginx
add_headernot working:add_headeronly applies to 2xx/3xx by default, not 4xx/5xx. Useadd_header ... always. - Preflight blocked by auth middleware: OPTIONS requests should not require authentication. Exempt OPTIONS before the auth middleware runs.
- CDN cached wrong headers: A CDN may cache CORS headers from the first request, causing subsequent requests from different origins to get the wrong
Access-Control-Allow-Origin. Either cache perOriginbucket on the CDN, or addVary: Origin.
Step 4: Simulate Preflight with curl
curl -X OPTIONS https://api.example.com/api/users \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization" \
-i
Check whether the response headers are complete.
Security Considerations
- Don't blindly use
Access-Control-Allow-Origin: *, especially with credentials. - Always use a whitelist in production, explicitly listing allowed origins.
Access-Control-Allow-Credentials: trueand*are mutually exclusive — the browser will reject it.- CORS is not a replacement for auth. It only controls whether the browser can read responses; an attacker can still make requests from the server side. Sensitive operations need CSRF tokens and SameSite cookies.
- Note that CORS does not block
<img>,<script>,<link>tags from loading cross-origin resources — these aren't subject to SOP (but their content can't be read).
Summary
| Key Point | Description |
|---|---|
| Same-Origin Policy | Browser security cornerstone, restricts cross-origin script access |
| CORS | HTTP header mechanism for servers to declare allowed cross-origin access |
| Simple Request | Sent directly; response needs Allow-Origin |
| Preflight Request | OPTIONS goes first; triggered by complex requests |
| Credentials Mode | Can't use * when credentials: true |
| Recommended Order | Nginx same-origin proxy > backend whitelist > wildcards |
The key to mastering CORS: it's enforced by the browser and negotiated via response headers. Always follow the principle of least privilege — only allow the origins, methods, and headers you genuinely need.