# The HTTP Security Headers Guide (with CSP)

URL: https://www.devopsness.com/blog/security-headers-guide
Author: Kiril Urbonas
Published: 2026-07-22
Updated: 2026-08-30
Categories: DevOps
Tags: Security, Appsec, Web Security, CSP, HTTP Headers

Summary: A practical walkthrough of the HTTP response headers that harden a web app, with a real rollout plan for Content-Security-Policy.

---
Response headers are the cheapest defense-in-depth you can ship. They don't fix a broken auth flow or a SQL injection, but they shrink the blast radius when something else goes wrong. A handful of headers turn "one bug becomes account takeover" into "one bug the browser refuses to weaponize." This guide walks through each header worth setting, what it actually does, and a value we'd deploy. Then we get into Content-Security-Policy, which deserves its own section because it's powerful, fiddly, and the one most likely to break your site if you rush it.

For the wider context on where headers fit, see our [application security best practices](/blog/application-security-best-practices) guide.

## The headers that carry their weight

**Strict-Transport-Security (HSTS):** Forces browsers to use HTTPS for your domain, so a stripped or downgraded connection never gets a chance. Once a browser sees this header, it refuses plain HTTP for the duration of `max-age`.

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

Use a long `max-age` (two years here). `includeSubDomains` covers every subdomain, so confirm they all serve HTTPS before enabling it. `preload` opts you into the browser-baked HSTS list at hstspreload.org, which means the very first request is protected too. Preload is hard to reverse, so treat it as a commitment.

**X-Content-Type-Options: nosniff:** Stops the browser from guessing a response's content type. Without it, a browser might treat a user-uploaded text file as JavaScript. There is exactly one value and you always want it.

```
X-Content-Type-Options: nosniff
```

**X-Frame-Options and frame-ancestors:** Both defend against clickjacking, where your page gets loaded in an invisible iframe on an attacker's site. `X-Frame-Options: DENY` is the legacy control; the modern replacement is the CSP `frame-ancestors` directive, which is more expressive (you can allow specific origins). Set both for coverage across old and new browsers.

```
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
```

**Referrer-Policy:** Controls how much of the referring URL leaks to other sites. The default in many browsers still sends the full path to same-origin and downgrades elsewhere, but paths often carry tokens or IDs. `strict-origin-when-cross-origin` sends the full URL to your own origin and only the bare origin to others, never over a downgrade.

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

**Permissions-Policy:** Declares which browser features the page and its iframes may use, such as camera, microphone, and geolocation. Deny everything you don't need, so a compromised script can't silently reach for the webcam.

```
Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()
```

**A note on deprecated headers:** `X-XSS-Protection` is dead. Modern browsers removed the auditor it controlled because it caused its own information-leak bugs, and in some cases it made XSS worse. Don't set it, or set `X-XSS-Protection: 0` to disable any lingering legacy behavior. CSP is the real XSS defense now.

## Content-Security-Policy, the big one

CSP tells the browser exactly where resources may load from and, critically, whether inline scripts are allowed to run. A well-built policy is the single strongest browser control against cross-site scripting, which is why we cover the attack itself in [preventing XSS](/blog/prevent-xss).

The core directives:

- `default-src` is the fallback for any resource type you don't name explicitly.
- `script-src` governs JavaScript, and it's where the security actually lives.
- `object-src 'none'` and `base-uri 'none'` close off legacy injection vectors most policies forget.
- `frame-ancestors` handles clickjacking as noted above.
- `upgrade-insecure-requests` rewrites any `http://` subresource to `https://` at load time.

The hard part is inline scripts. `'unsafe-inline'` allows any inline `<script>` to run, which defeats most of the point, because an injected script is inline too. The two safe alternatives are **nonces** and **hashes**. A nonce is a random per-response token you put on both the header and each legitimate `<script nonce="...">`; the browser runs only scripts carrying the matching value. A hash pins a script by the SHA-256 of its exact contents, which suits static inline blocks that never change. Prefer nonces for server-rendered pages and hashes for fixed snippets. Never ship both `'unsafe-inline'` and a nonce expecting the nonce to win in old browsers; it won't.

A reasonable starter policy, generated fresh per request with a nonce:

```
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM}';
  style-src 'self';
  img-src 'self' data:;
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';
  upgrade-insecure-requests;
```

## Rolling CSP out without an outage

A strict CSP will break things you forgot existed: a third-party analytics tag, an inline onclick handler, a font from a CDN. Deploy it blind and you'll be reverting within the hour. The disciplined path uses report-only mode first.

`Content-Security-Policy-Report-Only` enforces nothing. The browser evaluates your policy, lets everything load, and posts a JSON violation report to an endpoint you name. You collect real-world violations from real traffic, learn what your policy would have blocked, and tighten it until the reports go quiet.

```
Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM}';
  object-src 'none';
  report-uri /csp-reports;
```

The process, in order:

1. Ship the policy in report-only and watch the reports for a week or two across your full traffic mix.
2. Fix the legitimate violations: add the analytics domain to `script-src`, replace inline handlers with nonced scripts, move styles out of `style` attributes.
3. When reports are down to genuine noise and attack attempts, flip the exact same policy from `-Report-Only` to `Content-Security-Policy`.
4. Keep a reporting endpoint on the enforced policy too, so regressions surface before users file tickets.

Here's the enforced version wired up in Express, generating a nonce per request:

```javascript
const crypto = require('crypto');

app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString('base64');
  res.locals.nonce = nonce;
  res.setHeader('Content-Security-Policy', [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}'`,
    "style-src 'self'",
    "img-src 'self' data:",
    "object-src 'none'",
    "base-uri 'none'",
    "frame-ancestors 'none'",
    "upgrade-insecure-requests",
  ].join('; '));
  next();
});
```

## Testing what you shipped

Two tools tell you the truth. **securityheaders.com** grades a live URL and flags missing or weak headers in seconds. **Mozilla Observatory** goes deeper and scores your CSP quality specifically, calling out `'unsafe-inline'` and missing directives. Run both after every change, and check the browser console, which prints the precise directive that blocked each resource.

## The call we'd make

Set HSTS, `nosniff`, `frame-ancestors`, `Referrer-Policy`, and `Permissions-Policy` today; they're low-risk and you can ship them in one deploy. Treat CSP as a project, not a header. Start in report-only, drive it with real violation data, and only enforce once the reports are quiet. A nonce-based policy with `object-src 'none'` and `base-uri 'none'` is the target. Skip `'unsafe-inline'` entirely, retire `X-XSS-Protection`, and let the browser do the work your application layer can't guarantee.

