Real compromises rarely start with something exotic. They start with a cookie missing an attribute, an error page naming the framework and its version, or a backup file that was never meant to be reachable and has been for two years.
This layer is the cheapest security work available. Most of it is configuration, not code, and a lot of it can be fixed in an afternoon. It is also the layer that every future auditor, customer security questionnaire and procurement review will check before anything else — so closing it once removes it from all of them.
The headers, in priority order
| Header | Value to aim for | What it stops |
|---|---|---|
Content-Security-Policy | nonce + 'strict-dynamic' + default-src 'self' | An injected script from doing anything useful |
Strict-Transport-Security | max-age=31536000; includeSubDomains | Downgrade to plaintext after the first visit |
X-Content-Type-Options | nosniff | The browser guessing a type and executing an upload |
Referrer-Policy | strict-origin-when-cross-origin | Leaking full URLs, including tokens, to third parties |
Permissions-Policy | Deny what you do not use | Camera, microphone, geolocation and payment access |
X-Frame-Options | DENY | Clickjacking in browsers predating CSP frame-ancestors |
Cross-Origin-Opener-Policy | same-origin | Cross-window scripting attacks and some side channels |
Content-Security-Policy
The most valuable and the most work, so it gets its own article: a Content-Security-Policy that actually works.
The short version. A policy without script-src and without default-src restricts nothing that matters, no matter how many other directives it carries. Use a per-request nonce with 'strict-dynamic', deploy in report-only mode first, and set frame-ancestors, base-uri, form-action and object-src explicitly — default-src does not cover those.
Strict-Transport-Security
Strict-Transport-Security: max-age=31536000; includeSubDomainsHSTS, defined in RFC 6797, tells the browser to refuse plaintext HTTP to your host for the stated period. 31536000 seconds is one year and is the accepted value.
The catch is in the name of the mechanism: strict. Once a browser has seen this header it will not talk to your host over HTTP until the max-age expires, and you cannot revoke that remotely. Confirm every subdomain genuinely serves HTTPS before adding includeSubDomains. Adding preload is stronger again and considerably harder to undo — treat it as a decision, not a default.
X-Content-Type-Options
X-Content-Type-Options: nosniffWithout it, browsers may ignore your declared Content-Type and infer one from the bytes. That turns a user-uploaded file which merely looks like script into script.
One value, no configuration, no downside. Send it on API responses too, not just HTML — it is routinely missing there because people add headers at the page layer and forget the JSON.
Referrer-Policy
Referrer-Policy: strict-origin-when-cross-originControls how much of the current URL is sent when a user follows a link away from your site. The default behaviour on many setups sends the full path and query string to the destination.
That matters more than it sounds. Password reset links, invitation tokens and session identifiers all live in URLs on plenty of real applications. This value sends the origin only when crossing to another site, and nothing at all when downgrading to HTTP.
Permissions-Policy
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()The empty parentheses mean "nobody, including this document". Deny every capability you do not actively use. If an injection ever lands on your page, this is what stops it from quietly asking for the microphone.
Two headers to remove
X-XSS-Protection
X-XSS-Protection: 1; mode=block ← delete thisThis is still recommended by a surprising number of blog posts and still shipped by a surprising number of frameworks. The auditors are behind on it too, which is how it survives.
It controlled a browser XSS filter that no longer exists in any current browser. Worse, that filter introduced its own vulnerability class, where an attacker could use it to selectively break parts of a page. Remove the header. Content-Security-Policy is the replacement, and it is not close.
Anything naming your software version
Server: nginx/1.24.0 and X-Powered-By: PHP/8.1.2 hand an attacker your patch level for free. Naming the product without the version is a much smaller matter — Via: 1.1 Caddy is untidy rather than dangerous — but the version number should go.
While you are there, check for internal routing headers. Frameworks sometimes emit things like an X-Pathname echoing the resolved internal route. Harmless in development, and it should not be leaving your edge in production.
Cookies: the part everybody forgets
Headers get the attention and cookies get missed. Every cookie your application issues should carry:
- `Secure` — never sent over plaintext. There is no cookie that benefits from omitting this, including the ones holding nothing sensitive.
- `HttpOnly` — unreadable from JavaScript. Mandatory for session cookies. The exception is a cookie the front end genuinely has to read, such as a locale preference, and that exception should be written down as deliberate.
- `SameSite=Lax` — not sent on cross-site requests, which removes most CSRF exposure. Use
Strictwhere the user experience allows it.
Set-Cookie: session=...; Path=/; Secure; HttpOnly; SameSite=LaxBeyond headers
The same configuration pass should cover four more things, because they fail in the same way and get found by the same people.
- 1HTTP methods. Unsupported methods should be refused with 405 at the edge rather than handed to application code. A
TRACErequest answered with a 500 means it reached your application, which is not where it should have got to. - 2Exposed files. Check
/.env,/.git/config,/.git/HEAD,package.json,docker-compose.yml, and.bak.sql.zipvariants of your config files. Check whether JavaScript source maps are served. All should return 404. - 3Error behaviour. Send malformed JSON and a deliberately wrong content type. You want a typed error and nothing else. No stack trace, no framework internals, no version string.
- 4A `security.txt`. Publish one at
/.well-known/security.txtto RFC 9116, with a monitored address and anExpiresdate kept in the future. Without it, a researcher who finds a problem has to guess, and "guess" often means a public tweet.
Where to apply the fix
One thing worth getting right on the first pass. If you run more than one site or environment, these belong in shared edge configuration rather than in each site's own block.
Set them per site and they drift. One host gets the new policy, another does not, and six months later you have two different security postures and no way to tell which is which without checking each one by hand.
Security & Configuration Audit
Security audit services covering TLS, HTTP security headers, cookies, Content-Security-Policy, HTTP methods, exposed files and error handling, measured against current practice. Free consultation.
See how we run it →