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 Strict everywhere. Lax is the usual value; None requires Secure and 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.
  • includeSubDomains changes 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.com sends 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 Domain on subdomains you do not control
The specific failure: a session cookie scoped to .example.com is sent to staging.example.com and 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 500 or 404 served 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.
  • nosniff on 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-ancestors supersedes X-Frame-Options in 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.