Guide
Security headers explained: HSTS, CSP, cookies and common mistakes
The hard case in this layer is not a missing header. It is a header that is present, correct-looking, and enforcing nothing — and the only reliable way to tell is to read the response rather than the configuration file.
Most of the HTTP headers your server returns are instructions to the browser rather than information about the response. A small set of them tells the browser what it is permitted to do with what it just received, and that set is unusually valuable for three reasons: it costs almost nothing to set, it is entirely visible from outside, and a mistake in it usually produces a policy that looks correct in a header dump and changes nothing in a browser.
This guide covers what each header prevents, what configuration makes it ineffective, and how to verify the result.
The headers, and what each one prevents
Four headers cover most of the available ground. Everything else is worth setting, but these four are the ones whose absence is a genuine risk.
Strict-Transport-Security
Prevents: the first request of a visit being made over plain HTTP.
This is the only protection against a downgrade, and it is worth
understanding why. Without HSTS, a browser connecting to your site for the
first time has no way to know it should be encrypted. It asks for
http://, the network can respond, and everything after that —
including the Set-Cookie — is visible to whoever is in the path.
Your visitor sees a padlock, because the subsequent request was
encrypted, and none of that matters.
HSTS tells the browser to go straight to HTTPS for this host from now on, for a stated number of seconds. The browser honours it before contacting you, so there is no first request to attack.
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
A redirect from HTTP to HTTPS is not a substitute and does not conflict: the redirect serves visitors whose browser has not yet stored the policy, and HSTS covers the ones that have.
Content-Security-Policy
Prevents: injected script running in your origin.
CSP is the only broadly effective control against cross-site scripting, because it does not try to recognise a malicious payload. It states which sources of script, style, frame and connection are permitted, and the browser refuses everything else. A script that gets injected into a page with a strict policy has nowhere to load from and does not execute.
It is also the header most likely to break an application if deployed carelessly, which is why the mistakes section below is longer than the benefits section.
Content-Security-Policy: default-src 'self'; base-uri 'none'; object-src 'none'; script-src 'nonce-{RANDOM}' 'strict-dynamic'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'
Reading it: default everything to your own origin; forbid
base-uri and object-src outright; allow scripts only
by nonce, with strict-dynamic so scripts loaded by an allowed
script are allowed too; allow only your own styles, images and connections;
confine form posts to your own origin; and forbid framing outright.
The two directives worth knowing by name: base-uri 'none'
stops an injected <base> tag from redirecting every relative
URL on the page to an attacker's host, and form-action stops a
phishing form being injected into your page and posting your users somewhere
else. Both are cheap and both are usually missing.
X-Content-Type-Options
Prevents: a browser treating a non-HTML response as HTML because the server never said not to.
X-Content-Type-Options: nosniff
This one line stops a whole class of problems — a JSON file served as
text/plain and rendered when navigated to directly, a
user-uploaded file sniffed into something executable. It costs nothing and has
no realistic downside, which makes its absence one of the most inexplicable
findings in a report.
Set-Cookie attributes
Prevent: a stolen session cookie being useful to whoever stole it.
A cookie is a bearer token. Anyone holding the value can present it as you. Three attributes decide how much damage that is worth:
Secure- The cookie is only sent over HTTPS. Without it, a session cookie is readable by anyone on an unencrypted hop. This is the attribute to set first; it is also the one people skip when a site has an HTTP path they have not finished removing.
HttpOnly- The cookie is invisible to JavaScript. Without it, any script running on your pages — including injected script — can read the session cookie and send it anywhere. This is what converts a cross-site scripting flaw from "an annoying pop-up" into full account takeover, and it is the single most consequential attribute in the list.
SameSite- The cookie is not sent on cross-site requests. Set it explicitly rather
than relying on the browser default, which has varied across versions and is
not the same as
Stricteverywhere.Laxis the usual value;NonerequiresSecureand exists for genuine cross-site cases.
The __Host- and __Secure- cookie prefixes are a
stricter way to express the first of those: a cookie named
__Host- is only accepted by the browser if it is
Secure, has no Domain attribute, and has
Path=/. The browser enforces it, so a misconfiguration cannot
quietly drop the protection.
The remaining headers — X-Frame-Options and CSP
frame-ancestors for clickjacking, Referrer-Policy,
Permissions-Policy, the cross-origin policies, and cache control
on per-user responses — are covered on the
security headers checker page.
HSTS, and the difference from a redirect
Worth separating, because they are routinely confused.
An HTTP-to-HTTPS redirect is a server response. The browser has
already sent a request in clear text to receive it. On that first request, the
Set-Cookie in the redirect response is exposed.
HSTS is a client-side instruction cached before any request. Once stored, the browser goes straight to HTTPS for the host, so there is no unprotected first request at all.
Three configuration details that decide whether it works:
- max-age has to be long. A few seconds protects nothing; it is only read once and then forgotten. A year is the minimum worth having, and two is common.
- It must only be sent over HTTPS. A browser ignores the header on an insecure response, which is deliberate: otherwise anybody could pre-seed the policy for your site.
includeSubDomainschanges scope. It applies the policy to every subdomain, which is right if you can guarantee they are all HTTPS-capable. If any subdomain is not, a browser that has stored the policy will refuse to connect to it at all — which is a site outage, and the reason to check that every subdomain is HTTPS before turning this on rather than after.
The preload token is a separate opt-in: it submits your domain
to the browser vendors' preload lists so the policy applies on a first-ever
visit. The submission requirements are strict, and the commonest way to end up
listed is to satisfy them and then keep satisfying them.
CSP, and why a wide policy is a wide policy
The trap with CSP is that it is easy to add a policy that looks
comprehensive and permits everything. A permissive policy is weaker than a
strict one, and no stronger than having no policy at all: the browser still
enforces what is written, so the parts you did tighten —
object-src, base-uri, frame-ancestors —
are still doing their job. What a wide script-src gives up is the
restriction on where script may come from and whether it may be inline, which
is the restriction that matters most if markup can be injected into the
page.
Configurations that give the script directive little or nothing to enforce:
# Permits inline script, so script-src places no restriction on where
# script comes from. The rest of the policy still applies.
script-src 'unsafe-inline'
# Carries a nonce the browser will honour, so the inline permission is
# ignored in browsers implementing CSP Level 3 - and comes back the moment
# the nonce is dropped. Reported as hygiene, not as a granted permission.
script-src 'nonce-abc' 'unsafe-inline'
# Permits script from any https origin.
script-src https: *.example.com
# Permits JavaScript reached through a string - eval, new Function, a string
# body passed to setTimeout. Whether that matters depends on whether an
# attacker-influenced string ever reaches one of them.
script-src 'unsafe-eval'
The nonce value must be unpredictable and different for every response. A
static nonce in a configuration file is not a nonce; it is a constant that an
injected script can read out of the page and reuse. This is why the
non-interactive modern form is strict-dynamic: the server picks
the single nonce per response, and scripts loaded by an allowed script
are allowed without needing their own nonces.
Deploy it in report-only mode first.
Content-Security-Policy-Report-Only: default-src 'self'; …
The browser enforces nothing and reports every violation. That is how you find out what your application actually loads — third-party scripts, inline styles, analytics endpoints, a font CDN you forgot — without breaking anything for a user. Moving to an enforcing policy after a report-only week is a configuration change; deploying a guessed policy to production is an incident.
A practical starting point, if you have no existing policy: set
default-src 'self', add base-uri 'none' and
object-src 'none', and leave script-src permissive
for now. That is a small real improvement over nothing, and it is worth having
while you work out the script sources.
Cookie attributes that carry security weight
Beyond Secure, HttpOnly and
SameSite:
Path- Scope.
Path=/is right for a session cookie. A narrower path reduces the number of requests the cookie rides on, which is mostly a performance question.
Domain- Scope, and a risk if set. Omitting it confines the cookie to the exact
host that set it. Setting it to
.example.comsends it to every subdomain — so a compromised or forgotten subdomain receives everyone's session cookie. The__Host-prefix forbids the attribute entirely.
Max-Age/Expires- Lifetime. A session cookie with neither is a browser-session cookie, which is often what you want. An explicit long lifetime on a privileged cookie extends the window in which a stolen copy is usable.
- Not
Domainon subdomains you do not control - The specific failure: a session cookie scoped to
.example.comis sent tostaging.example.comand to every other subdomain. If any of those can be influenced by someone else, they can read it. This is a common finding on estates with a large subdomain count.
Mistakes that make a strong header useless
This is the part worth re-reading. All of these produce a response that looks correctly configured in a header dump.
- CSP with
'unsafe-inline'. The header is present and the browser does enforce it, but with no host allow-list beside it,script-src 'unsafe-inline'restricts neither where script may come from nor whether it may be inline. The rest of the policy is still doing its job. 'unsafe-inline'next to a nonce or hash. The behaviour here runs the other way round from most people's first instinct. In browsers implementing CSP Level 3, a nonce or hash source expression in the same directive causes'unsafe-inline'to be ignored — the strict expression wins, not the permissive keyword. That is why it is reported as hygiene rather than as a granted permission: the clause starts mattering again the moment the nonce or hash is removed, or in an older browser that honours the keyword regardless. A nonce is not "defeated" by'unsafe-inline'; it is overridden by it.- A static CSP nonce. The policy looks exactly right. The nonce is the same on every response for the lifetime of the deploy, which makes it a constant an injected script can read.
- HSTS with a short max-age. A visitor who arrives on plain HTTP, gets the redirect, and then leaves the tab open past the expiry is unprotected again — silently.
- Session cookie without
HttpOnly. One scripting flaw becomes an account takeover, and nothing in a header audit suggests the two are related. - Headers on the HTML page only. A
500or404served by a different handler frequently carries none of them, and those are the responses an attacker asks for. Check an error response, not just the homepage. nosniffon the page, not on your uploads. The value of the header is entirely on responses serving content types the browser might guess at.- Framing control on one header only. CSP
frame-ancestorssupersedesX-Frame-Optionsin modern browsers; older ones read the other. Setting one and not the other means the protection depends on the visitor's browser. - A policy applied in the wrong place. A CSP set by a reverse proxy can be overwritten by an application that also sets one. Whatever sends last wins, so check the actual response rather than the configuration file.
Verifying from the outside
Check the response you actually get, rather than the configuration you believe you have:
curl -sSI https://example.com | grep -iE 'strict-transport|content-security|x-content-type|x-frame|referrer-policy|permissions-policy|set-cookie'
Then check the responses that are easy to forget — a 404, a redirect, a path served by a different application, a static asset:
curl -sSI https://example.com/does-not-exist
curl -sSI https://example.com/redirected-path
The security headers checker does
this across a site, and grades policy quality separately from presence — which
is the distinction the mistakes above are all about. A response whose CSP
carries 'unsafe-inline' in a script directive is reported as a
finding, and the header's presence is still recorded alongside it, worded as
an observation rather than as a verified control. The keyword is read in the
directive that governs scripts: 'unsafe-inline' in
style-src is a style permission and is not reported as a script
weakness.