Web Security
Web Security

Security headers worth sending

Card · ~10 min
After this unit you can
  • Recall what each common security header defends against, and its usual value
  • Recall why HSTS only works over HTTPS, its first-visit gap, and what preloading requires
  • Tell frame-ancestors from X-Frame-Options, and which one wins when both are sent
  • Recall which legacy headers to remove or switch off, and where Next.js sends static headers from

Cards

Strict-Transport-Security — HTTPS only, remembered

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

Tells the browser to use only HTTPS for this host for max-age seconds (here two years), so a typed http:// link or a downgrade attempt never goes out in plain text.

  • Only counts over HTTPS. Browsers ignore it on an http:// response, so an attacker in the middle can't add or shorten it.
  • First-visit gap. It takes effect after one secure response. The very first http:// request is still exposed.
  • preload closes that gap. Browsers ship a list of HSTS hosts (hstspreload.org). Listing requires max-age of at least 31536000 (one year), includeSubDomains and preload.
  • includeSubDomains covers every subdomain. OWASP warns that a misconfigured header or a certificate problem can leave users unable to reach the site until max-age runs out: check that no subdomain still needs plain HTTP first.

X-Content-Type-Options — no MIME sniffing

Flag
X-Content-Type-Options: nosniff

Tells the browser to believe your Content-Type instead of guessing from the bytes. Without it, a user-uploaded file served as text/plain that happens to contain HTML could be rendered as a page on your origin.

With nosniff:

  • a response loaded as a script is blocked unless its type is a JavaScript MIME type, and one loaded as a stylesheet unless it's text/css;
  • other responses, navigations included, use the declared Content-Type as-is.

It's the only valid value, and it has a precondition: every resource must be served with the right Content-Type, or your own scripts and styles get blocked.

Referrer-Policy — how much of your URL leaves with each request

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

Controls the Referer header browsers send when a page links to or loads from somewhere else. URLs carry more than you think: a reset token in a query string, an internal document ID in a path.

strict-origin-when-cross-origin is the browser default when no valid policy is set (MDN), and OWASP's recommended value — set it explicitly rather than relying on the default:

DestinationWhat's sent
same originfull URL, including path and query
another origin, HTTPS → HTTPSthe origin only
HTTPS → HTTPnothing

For pages whose URLs themselves are secret, such as a password-reset link, use no-referrer. Avoid unsafe-url, which sends the full URL everywhere, even to plain-HTTP sites.

frame-ancestors vs X-Frame-Options — who may frame you

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

Both stop other sites loading your page in an invisible frame and tricking users into clicking it (clickjacking).

  • frame-ancestors is the modern control: 'none', 'self', or a list of hosts allowed to embed you. When a policy with frame-ancestors is enforced, the CSP spec says X-Frame-Options "will be ignored". Next.js and OWASP describe it as superseding the old header.
  • X-Frame-Options (DENY or SAMEORIGIN) is the fallback for browsers too old for CSP's directive. Sending both is harmless.

Two frame-ancestors traps: it doesn't fall back to default-src (even default-src 'none' allows framing), and it's ignored in a <meta> tag, so send it as a header.

Permissions-Policy — switch off browser features you don't use

Flag
Permissions-Policy: camera=(), microphone=(), geolocation=()

Declares which powerful browser features this page, and the frames inside it, may use. () is an empty allow-list: the feature is off everywhere, iframes included. self allows your origin only; (self "https://maps.example.com") adds one embed. The header sets the ceiling, and an <iframe> also needs the matching allow attribute.

The payoff is containment: an XSS payload or a compromised third-party iframe can't start the camera or read the location on a page that turned those off.

Support is uneven: MDN marks the header as experimental and "Limited availability", and its compatibility data lists Chromium browsers only (Chrome 85+), not Firefox or Safari. It's a cheap layer, but don't rely on it alone.

Cross-Origin-Opener-Policy — cut the link to whoever opened you

Flag
Cross-Origin-Opener-Policy: same-origin

When another site opens your page with window.open(), the two windows can normally keep references to each other. COOP puts your document in its own browsing context group unless the other side is same-origin, so a cross-origin opener can't reach your window, and the browser may isolate it in a separate process. MDN: this "prevents a set of cross-origin attacks referred to as XS-Leaks".

  • same-origin is the strict value (OWASP's recommendation).
  • same-origin-allow-popups keeps working popups you open yourself, such as OAuth or payment windows that have no COOP of their own.
  • The default, with no header, is unsafe-none.

Paired with Cross-Origin-Embedder-Policy: require-corp it makes the page cross-origin isolated, which unlocks SharedArrayBuffer.

Cross-Origin-Resource-Policy — who may embed your files

Flag
Cross-Origin-Resource-Policy: same-site

Set on a resource (an image, a script, a JSON file), not on the page that loads it. It tells the browser to block other sites from loading it with a no-cors request: an <img>, a <script>, or fetch(url, { mode: "no-cors" }).

ValueWho may load it
same-originonly pages on exactly your origin
same-siteyour origin and sibling subdomains
cross-originanyone (what a public CDN or embeddable widget needs)

It closes a gap CORS doesn't: CORS controls reading responses through fetch, but no-cors loads such as <img> and <script> happen without it. Useful for private, per-user files, and it's how a cross-origin resource opts in to being loaded by a page that sends Cross-Origin-Embedder-Policy: require-corp.

Headers to drop or switch off

Flag
// next.config.js
module.exports = { poweredByHeader: false }; // Next.js adds X-Powered-By by default

Some headers make things worse, or only help an attacker:

  • X-XSS-Protection drove a filter that old browsers built in. OWASP: "Do not set this header or explicitly turn it off" (X-XSS-Protection: 0), because "in some cases, this header can create XSS vulnerabilities in otherwise safe websites". A CSP is the real replacement.
  • X-Powered-By advertises your framework. Next.js sends it unless poweredByHeader is false. OWASP: "Remove all X-Powered-By headers."
  • Server with a product and version: OWASP says to remove it or set a non-informative value.

Removing them is hygiene, not a defence. OWASP: "attackers have other means of fingerprinting your tech stack", so it buys you less than any header on the previous cards.

Sending the static set from Next.js

Flag
// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
          { key: "X-Content-Type-Options", value: "nosniff" },
          { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
          { key: "X-Frame-Options", value: "DENY" },
          { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
          { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
        ],
      },
    ];
  },
};

Headers that are the same on every response belong in headers() in next.config.js. If two entries match the same path and set the same key, the docs say "the last header key will override the first".

A nonce-based CSP can't live here, because it must change per request: generate it in proxy.ts (the Content-Security-Policy unit). Check what's actually served with curl -sI.

Further reading