Skip to content

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 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

The policy governs reading, not sending. This distinction causes most of the confusion.

Blocked (cross-origin reads):

  • Reading the response body of a fetch/XMLHttpRequest without CORS permission.
  • Reading the DOM or contentWindow of a cross-origin <iframe>.
  • Reading pixels from a cross-origin image drawn onto a <canvas> (it becomes “tainted”).
  • Reading cookies, localStorage or 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 fetch with mode: '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:

  1. 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.
  2. 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 DELETE may well have deleted something.

A request avoids preflight only if all of these hold:

  • Method is GET, HEAD or POST.
  • Headers are limited to CORS-safelisted ones: Accept, Accept-Language, Content-Language, Content-Type, plus a few others. Notably no Authorization and no custom headers.
  • If Content-Type is present, it is one of application/x-www-form-urlencoded, multipart/form-data, or text/plain. application/json does not qualify.

The browser sends the request with an Origin header and inspects the response:

Terminal window
curl -v https://api.example.com/items -H 'Origin: https://app.example.com'
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json

If 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.

Anything else triggers an automatic OPTIONS request first, asking permission.

Terminal window
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 Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

Only 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).

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.

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.com
Access-Control-Allow-Credentials: true
Terminal window
curl -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.

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'
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'.

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.

Deploy in report-only mode first. The browser reports violations without blocking anything:

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-reports

Collect reports, fix the legitimate ones, then switch to the enforcing header.

Terminal window
# What does a site currently send?
curl -sI https://example.com/ | grep -i 'content-security-policy'

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; preload
X-Content-Type-Options: nosniff

Stops 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.

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: DENY
Content-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.

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.
Strict-Transport-Security: max-age=63072000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
Terminal window
# Audit any site's headers in one command
curl -sI https://example.com/ | grep -iE 'strict-transport|content-security|x-content-type|x-frame|referrer-policy'

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-requests

which rewrites http:// subresource URLs to https:// before requesting them. It is a patch over broken markup, not a solution.

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.

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=Lax

2. 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>.

  • 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/json and custom headers trigger a preflight; form-encoded POSTs do not.
  • With credentials you must echo an allowlisted origin — never reflect Origin blindly, and add Vary: Origin.
  • CSP with a per-response nonce makes injected scripts inert; 'unsafe-inline' throws that away.
  • Set HSTS, nosniff, frame-ancestors and a strict Referrer-Policy as a baseline on every response.
  • CSRF is defeated by SameSite cookies plus tokens, and by never changing state on GET.