HTTP Security Headers Explained: HSTS, CSP, and the Rest
July 18, 2026 · DevTools
You ship a perfectly working site, the login works, the API is locked down, the database is patched — and yet a single malicious iframe, a stray Content-Type sniffing bug, or a downgrade attack can still sink the whole thing. The good news: a small set of HTTP response headers closes most of those gaps. The bad news: most production sites still ship with weak defaults, or none at all.
This guide walks through each header that matters, what good values look like, and the common weak values that look correct but aren't. When you're ready to score your own site, the HTTP Security Header Checker grades your live response headers against this checklist.
What "security headers" actually do
Response headers are instructions the browser receives alongside your HTML, JS, and CSS. Most headers tell the browser how to render the page or negotiate caching. A small subset tell the browser what the page is and isn't allowed to do — and these are the security headers. They don't block anything on the server; they constrain the browser so an injected script or a hostile iframe can't do as much damage.
Before and after: an unhardened site
A freshly deployed site often looks like this in the response headers:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Server: nginx/1.27.0
Cache-Control: public, max-age=3600
That's it. No Strict-Transport-Security. No Content-Security-Policy. No X-Frame-Options. A vulnerability scanner will flag the page for almost every check it runs.
After a deliberate hardening pass, the same response looks like this:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
Cross-Origin-Opener-Policy: same-origin
Same content, very different attack surface.
Step 1: Force HTTPS with HSTS
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Strict-Transport-Security (HSTS) tells the browser: for the next two years, never speak HTTP to this domain — even if the user typed http:// or clicked a link that started with http://. The browser will silently upgrade to HTTPS.
The strong form has three pieces:
max-age=63072000— two years. Anything below six months is too easy to roll back accidentally.includeSubDomains— applies to every subdomain too. Without this, an attacker who ownsevil.app.example.comcan still downgrade the bare domain.preload— opts you into the browser-shipped HSTS preload list. Once submitted and accepted, your domain is HTTPS-only even on a fresh browser that has never seen your site.
The weak value: max-age=0 (or no header at all) leaves the door open to SSL-stripping attacks on the first visit.
Step 2: Lock down scripts with Content-Security-Policy
Content-Security-Policy: default-src 'self'; script-src 'self'; ...
CSP is the most powerful and the most painful header to set correctly. It tells the browser which sources of code, images, styles, and connections are allowed. Anything not on the list is blocked, even if an attacker managed to inject a <script> tag.
A reasonable starting policy for an app that serves its own assets:
default-src 'self'
script-src 'self'
style-src 'self' 'unsafe-inline'
img-src 'self' data:
connect-src 'self'
frame-ancestors 'none'
base-uri 'self'
form-action 'self'
default-src 'self'— deny everything by default; allow the same origin.script-src 'self'— only scripts served from your origin. No inline<script>, no third-party tags by default.style-src 'self' 'unsafe-inline'— same-origin plus inline styles, which many apps still rely on.img-src 'self' data:— same-origin plus inlinedata:URLs (for embedded images).connect-src 'self'— fetch/XHR/WebSocket only to your own origin.frame-ancestors 'none'— your page can't be framed at all. This is the modern replacement forX-Frame-Options.base-uri 'self'— prevents attackers from injecting a<base>tag to redirect relative URLs.form-action 'self'—<form>submissions can only target your own origin.
The weak values to avoid:
unsafe-inlineinscript-src— defeats the purpose of CSP for XSS; only acceptable during a migration.unsafe-eval— allowseval()andnew Function(), which XSS payloads love.*in any directive — undoes the lockdown.- No
frame-ancestors— your page can still be framed, clickjacked.
If you're not sure where to start, the CSP Builder generates a starter policy based on what your site actually loads.
Step 3: Stop MIME sniffing
X-Content-Type-Options: nosniff
This one is easy. It tells the browser: trust the Content-Type header I sent — do not guess. Without it, a file named evil.txt that actually contains JavaScript could be executed as a script by some browsers. With it, the browser refuses to deviate from the declared type.
There's only one valid value: nosniff. Anything else (or no header at all) is the weak value.
Step 4: Block clickjacking
X-Frame-Options: DENY
This header tells the browser your page may not be loaded inside a <frame>, <iframe>, <embed>, or <object>. The strong value is DENY. The slightly weaker SAMEORIGIN allows framing only by your own pages.
In modern browsers this is superseded by CSP's frame-ancestors directive, but X-Frame-Options is still respected and worth sending for older browsers. If you send both, they should agree.
The weak value: ALLOWALL (deprecated, treated as no header) or absent. The worst value: ALLOW-FROM https://example.com — also deprecated and ignored by Chrome and Firefox.
Step 5: Control referrer leakage
Referrer-Policy: strict-origin-when-cross-origin
The Referer header (yes, the spelling is wrong — it stuck) leaks the page the user was on when they clicked a link. For an internal admin URL or a search-results page with sensitive query parameters, that's a leak.
strict-origin-when-cross-origin is the right default for almost everyone:
- Same-origin requests: send the full URL.
- Cross-origin requests from HTTPS to HTTPS: send only the origin.
- Cross-origin requests from HTTPS to HTTP: send nothing (don't leak over a downgrade).
Weak values: unsafe-url (always send the full URL, including path and query, to anyone), no-referrer-when-downgrade (sends full URL over protocol downgrades, which leaks), or absent (browsers default to strict-origin-when-cross-origin now, but don't rely on it).
Step 6: Disable unused browser features
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
Permissions-Policy (formerly Feature-Policy) tells the browser which powerful features are off-limits to this page and any iframes. If your site has no business asking for the camera, the microphone, or geolocation, turn them off at the header level so an injected script can't quietly enable them.
Each directive takes an allowlist:
feature=()— nobody, including your own page.feature=(self)— your page only, not iframes.feature=(self "https://maps.example.com")— your page and the named iframe origin.
Common features worth disabling if you don't use them: camera, microphone, geolocation, payment, usb, bluetooth, accelerometer, gyroscope, magnetometer, interest-cohort (the FLoC opt-out).
Weak value: no Permissions-Policy header at all.
Step 7: Isolate browsing contexts
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
These three are the modern isolation trio:
COOP: same-origin— your window is isolated from other origins' windows; protects against cross-window attacks like Spectre side channels.COEP: require-corp— all subresources must explicitly opt in to being loaded; required if you want to useSharedArrayBufferor precise timers.CORP: same-origin— resources served by this page refuse to be loaded by other origins.
Not every site needs all three, but COOP: same-origin is a near-universal safe default.
Common pitfalls
- Sending the same header twice with different values. Browsers usually honor the stricter one, but it's a code smell.
- Setting
max-agein HSTS but notincludeSubDomains. A subdomain takeover defeats the policy. - CSP that allows
unsafe-inlineeverywhere "for now" and never gets tightened. Inline-allowing CSP is barely CSP. X-Frame-Options: SAMEORIGINwithoutframe-ancestors 'self'in CSP — modern browsers prefer CSP, but old ones won't, so send both.- Testing only the homepage. Static assets, the API, the admin panel, and error pages all need the same hardening.
- Forgetting about subdomains. HSTS and CSP only protect the host that sent the header.
Everything stays in your browser
The HTTP Security Header Checker runs 100% client-side. Paste a URL, it fetches the response headers (a server-side fetch from your browser, not a third-party proxy) and grades each one against the checklist above. Nothing leaves your machine.
Next: write the policy that fits your app
Scoring is the audit. Once you know what's missing, the next step is generating the actual headers your server should send. Start with a CSP tuned to what your app really loads using the CSP Builder, then re-run the HTTP Security Header Checker to confirm the score is where you want it.