The Security Model, CORS and Headers
A browser runs code from strangers on every page load, often while holding your logged-in session for a dozen other sites. The web’s security model exists to make that survivable. Everything here — the same-origin policy, CORS, CSP, cookie attributes — follows from that one problem.
The same-origin policy
Section titled “The same-origin policy”The foundational rule: code from one origin cannot read data from another origin.
An origin is the triple scheme + host + port. All three must match exactly.
| URL | Same origin as https://example.com/app? |
Why |
|---|---|---|
https://example.com/other |
Yes | Path is irrelevant |
https://example.com:443/x |
Yes | 443 is the default for https |
http://example.com/app |
No | Different scheme |
https://api.example.com/app |
No | Different host — subdomains count |
https://example.com:8443/app |
No | Different port |
What it does and does not restrict
Section titled “What it does and does not restrict”The policy governs reading, not sending. This distinction causes most of the confusion.
Blocked (cross-origin reads):
- Reading the response body of a
fetch/XMLHttpRequestwithout CORS permission. - Reading the DOM or
contentWindowof a cross-origin<iframe>. - Reading pixels from a cross-origin image drawn onto a
<canvas>(it becomes “tainted”). - Reading cookies,
localStorageor IndexedDB belonging to another origin.
Allowed (cross-origin embedding and sending):
<img src>,<script src>,<link rel=stylesheet>,<video>,<iframe>— you can load and display, just not introspect.- Submitting a
<form>to any origin. - Navigating to any URL.
- Sending a
fetchwithmode: 'no-cors'— the request goes out, the response is opaque.
A cross-origin <script src> executing in your origin’s context is also why an XSS in a third-party script is an XSS in your site. The script’s origin is irrelevant once it runs; it inherits yours.
Cross-Origin Resource Sharing is how a server opts specific other origins into reading its responses. It is a relaxation of the same-origin policy, enforced entirely by the browser.
Two things to internalise before the details:
- CORS is not a server-side security control. It stops a browser from handing a response to page JavaScript.
curl, Postman and every server-side HTTP client ignore it completely. It protects the user’s data from other sites; it does not protect your API. - The request usually still reaches your server. For simple requests, the server executes the handler, returns the response, and then the browser discards it. A CORS-blocked
DELETEmay well have deleted something.
Simple requests
Section titled “Simple requests”A request avoids preflight only if all of these hold:
- Method is
GET,HEADorPOST. - Headers are limited to CORS-safelisted ones:
Accept,Accept-Language,Content-Language,Content-Type, plus a few others. Notably noAuthorizationand no custom headers. - If
Content-Typeis present, it is one ofapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain.application/jsondoes not qualify.
The browser sends the request with an Origin header and inspects the response:
curl -v https://api.example.com/items -H 'Origin: https://app.example.com'HTTP/1.1 200 OKAccess-Control-Allow-Origin: https://app.example.comContent-Type: application/jsonIf Access-Control-Allow-Origin is absent or does not match, the browser throws a network error in the page — but the response already happened.
The safelist boundary explains the classic surprise: fetch(url, { method: 'POST', body }) with a form-encoded body is simple, but adding Content-Type: application/json turns it into a preflighted request.
Preflighted requests
Section titled “Preflighted requests”Anything else triggers an automatic OPTIONS request first, asking permission.
curl -v -X OPTIONS https://api.example.com/items/42 \ -H 'Origin: https://app.example.com' \ -H 'Access-Control-Request-Method: DELETE' \ -H 'Access-Control-Request-Headers: authorization, content-type'HTTP/1.1 204 No ContentAccess-Control-Allow-Origin: https://app.example.comAccess-Control-Allow-Methods: GET, POST, PUT, DELETEAccess-Control-Allow-Headers: Authorization, Content-TypeAccess-Control-Max-Age: 86400Only if the preflight approves the method and every requested header does the browser send the real request. Access-Control-Max-Age caches that approval, avoiding a round trip per call (browsers cap it — Chrome at 2 hours, Firefox at 24).
The headers
Section titled “The headers”| Header | Direction | Meaning |
|---|---|---|
Origin |
Request | The requesting origin. Set by the browser; page JS cannot forge it. |
Access-Control-Request-Method |
Preflight | The method the real request will use. |
Access-Control-Request-Headers |
Preflight | Non-safelisted headers the real request will send. |
Access-Control-Allow-Origin |
Response | A single origin, or *. Cannot be a list. |
Access-Control-Allow-Methods |
Preflight response | Permitted methods. |
Access-Control-Allow-Headers |
Preflight response | Permitted request headers. |
Access-Control-Allow-Credentials |
Response | true — permit cookies and HTTP auth. |
Access-Control-Expose-Headers |
Response | Which response headers page JS may read. |
Access-Control-Max-Age |
Preflight response | Seconds to cache the preflight result. |
By default, JavaScript can only read a handful of response headers (Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma). Anything else — a custom X-Total-Count, a Location — must be named in Access-Control-Expose-Headers.
Credentials
Section titled “Credentials”Cookies and HTTP auth are not sent cross-origin by default. Opting in requires both sides:
fetch('https://api.example.com/me', { credentials: 'include' });Access-Control-Allow-Origin: https://app.example.comAccess-Control-Allow-Credentials: truecurl -v https://api.example.com/me \ -H 'Origin: https://app.example.com' \ -H 'Cookie: session=abc123'Because Allow-Origin varies per requester when you echo it, add Vary: Origin so caches do not serve one origin’s response to another.
Content Security Policy
Section titled “Content Security Policy”CSP is a response header that tells the browser which sources of content are allowed to load and execute. Its main job is to make XSS unexploitable even when an injection exists.
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.example.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'Directives
Section titled “Directives”| Directive | Controls |
|---|---|
default-src |
Fallback for any fetch directive not otherwise specified. |
script-src |
Where JavaScript may come from. The one that matters most. |
style-src |
Stylesheets. |
img-src |
Images. |
connect-src |
Targets of fetch, XMLHttpRequest, WebSocket, EventSource. |
font-src, media-src |
Fonts, audio/video. |
frame-src |
What this page may embed in an iframe. |
frame-ancestors |
Who may embed this page. Supersedes X-Frame-Options. |
form-action |
Where forms may submit. Blocks injected forms exfiltrating data. |
base-uri |
Restricts <base href>, which could otherwise hijack every relative URL. |
object-src |
<object>/<embed>. Set to 'none'. |
upgrade-insecure-requests |
Rewrites http:// subresource URLs to https://. |
report-uri / report-to |
Where to POST violation reports. |
Source values: 'self', 'none', a host (https://cdn.example.com), a scheme (https:, data:), 'unsafe-inline', 'unsafe-eval', 'nonce-<random>', 'sha256-<hash>', 'strict-dynamic'.
How it stops XSS
Section titled “How it stops XSS”An injected <script>alert(1)</script> is inline script. Under script-src 'self', inline script is not allowed, so the browser refuses to run it. The injection still exists in the HTML; it just does nothing.
This only holds if you do not use 'unsafe-inline'. And that is the catch — most real sites have inline scripts. The modern answer is a per-response nonce:
Content-Security-Policy: script-src 'nonce-r4nd0mV4lu3' 'strict-dynamic'; object-src 'none'; base-uri 'self'<script nonce="r4nd0mV4lu3"> // runs: the nonce matches</script><script> // blocked: no nonce</script>The nonce must be cryptographically random and regenerated for every response — a static nonce is worthless, since an attacker can just read it and copy it into the injected tag. 'strict-dynamic' lets a nonced script load further scripts it trusts, which makes CSP workable with bundlers and script loaders.
Rolling it out
Section titled “Rolling it out”Deploy in report-only mode first. The browser reports violations without blocking anything:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-reportsCollect reports, fix the legitimate ones, then switch to the enforcing header.
# What does a site currently send?curl -sI https://example.com/ | grep -i 'content-security-policy'Other security headers
Section titled “Other security headers”Strict-Transport-Security (HSTS)
Section titled “Strict-Transport-Security (HSTS)”Forces HTTPS for this host for max-age seconds, converting http:// URLs internally before any request leaves the browser. Kills SSL-stripping on every visit after the first.
Strict-Transport-Security: max-age=63072000; includeSubDomains; preloadX-Content-Type-Options
Section titled “X-Content-Type-Options”X-Content-Type-Options: nosniffStops the browser guessing a content type different from the declared Content-Type. Without it, a file you serve as text/plain that happens to contain HTML may be sniffed and executed as HTML. Set it on everything, unconditionally.
X-Frame-Options and frame-ancestors
Section titled “X-Frame-Options and frame-ancestors”Both prevent clickjacking — your page loaded invisibly in an attacker’s iframe over their own UI, so a user’s click lands on your button.
X-Frame-Options: DENYContent-Security-Policy: frame-ancestors 'none'frame-ancestors is the modern, more expressive replacement (it accepts a source list; X-Frame-Options only supports DENY and SAMEORIGIN). Send both while older browsers matter; where they conflict, CSP wins.
Referrer-Policy
Section titled “Referrer-Policy”Controls how much of the current URL goes out in the Referer header on navigations and subresource requests. URLs leak — paths and query strings routinely contain IDs, tokens and search terms.
Referrer-Policy: strict-origin-when-cross-origin| Value | Behaviour |
|---|---|
no-referrer |
Never send it. |
same-origin |
Full URL to same origin, nothing cross-origin. |
strict-origin |
Origin only, and nothing when downgrading HTTPS → HTTP. |
strict-origin-when-cross-origin |
Full URL same-origin, origin only cross-origin, nothing on downgrade. The modern browser default. |
unsafe-url |
Always the full URL. Do not. |
A reasonable baseline
Section titled “A reasonable baseline”Strict-Transport-Security: max-age=63072000; includeSubDomainsX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originContent-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'# Audit any site's headers in one commandcurl -sI https://example.com/ | grep -iE 'strict-transport|content-security|x-content-type|x-frame|referrer-policy'Mixed content
Section titled “Mixed content”An HTTPS page loading http:// subresources. The lock is a lie at that point — the plaintext resource can be read and modified in transit, and a modified script owns the page.
- Active mixed content (scripts, stylesheets, iframes,
fetch): blocked outright by all modern browsers. - Passive mixed content (images, audio, video): blocked or auto-upgraded, depending on the browser.
Fix by serving everything over HTTPS. As a transitional measure:
Content-Security-Policy: upgrade-insecure-requestswhich rewrites http:// subresource URLs to https:// before requesting them. It is a patch over broken markup, not a solution.
Cookies and CSRF
Section titled “Cookies and CSRF”Cross-Site Request Forgery exploits one behaviour: browsers attach cookies based on the destination, not on who initiated the request.
The attack:
<!-- Hosted on evil.com. The visitor is logged in to bank.com. --><form action="https://bank.com/transfer" method="POST"> <input type="hidden" name="to" value="attacker"> <input type="hidden" name="amount" value="10000"></form><script>document.forms[0].submit();</script>The browser sends the POST with the bank.com session cookie attached. The same-origin policy stops evil.com reading the response — and does not matter, because the transfer already happened.
Note what this needs: an ambient credential sent automatically. An Authorization: Bearer header set by JavaScript is not attached automatically, so token-in-header APIs are not CSRF-prone in the same way. Cookie-based auth is.
Defences
Section titled “Defences”1. SameSite cookies. Lax (the browser default when unspecified) blocks the cookie on cross-site form POSTs, fetch and iframes. This alone stops the attack above.
Set-Cookie: __Host-sid=abc123; Path=/; Secure; HttpOnly; SameSite=Lax2. Anti-CSRF tokens. A random per-session value the server embeds in the form and validates on submit. The attacker cannot read it (same-origin policy) so cannot include it.
<form action="/transfer" method="POST"> <input type="hidden" name="_csrf" value="7f3a9c1e..."></form>The stateless variant is the double-submit cookie: send the same random value as both a cookie and a form field, and require them to match — the attacker can cause the cookie to be sent but cannot read it to fill in the field.
3. Check Origin/Sec-Fetch-Site. For state-changing requests, reject anything whose Origin is not yours. Sec-Fetch-Site: cross-site is a browser-set header that says the same thing more directly. Neither can be forged by page JavaScript.
4. Never mutate state on GET. A GET /transfer?to=x endpoint is exploitable with a bare <img src>.
Key points
Section titled “Key points”- The same-origin policy blocks cross-origin reads, not cross-origin sends — which is exactly why CSRF exists.
- CORS is a browser-enforced relaxation, not an API access control; the request usually reaches your server regardless.
application/jsonand custom headers trigger a preflight; form-encoded POSTs do not.- With credentials you must echo an allowlisted origin — never reflect
Originblindly, and addVary: Origin. - CSP with a per-response nonce makes injected scripts inert;
'unsafe-inline'throws that away. - Set HSTS,
nosniff,frame-ancestorsand a strictReferrer-Policyas a baseline on every response. - CSRF is defeated by
SameSitecookies plus tokens, and by never changing state on GET.