A modern browser is willing to enforce a lot of security policies for you, but only if your server tells it to. The way the server tells it is through HTTP response headers. Set the headers, the browser blocks attacks. Forget them, the browser allows the attack and you find out from a customer.

There are six headers worth knowing well. We will go through each one with what it does, what breaks if you set it wrong, and a sane default.

1. Strict-Transport-Security (HSTS)

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload

Tells the browser "from now on, never connect to this domain over plain HTTP. If the URL says http://, rewrite it to https:// before sending the request, and don't even ask me." Once the browser has seen this header once, it remembers for max-age seconds (here: 2 years).

This prevents a class of attacks where somebody on the same Wi-Fi as your visitor strips the redirect and downgrades the connection to HTTP (sslstrip). Without HSTS, the first connection of every browser session is HTTP and is interceptable. With HSTS the browser refuses to even try.

What breaks if you set it wrong: if you enable HSTS while your SSL certificate is not yet ready on every subdomain (and you used includeSubDomains), every subdomain becomes unreachable until the certificate works. Always test without includeSubDomains first, then add it once you are sure all subdomains have valid TLS.

preload is optional and means "submit my domain to the HSTS preload list inside browser binaries". Once preloaded, the browser refuses HTTP for your domain even on the very first visit. Removal from the list is slow (months). Only enable preload once you are certain.

2. Content-Security-Policy (CSP)

Content-Security-Policy: default-src 'self'; img-src 'self' data: https:; script-src 'self'; style-src 'self' 'unsafe-inline'

Tells the browser "for this page, only load resources from the sources I list. Anything else, refuse to load it." This is the single most effective defence against XSS (cross-site scripting). If your site gets a malicious <script> tag injected somehow, CSP prevents it from executing because it doesn't come from an allowed source.

CSP is also the header most likely to break your site if you copy-paste it from a tutorial. Most WordPress sites use inline scripts, inline styles, third-party fonts, analytics from another domain, embedded YouTube. A strict CSP refuses all of those by default.

The pragmatic approach:

  1. Start with Content-Security-Policy-Report-Only instead of Content-Security-Policy. Same syntax, but the browser only logs violations, doesn't block. You see in the console what your site actually loads.
  2. Build a policy that allows only what you need. Domain by domain.
  3. Switch to enforcing mode once the report log is clean.

The single biggest win, even if you don't get to a strict CSP, is frame-ancestors 'self', which prevents your site from being put inside an <iframe> on someone else's domain. That alone replaces the older X-Frame-Options.

3. X-Content-Type-Options

X-Content-Type-Options: nosniff

Tells the browser "trust the Content-Type I send. Don't try to guess." Without this, if your server sends a file as text/plain but the content looks like HTML or JavaScript, some browsers will execute it as such anyway. This enables an attack pattern where an attacker uploads a .txt file containing HTML or JS, and the browser runs it.

There is no downside to enabling this. Set it once on every response and forget about it. The header takes one value (nosniff) and that's it.

4. Referrer-Policy

Referrer-Policy: strict-origin-when-cross-origin

Controls what gets sent in the Referer header (yes, misspelled, historical reasons) when somebody clicks a link from your page to another site. The default browser behaviour leaks the full URL, including query strings, which often contain session tokens, user IDs, search terms.

strict-origin-when-cross-origin is a balanced default: when the user navigates within your domain, send the full URL; when they navigate to another domain, send only the origin (https://example.com, no path); when they navigate from HTTPS to HTTP, send nothing.

If your site has any sensitive data in URLs (it shouldn't, but it often does), tighten this further to strict-origin or even no-referrer.

5. Permissions-Policy

Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()

Tells the browser which sensitive APIs your site is allowed to use. Empty parentheses mean "nobody, not even my own pages". The default is "anybody". The header is verbose and there is a long list of features (payment, usb, serial, clipboard-read, display-capture, fullscreen, dozens more).

Why bother: many of these APIs trigger a permission prompt to the user, and a malicious script injected on your page (or a buggy plugin) can spam those prompts. Permissions-Policy lets you say "my site never uses microphone, so the browser shouldn't even consider it, even if some script asks for it".

Practical default: deny everything you don't actually use. If your site has no video calls, no map, no PWA features, the line above with everything in () is a good baseline. Add specific origins back when a feature is needed.

6. X-Frame-Options (deprecated, kept here for older browsers)

X-Frame-Options: SAMEORIGIN

Older equivalent of frame-ancestors from CSP. Says "don't allow my pages to be embedded in an iframe on another origin". Two values matter in practice: DENY (no embedding at all) and SAMEORIGIN (only your own domain can embed your pages).

Modern browsers honour frame-ancestors from CSP and ignore X-Frame-Options if both are set. But not every bot, browser version, or scanner does. Keeping X-Frame-Options: SAMEORIGIN next to frame-ancestors 'self' costs nothing and covers the older clients.

A starting nginx block

This is a sane default. Put it in your server block, or in a snippet you include from every site:

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; frame-ancestors 'self'" always;

The always flag matters. Without it, nginx skips the header on responses with status codes outside the default list (4xx, 5xx, 1xx). You want security headers on error pages too.

How to verify

Open DevTools, Network tab, click the document request, look at the Response Headers section. Or use one of these online checkers:

  • securityheaders.com (Scott Helme)
  • observatory.mozilla.org
  • internet.nl

They give you a graded report and explain exactly what is missing or set wrong. A first run on a typical site with no headers usually gets an F. After adding the block above and tightening CSP gradually, getting to an A is realistic.

What this won't protect you from

Headers are necessary, not sufficient. They prevent specific browser-level attacks. They do not stop:

  • SQL injection (that is the application's responsibility)
  • Brute force on login (rate limiting and 2FA)
  • Compromised third-party plugins (auditing and updates)
  • Server-side request forgery (input validation)
  • DDoS (a CDN with rate limiting)

Set the headers anyway. They cost ten minutes once and pay back every day after.