HTTP Security Headers Guide — Complete Reference
What is this?
HTTP security headers are response headers the browser reads and obeys as policy. They do not sit on the wire encrypting traffic — that is TLS's job. They do not filter requests — that is your WAF's job. They are instructions to the client saying: "when you render this response, enforce these rules, and refuse to do things the rules forbid."
Browsers respect them because the HTML and Fetch specifications say so. Every current Chrome, Firefox, Safari, and Edge build ships the same enforcement behavior for the headers listed in this guide. When you set Strict-Transport-Security, every modern browser will refuse to downgrade to HTTP for the duration you specified, even if an attacker strips TLS on a coffee-shop network. When you set Content-Security-Policy with script-src 'self', no browser will execute a script injected through an XSS payload from evil.example.com.
This is defense in depth. You still need input validation, output encoding, parameterised queries, and a patched server. Security headers are the layer that catches what leaks past the others — and, equally important, the layer auditors grade first because they are trivial to measure. If a penetration tester loads your site and the Network tab is empty of security headers, the report's executive summary writes itself.
This guide is the pillar. It teaches what each header does, what value to send, how to roll it out without breaking production, and how to configure it on every common platform. When you are ready to verify, test your headers with the BeaverCheck Security Headers Checker — it grades all the headers covered here and surfaces the exact directive that is missing or weak.
The 8 headers that matter
| Header | What it prevents | Tier |
|---|---|---|
| Strict-Transport-Security | TLS stripping, protocol downgrade | Must Have |
| Content-Security-Policy | XSS, data injection, clickjacking | Must Have |
| X-Content-Type-Options | MIME sniffing attacks | Must Have |
| Referrer-Policy | Leaking URLs to third parties | Must Have |
| Permissions-Policy | Rogue access to camera, mic, geolocation | Should Have |
| X-Frame-Options / frame-ancestors | Clickjacking via iframe | Should Have |
| Cross-Origin-Opener-Policy | Cross-origin window tampering, Spectre | Should Have |
| Cross-Origin-Embedder-Policy | Loading cross-origin resources without opt-in | Nice To Have |
Anything that grades A on a headers checker sets all eight. Anything that grades F is missing Content-Security-Policy and Strict-Transport-Security.
Content-Security-Policy (CSP)
Cross-site scripting is the vulnerability where an attacker gets the browser to execute JavaScript under your origin. Every stolen session cookie, every silent form rewrite, every fake login prompt on a genuine domain traces back to XSS. A Content-Security-Policy tells the browser which script sources are legitimate and refuses to run anything else — including inline scripts the attacker managed to inject into your HTML.
A starter policy that actually works for a modern web app:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
That policy blocks all inline script execution, restricts script, style, image, font, and XHR/fetch origins to your own domain, refuses to be framed by anyone, and kills the <object> tag entirely. It is strict enough to stop reflected XSS and loose enough that most sites can ship it after externalising their inline scripts.
Deploy it under Content-Security-Policy-Report-Only first. The browser reports every violation to a report-uri or report-to endpoint you control, but enforces nothing. Real traffic generates real reports. Some reports expose legitimate inline scripts you forgot about (analytics snippets, ad tags, payment widgets). Some expose genuine XSS attempts. Triage both, fix the legitimate ones by adding nonces or moving scripts to files, then flip the header name to Content-Security-Policy for enforcement.
The gotchas:
- Inline scripts.
<script>doThing()</script>is blocked byscript-src 'self'. Either move the script to a file, or add a nonce:<script nonce="abc123">and include'nonce-abc123'in your policy. The nonce must be random per response and unguessable. - eval and new Function. Blocked by default. If you are stuck with a library that requires them, add
'unsafe-eval'— but know that this undoes a large chunk of CSP's protection. Find a replacement library first. - Nonces vs hashes. Nonces are easier for server-rendered pages; you generate one per response and inject it into both the header and the script tag. Hashes are better for static sites; you precompute
sha256-...of the script content and list it in the policy. Do not mix the two for a single script.
Platform configs (test yours):
# Nginx
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" always;
# Apache
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
# Caddy (Caddyfile)
header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
// Cloudflare Worker
addEventListener('fetch', event => {
event.respondWith(handle(event.request));
});
async function handle(request) {
const response = await fetch(request);
const headers = new Headers(response.headers);
headers.set('Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; font-src 'self'; connect-src 'self'; " +
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'");
return new Response(response.body, { status: response.status, headers });
}
Strict-Transport-Security (HSTS)
HTTPS only protects the user if the browser actually uses it. Without HSTS, the first request to example.com is plain HTTP — an attacker on the same Wi-Fi can intercept it, serve a fake login page over HTTP, and harvest credentials before any TLS handshake happens. This is the SSL strip attack.
HSTS tells the browser: "for the next N seconds, never connect to this origin over anything but HTTPS, and do not let the user click through a certificate warning." Once the header is cached, the browser upgrades every request to HTTPS locally, before any network packet leaves the device.
The safe rollout path, in order:
- Serve HTTPS everywhere first. Every subdomain, every path, every asset. Redirect HTTP to HTTPS at the edge. If one internal tool still serves HTTP, fix that before continuing.
- max-age=300 (five minutes), no includeSubDomains, no preload. Deploy and monitor. If something breaks, you have a five-minute blast radius.
- max-age=86400 (one day), then max-age=31536000 (one year). Step up as you verify.
- Add
includeSubDomains. Only after you have confirmed that every subdomain —api.,cdn.,mail.,staging., the one the marketing team set up in 2019 — supports HTTPS. - Add
preloadand submit to hstspreload.org. Now you are hard-coded into every major browser's source tree.
The preload step is effectively permanent. Removal takes six to twelve months to propagate to stable Chrome, and other browsers follow on their own timelines. If you preload a domain and then discover a subdomain that cannot do HTTPS, you cannot serve it until removal completes. Preload last, and only when you are certain.
Final production header:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Platform configs (test yours):
# Nginx
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Apache
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# Caddy (Caddyfile) — Caddy sets HSTS automatically on HTTPS sites, but to customise:
header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
// Cloudflare: Dashboard > SSL/TLS > Edge Certificates > HSTS, or via Transform Rule:
// Rules > Transform Rules > Modify Response Header > Set static
// Header name: Strict-Transport-Security
// Value: max-age=31536000; includeSubDomains; preload
X-Frame-Options and frame-ancestors
Clickjacking works by loading your site in a transparent iframe on an attacker-controlled page, overlaying fake UI, and tricking the user into clicking through to a real action on your domain. X-Frame-Options and CSP's frame-ancestors directive both tell the browser who is allowed to frame your page.
CSP frame-ancestors supersedes X-Frame-Options in every modern browser. A CSP with frame-ancestors 'none' is obeyed even if X-Frame-Options is absent or contradicts it. So the modern approach is:
- Put
frame-ancestors 'none'(or'self', or a specific origin list) in your Content-Security-Policy. - Keep X-Frame-Options: DENY or SAMEORIGIN for defense in depth and for crawlers, scanners, and scripted clients that only check the legacy header.
When to pick which value:
- DENY /
frame-ancestors 'none'— the default for any page that is not deliberately designed to be embedded. Dashboards, auth flows, account pages, checkout. - SAMEORIGIN /
frame-ancestors 'self'— for apps that embed their own pages in iframes (some admin consoles, documentation tools). - Specific ancestors via CSP — when a partner or parent app genuinely needs to embed you. X-Frame-Options cannot express "allow partner.example.com", so CSP is the only correct answer here. Use
frame-ancestors https://partner.example.com.
Platform configs (test yours):
# Nginx — set both for defense in depth
add_header X-Frame-Options "DENY" always;
# frame-ancestors goes in the Content-Security-Policy header (see CSP section)
# Apache
Header always set X-Frame-Options "DENY"
header X-Frame-Options "DENY"
Referrer-Policy
The default browser behavior is to send the full URL of the previous page in the Referer header when the user navigates away. That leaks information: search queries, session tokens in URLs (a bad pattern you might not control everywhere), internal page paths, customer IDs. A third-party analytics script on the destination page will log all of it.
Set Referrer-Policy: strict-origin-when-cross-origin as your default. This sends the full URL when the user navigates within your origin (useful for your own analytics), the origin only (scheme, host, port — no path or query) when navigating to another HTTPS origin, and nothing at all when navigating from HTTPS to HTTP. That is the exact policy Chrome, Firefox, and Safari now use as their default, but setting it explicitly both documents the intent and catches older browsers.
If you need stricter behavior — for example, a banking app where even the origin is sensitive — use no-referrer. Avoid unsafe-url entirely; it sends the full URL everywhere, including downgrade navigations.
Platform configs (test yours):
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Header always set Referrer-Policy "strict-origin-when-cross-origin"
header Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy
Permissions-Policy (the successor to Feature-Policy, which browsers no longer honor) lets you turn off browser features on pages that do not need them. If your app never uses the camera, a compromised third-party script cannot ask for camera access, because the browser has already been told the page is not allowed to.
The right approach is allow-list: deny everything, then explicitly grant the features you actually use. A starter value for a site that uses none of the sensitive APIs:
Permissions-Policy: accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), display-capture=(), document-domain=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), serial=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()
An empty parenthesis () means "allowed for no origins" — fully disabled. If you need a feature, allow it for self: camera=(self). If a trusted third-party iframe needs a feature, list it: payment=(self "https://checkout.example.com").
Keep the list current. Browsers add new features yearly, and each one defaults to "allowed" unless you restrict it. Revisit your policy at least once a release cycle.
Platform configs (test yours):
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)" always;
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)"
X-Content-Type-Options: nosniff
Browsers historically tried to be helpful by sniffing the first few bytes of a response and overriding the declared Content-Type if they thought the server got it wrong. An attacker who can upload a file — say, an avatar image — can exploit this by uploading an HTML document with a .jpg extension. The server serves it as image/jpeg; the browser sniffs it, decides it looks like HTML, renders it as a page in your origin's context, and the attacker gets XSS.
X-Content-Type-Options: nosniff disables the sniffing. The browser uses the Content-Type you declared and treats mismatches as errors. There is no downside to setting this header on every response from every origin. Set it once, forget it.
add_header X-Content-Type-Options "nosniff" always;
Header always set X-Content-Type-Options "nosniff"
header X-Content-Type-Options "nosniff"
Cross-Origin-Embedder-Policy and Cross-Origin-Opener-Policy (COEP/COOP)
These two headers unlock cross-origin isolation, a security state the browser enters when it can guarantee that your document cannot share memory with anything it did not explicitly agree to share with. In the isolated state, the browser re-enables two features that were disabled after Spectre: SharedArrayBuffer and high-resolution performance.now() timers. It also shuts off an entire class of cross-origin side-channel attacks.
Cross-Origin-Opener-Policy controls the relationship between your top-level window and windows opened via window.open() or target="_blank". same-origin means any window you open must be same-origin, and references to it will be severed (window.opener becomes null) if it navigates cross-origin. This prevents an opened cross-origin page from reaching back through window.opener and tampering with your document.
Cross-Origin-Embedder-Policy controls what your page can embed. require-corp means every cross-origin subresource — image, script, iframe — must explicitly opt in by serving Cross-Origin-Resource-Policy: cross-origin (or the older CORS headers). If even one resource has not opted in, the browser blocks it.
You need both to achieve isolation:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Do you need them? If you ship JavaScript that uses SharedArrayBuffer (some WASM apps, some video processing libraries, some collaborative editors), yes. If you ship a site that pops up third-party payment windows or social-login windows, you probably want at least Cross-Origin-Opener-Policy: same-origin-allow-popups to protect against opener-based attacks without breaking the popups. Everyone else can set COOP same-origin for free defense and skip COEP until a feature requires it.
The trap with COEP is that it breaks cross-origin images and scripts that do not serve CORP. Staging it behind Cross-Origin-Embedder-Policy-Report-Only first is the same pattern as CSP rollout — log violations, fix or replace the offending subresources, then flip to enforcement.
How to fix it
Configure headers in Nginx
Add to your server block, or to an include file that every server block references:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
The always flag is critical — without it, Nginx only adds the header for 2xx and 3xx responses, so your 404 and 500 pages ship unprotected. Reload with nginx -t && nginx -s reload.
Configure headers in Apache
Add to .htaccess or to a virtual host block. mod_headers must be enabled: a2enmod headers.
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)"
Header always set Cross-Origin-Opener-Policy "same-origin"
Reload with apachectl configtest && apachectl graceful.
Configure headers in Caddy
Caddy sets a conservative HSTS by default on any HTTPS site. To apply the full set, add a header block in your Caddyfile:
example.com {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)"
Cross-Origin-Opener-Policy "same-origin"
}
reverse_proxy localhost:8080
}
Reload with caddy reload --config /etc/caddy/Caddyfile.
Configure headers in Cloudflare (Workers or Transform Rules)
The quickest path is Rules > Transform Rules > Modify Response Header. Create one rule per header, set the "When incoming requests match" to hostname equals example.com, and "Then" to "Set static" with the header name and value.
For programmatic control, use a Worker:
const securityHeaders = {
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload',
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'",
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)',
'Cross-Origin-Opener-Policy': 'same-origin',
};
export default {
async fetch(request, env, ctx) {
const response = await fetch(request);
const headers = new Headers(response.headers);
for (const [name, value] of Object.entries(securityHeaders)) {
headers.set(name, value);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
},
};
Configure headers in Express.js with Helmet
Helmet is the de facto security middleware for Express and is maintained by the OWASP community. The defaults are close to the recommended policy; override only what you need.
const express = require('express');
const helmet = require('helmet');
const app = express();
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
fontSrc: ["'self'"],
connectSrc: ["'self'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
objectSrc: ["'none'"],
},
},
strictTransportSecurity: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
crossOriginOpenerPolicy: { policy: 'same-origin' },
}));
// Permissions-Policy is not yet in Helmet's defaults; set manually:
app.use((req, res, next) => {
res.setHeader('Permissions-Policy',
'camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)');
next();
});
Configure headers in Django
Django's SecurityMiddleware handles HSTS, nosniff, and referrer policy. CSP needs django-csp. Permissions-Policy needs django-permissions-policy.
# settings.py
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'csp.middleware.CSPMiddleware',
'django_permissions_policy.PermissionsPolicyMiddleware',
# ... rest of your middleware
]
# HSTS
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# X-Content-Type-Options: nosniff
SECURE_CONTENT_TYPE_NOSNIFF = True
# Referrer-Policy
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
# X-Frame-Options (Django sends this by default as SAMEORIGIN)
X_FRAME_OPTIONS = 'DENY'
# CSP (django-csp)
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'",)
CSP_STYLE_SRC = ("'self'", "'unsafe-inline'")
CSP_IMG_SRC = ("'self'", "data:", "https:")
CSP_FRAME_ANCESTORS = ("'none'",)
CSP_OBJECT_SRC = ("'none'",)
CSP_BASE_URI = ("'self'",)
CSP_FORM_ACTION = ("'self'",)
# Permissions-Policy (django-permissions-policy)
PERMISSIONS_POLICY = {
'camera': [],
'microphone': [],
'geolocation': [],
'payment': [],
'usb': [],
'fullscreen': ['self'],
}
# Cross-Origin-Opener-Policy (Django 4.2+)
SECURE_CROSS_ORIGIN_OPENER_POLICY = 'same-origin'
Verify headers with the BeaverCheck Security Headers Checker
Once deployed, run your URL through the BeaverCheck Security Headers Checker. The tool fetches your page over a SSRF-safe transport, parses every response header, grades each security-relevant header against the recommendations in this guide, and flags any that are missing, weak, or misconfigured. Unlike single-purpose checkers, the grade plugs into a full BeaverCheck audit — so you also see TLS configuration, cookie flags, mixed content, and every other signal the auditor will look at.
Common audit failures and their fixes
| Finding (as it typically appears in an audit) | Header that fixes it |
|---|---|
| "HTTP Strict Transport Security not enforced" | Strict-Transport-Security: max-age=31536000; includeSubDomains |
| "Content Security Policy not present or allows unsafe-inline" | Content-Security-Policy with no 'unsafe-inline' in script-src |
| "Clickjacking protection missing" | X-Frame-Options: DENY and/or CSP frame-ancestors 'none' |
| "MIME type sniffing permitted" | X-Content-Type-Options: nosniff |
| "Referrer information leaking to third parties" | Referrer-Policy: strict-origin-when-cross-origin |
| "Sensitive browser features not restricted" | Permissions-Policy with unused features set to () |
| "Cross-origin isolation not enforced" | Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp |
| "Legacy XSS filter enabled" | X-XSS-Protection: 0 (or header removed) |
| "Server version disclosed" | Remove Server and X-Powered-By headers (not a security header, but always flagged) |
| "Cookies lack Secure and HttpOnly flags" | Set-Cookie directives, not a separate header — covered in your session middleware |
A clean run here is what the executive summary of a pentest report calls "no findings on HTTP response security controls." That is the goal.
Further reading
- OWASP Secure Headers Project — the canonical reference list, maintained by the same community that publishes the OWASP Top 10.
- MDN: HTTP headers — per-header documentation with browser compatibility tables.
- Scott Helme — Hardening your HTTP response headers — the long-running blog post that started most teams down this path.
- W3C Content Security Policy Level 3 — the authoritative CSP specification.
- Chrome HSTS Preload List submission — submission, removal, and the current requirements for inclusion.