Browser Security Model: SOP, CSP, CORS, and Cross-Origin Isolation
How does the browser security model work (SOP, CSP, CORS)?
TL;DR
- Bottom line: The browser security model is a layered defense system where the Same-Origin Policy (SOP) is the foundation restricting cross-origin data access, CORS selectively relaxes SOP for controlled API sharing, CSP restricts resource loading to prevent injection attacks, and COOP/COEP enable process-level isolation against Spectre-class side-channel attacks.
- Key tool/command:
curl -sI https://your-site.com | grep -iE 'content-security|access-control|cross-origin|referrer-policy|permissions-policy'to audit all browser security headers at once. - Watch out for: Using
Access-Control-Allow-Origin: *with credentials -- browsers silently reject this, causing hard-to-debug CORS failures. - Works with: All modern browsers. CSP Level 3 in Chrome 59+, Firefox 58+, Safari 15.4+, Edge 79+. COOP/COEP in Chrome 83+, Firefox 79+, Safari 15.2+.
Constraints
- SOP is enforced by the BROWSER, not the server -- server-side code cannot rely on SOP for protection
- CORS headers MUST be set on the SERVER response, not on the client request -- misconfigured CORS is a server vulnerability
- NEVER use
Access-Control-Allow-Origin: *withAccess-Control-Allow-Credentials: true-- browsers reject this combination - CSP is defense-in-depth, NOT a replacement for input validation or output encoding
- COOP:
same-originbreaks OAuth popup flows and payment integrations -- usesame-origin-allow-popupsfor those cases - Permissions-Policy replaces Feature-Policy -- use the new header name (Feature-Policy is a deprecated alias)
Quick Reference
Browser Security Policies Comparison
| # | Policy/Header | Purpose | Scope | Default Behavior | Bypass Risk |
|---|---|---|---|---|---|
| 1 | Same-Origin Policy (SOP) | Restricts cross-origin DOM access, XHR/fetch reads, and storage | Browser-enforced on all origins | Blocks cross-origin reads; allows embeds and writes | Misconfigured CORS, document.domain (deprecated), postMessage without origin check |
| 2 | Content Security Policy (CSP) | Controls which resources (scripts, styles, images) a page can load | Per-page via HTTP header or <meta> | No restrictions (everything allowed) | unsafe-inline, unsafe-eval, overly broad allowlists, script gadgets |
| 3 | Cross-Origin Resource Sharing (CORS) | Allows servers to opt in to cross-origin requests | Per-resource via response headers | Browsers block cross-origin fetch/XHR responses | Wildcard origin with credentials, reflected Origin header, null origin trust |
| 4 | Cross-Origin-Opener-Policy (COOP) | Isolates browsing context group from cross-origin openers | Per-document via response header | unsafe-none (no isolation) | Breaking popup-based OAuth/payment flows with same-origin |
| 5 | Cross-Origin-Embedder-Policy (COEP) | Requires subresources to opt in via CORS/CORP | Per-document via response header | unsafe-none (no restrictions) | Third-party resources without CORP/CORS headers get blocked |
| 6 | Cross-Origin-Resource-Policy (CORP) | Controls which origins can embed a resource | Per-resource via response header | Resources loadable from any origin | Blocks legitimate cross-origin embeds if set too restrictively |
| 7 | Permissions-Policy | Restricts browser features (camera, geolocation, etc.) | Per-page and per-iframe | All features available to top-level | Overly permissive allow on iframes |
| 8 | Referrer-Policy | Controls what referrer info is sent with requests | Per-page or per-request | strict-origin-when-cross-origin (Chrome 85+ default) | no-referrer-when-downgrade leaks full URL on HTTPS-to-HTTPS navigations |
Origin Definition (SOP Foundation)
| Component | Same-Origin Example | Cross-Origin Example |
|---|---|---|
| Scheme | https://a.com = https://a.com | http://a.com != https://a.com |
| Host | a.com = a.com | a.com != b.a.com |
| Port | a.com:443 = a.com:443 | a.com:443 != a.com:8443 |
Decision Tree
START: What is your cross-origin security goal?
├── Need to BLOCK cross-origin data reads?
│ ├── YES → SOP handles this by default. No action needed.
│ └── NO ↓
├── Need to ALLOW cross-origin API access?
│ ├── YES → Configure CORS headers on the server (Access-Control-Allow-Origin)
│ └── NO ↓
├── Need to prevent inline script injection / restrict resource loading?
│ ├── YES → Deploy Content Security Policy (CSP) with nonce-based script-src
│ └── NO ↓
├── Need SharedArrayBuffer or high-res timers (cross-origin isolation)?
│ ├── YES → Set COOP: same-origin + COEP: require-corp on the document
│ └── NO ↓
├── Need to restrict browser features in embedded iframes?
│ ├── YES → Use Permissions-Policy header + iframe allow attribute
│ └── NO ↓
├── Need to limit referrer information leakage?
│ ├── YES → Set Referrer-Policy: strict-origin-when-cross-origin (or stricter)
│ └── NO ↓
└── DEFAULT → Deploy all security headers: CSP + COOP + COEP + Permissions-Policy + Referrer-Policy
Step-by-Step Guide
1. Audit current security headers
Check which headers your site already sends. Missing headers mean the browser uses permissive defaults. [src1]
# Audit all browser security headers at once
curl -sI https://your-site.com | grep -iE \
'content-security|access-control|cross-origin|referrer-policy|permissions-policy|x-frame|strict-transport'
Verify: Every key header should appear in the output: Content-Security-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy, Referrer-Policy, Permissions-Policy.
2. Deploy a strict Content Security Policy
CSP is the most impactful single header -- it mitigates XSS, data injection, and clickjacking. Use nonce-based policies for best protection. [src3]
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{random}' 'strict-dynamic';
style-src 'self' 'nonce-{random}';
img-src 'self' https:;
font-src 'self';
connect-src 'self' https://api.your-domain.com;
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
form-action 'self';
upgrade-insecure-requests;
Verify: Inject <script>alert(1)</script> -- CSP should block it. Check DevTools Console for CSP violation reports.
3. Configure CORS on your API server
Set specific origins -- never reflect the Origin header blindly. [src2]
// Node.js/Express CORS configuration
const cors = require('cors'); // ^2.8.0
const corsOptions = {
origin: ['https://your-app.com', 'https://staging.your-app.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400 // Preflight cache: 24 hours
};
app.use('/api', cors(corsOptions));
Verify: curl -H "Origin: https://evil.com" -sI https://api.your-site.com/endpoint -- should NOT return Access-Control-Allow-Origin: https://evil.com.
4. Enable cross-origin isolation (COOP + COEP)
Required for SharedArrayBuffer and high-resolution timers. Test in report-only mode first. [src5]
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
For pages needing OAuth popups or payment integrations:
Cross-Origin-Opener-Policy: same-origin-allow-popups
Cross-Origin-Embedder-Policy: credentialless
Verify: Open DevTools Console and check self.crossOriginIsolated === true.
5. Set Permissions-Policy and Referrer-Policy
Restrict unnecessary browser features and control referrer leakage. [src1]
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(self),
usb=(), magnetometer=(), gyroscope=(), accelerometer=()
Referrer-Policy: strict-origin-when-cross-origin
Verify: document.featurePolicy.allowsFeature('camera') should return false in DevTools Console.
Code Examples
Node.js/Express: Complete Security Headers Middleware
const crypto = require('crypto');
function securityHeaders(req, res, next) {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.cspNonce = nonce;
res.set({
'Content-Security-Policy':
`default-src 'self'; script-src 'nonce-${nonce}' 'strict-dynamic'; ` +
`style-src 'self' 'nonce-${nonce}'; object-src 'none'; base-uri 'none'; ` +
`frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests`,
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY'
});
next();
}
Nginx: Security Headers Configuration
# /etc/nginx/snippets/security-headers.conf
add_header Content-Security-Policy
"default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy
"camera=(), microphone=(), geolocation=(), payment=(self)"
always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
Python/Django: Middleware Security Headers
# settings.py -- Django 4.x+ built-in security middleware
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
X_FRAME_OPTIONS = "DENY"
# CSP via django-csp (^4.0)
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'",)
CSP_OBJECT_SRC = ("'none'",)
CSP_BASE_URI = ("'none'",)
CSP_FRAME_ANCESTORS = ("'none'",)
# CORS via django-cors-headers (^4.0)
CORS_ALLOWED_ORIGINS = [
"https://your-frontend.com",
"https://staging.your-frontend.com",
]
CORS_ALLOW_CREDENTIALS = True
Go: Security Headers Middleware
package main
import "net/http"
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy",
"default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
w.Header().Set("Cross-Origin-Embedder-Policy", "require-corp")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}
Anti-Patterns
Wrong: Reflecting the Origin header in CORS
// BAD -- reflects any origin, equivalent to disabling SOP
app.use((req, res, next) => {
res.set('Access-Control-Allow-Origin', req.headers.origin);
res.set('Access-Control-Allow-Credentials', 'true');
next();
});
// Attacker on https://evil.com can read authenticated responses
Correct: Allowlist specific origins
// GOOD -- only trusted origins can make credentialed requests
const ALLOWED = new Set(['https://app.example.com', 'https://staging.example.com']);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (ALLOWED.has(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Credentials', 'true');
res.set('Vary', 'Origin'); // Critical for caching
}
next();
});
Wrong: CSP with unsafe-inline and unsafe-eval
# BAD -- defeats the entire purpose of CSP
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'
Correct: Nonce-based CSP with strict-dynamic
# GOOD -- nonces block injected scripts, strict-dynamic trusts loader chains
Content-Security-Policy: default-src 'self'; script-src 'nonce-abc123' 'strict-dynamic'
Wrong: Trusting the null origin in CORS
// BAD -- null origin comes from sandboxed iframes, data: URLs, redirects
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin === 'null') {
res.set('Access-Control-Allow-Origin', 'null');
}
next();
});
// Attacker can forge null origin via sandboxed iframe
Correct: Never trust null origin
// GOOD -- reject null and only trust explicit HTTPS origins
const ALLOWED = new Set(['https://app.example.com']);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED.has(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Vary', 'Origin');
}
next();
});
Wrong: Missing Vary: Origin on CORS responses
// BAD -- CDN caches response for one origin, serves it to all
res.set('Access-Control-Allow-Origin', req.headers.origin);
// Missing: res.set('Vary', 'Origin');
Correct: Always set Vary: Origin
// GOOD -- ensures CDN/proxy caches differentiate by origin
res.set('Access-Control-Allow-Origin', origin);
res.set('Vary', 'Origin');
Common Pitfalls
- CORS preflight caching: Browsers cache preflight responses (OPTIONS) based on
Access-Control-Max-Age. If you change allowed methods/headers, users won't see the change until the cache expires. Fix: Set a reasonableAccess-Control-Max-Age(e.g., 86400 seconds) and document it. [src2] - CSP strict-dynamic + script gadgets:
strict-dynamictrusts any script loaded by a trusted script. Libraries like jQuery or Angular that evaluate strings as code can be exploited as script gadgets. Fix: Audit third-party libraries foreval()-like behavior; prefer nonce-only policies. [src6] - COEP blocks third-party resources: Enabling
Cross-Origin-Embedder-Policy: require-corpblocks all cross-origin resources without CORP/CORS headers (ads, analytics, fonts). Fix: Usecredentiallessmode or addcrossoriginattribute to<img>,<script>,<link>tags. [src5] - postMessage without origin verification:
window.addEventListener('message', handler)without checkingevent.originenables cross-origin data injection. Fix: Always validateevent.originagainst an allowlist before processing the message. [src1] - COOP breaks window.opener:
Cross-Origin-Opener-Policy: same-originsevers thewindow.openerreference for cross-origin popups, breaking OAuth redirect flows. Fix: Usesame-origin-allow-popupswhen popup communication is needed. [src5] - Missing CORS on error responses: Server returns CORS headers on 200 OK but not on 4xx/5xx errors, causing the browser to block the error details from JavaScript. Fix: Apply CORS headers to ALL responses, including errors. [src2]
- Referrer leakage on HTTPS-to-HTTPS cross-origin: Default
strict-origin-when-cross-originstops sending referrer on protocol downgrade, butno-referrer-when-downgradeleaks the full URL on HTTPS-to-HTTPS cross-origin requests. Fix: Usestrict-origin-when-cross-originorno-referrer. [src1] - document.domain deprecation: Setting
document.domainto relax SOP between subdomains is deprecated in Chrome 115+ and disabled by default. Fix: UsepostMessage()or CORS for cross-subdomain communication. [src1]
Diagnostic Commands
# Audit all security headers at once
curl -sI https://your-site.com | grep -iE \
'content-security|access-control|cross-origin|referrer-policy|permissions-policy|x-frame|strict-transport'
# Test CORS: check if an origin is allowed
curl -H "Origin: https://app.example.com" -sI https://api.example.com/endpoint \
| grep -i access-control
# Test CORS preflight (OPTIONS request)
curl -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization" \
-sI https://api.example.com/endpoint
# Check cross-origin isolation status (in browser console)
# self.crossOriginIsolated // should be true
# Validate CSP: visit https://csp-evaluator.withgoogle.com/
# Scan security headers: visit https://securityheaders.com/?q=https://your-site.com
# Test for CORS misconfiguration (evil origin should be rejected)
curl -H "Origin: https://evil.com" -sI https://your-site.com/api \
| grep -i access-control-allow-origin
Version History & Compatibility
| Standard/Feature | Status | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|---|
| Same-Origin Policy (SOP) | Foundational | All | All | All | All |
| CSP Level 2 | W3C Rec | 40+ | 31+ | 10+ | 15+ |
| CSP Level 3 (strict-dynamic, nonces) | W3C WD | 59+ | 58+ | 15.4+ | 79+ |
| CORS (basic) | W3C Rec | 4+ | 3.5+ | 4+ | 12+ |
| COOP (same-origin) | Standard | 83+ | 79+ | 15.2+ | 83+ |
| COEP (require-corp) | Standard | 83+ | 79+ | 15.2+ | 83+ |
| COEP (credentialless) | Standard | 96+ | 119+ | No | 96+ |
| Cross-Origin-Resource-Policy (CORP) | Standard | 73+ | 74+ | 12+ | 79+ |
| Permissions-Policy | Standard | 88+ | 65+ (partial) | No | 88+ |
| Referrer-Policy | Standard | 56+ | 50+ | 11.1+ | 79+ |
| Site Isolation | Chrome-specific | 67+ (desktop) | Fission (95+) | Process per tab | 67+ |
| Trusted Types | Draft | 83+ | Behind flag | No | 83+ |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Your API serves multiple frontend origins | API and frontend share the same origin | SOP already blocks unauthorized access -- CORS not needed |
| Preventing inline script injection (XSS defense layer) | You need sole XSS prevention | CSP alone is insufficient -- add output encoding (see XSS Prevention unit) |
| App uses SharedArrayBuffer, WASM threads, or high-res timers | App is a simple content site with no advanced APIs | COOP/COEP add complexity with no benefit |
| Embedding third-party iframes that should not access camera/mic | Top-level page controls all features itself | Permissions-Policy is most valuable for iframe restrictions |
| Hosting resources that should only load on your own site | Hosting public CDN resources meant for cross-origin use | Set CORP: cross-origin for public resources |
Important Caveats
- SOP allows cross-origin writes (form submissions, navigations) and embeds (images, scripts, stylesheets, iframes) by default -- it only blocks cross-origin reads (XHR/fetch responses, DOM access)
- CORS does NOT protect against CSRF -- CORS is about reading responses, while CSRF exploits the browser automatically attaching cookies to cross-origin requests
- CSP
strict-dynamicpropagates trust: if a trusted script loads a third-party script, that third-party script is also trusted, which can be exploited via script gadgets in libraries like jQuery or Angular - Site Isolation (process-per-site) adds 10-13% memory overhead on desktop -- it's a browser-level defense, not something developers configure, but it impacts performance budgets
document.domainis deprecated and disabled by default in Chrome 115+ -- do not rely on it for cross-subdomain communication- The Permissions-Policy header has inconsistent browser support -- Safari does not support it, and Firefox support is partial (use
<iframe allow="...">for broader compatibility)