All articles · Web Security

Security Headers Beyond CSP: nosniff, Referrer-Policy and More

Cover with the article title next to a stack of layered shields, in violet on a dark background

Content Security Policy gets most of the attention, and for good reason: it is the header that limits cross-site scripting. But a typical security header check looks at several more, and they are much easier to get right. Most of them are a single line with one sensible value.

This article covers four of them: X-Content-Type-Options, Referrer-Policy, Permissions-Policy and Cross-Origin-Opener-Policy. For each one you’ll see what it prevents, a safe default, and the cases where that default needs adjusting. At the end there’s an nginx configuration that sets all of them without the most common mistake.

X-Content-Type-Options: stop MIME sniffing

Browsers used to guess the type of a response from its content when the Content-Type header looked wrong. That guessing, called MIME sniffing, could turn an uploaded “image” or a text file into something the browser executed as script or applied as a stylesheet.

One header turns it off:

X-Content-Type-Options: nosniff

With nosniff, browsers refuse to run a script or apply a stylesheet whose Content-Type isn’t a matching JavaScript or CSS type. There’s only one valid value, so there is nothing to tune.

What can break: a server that sends JavaScript as text/plain or application/octet-stream. Older storage buckets and misconfigured static file handlers sometimes do this. Open the network tab, load your pages with the header in place, and fix the content types it exposes. That fix is worth making anyway.

Referrer-Policy: control what URLs leak

When a user follows a link or your page loads a resource from another site, the browser may send the current page’s URL in the Referer header. If your URLs contain anything sensitive, such as password reset tokens, search terms, order numbers or internal paths, that information goes to every third party the page touches.

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

This is the value most sites want, and it’s also the default in current browsers when no policy is set:

  • same-origin requests send the full URL, so your own analytics keep working;
  • cross-origin requests send only the origin (https://example.com/), not the path or query;
  • nothing is sent when going from HTTPS to HTTP.

Setting it explicitly is still worthwhile. It documents your intent, applies to older browsers that used a looser default, and stops a scanner from reporting the header as missing.

Stricter options exist for sensitive areas. same-origin sends nothing at all to other sites, and no-referrer sends nothing anywhere. A banking dashboard or an admin panel can reasonably use no-referrer. Avoid unsafe-url and no-referrer-when-downgrade, which leak full URLs to other sites.

You can also set the policy per link with rel="noreferrer" or the referrerpolicy attribute, which is handy when one outbound link needs different treatment from the rest of the page.

Permissions-Policy: switch off features you don’t use

Permissions-Policy controls which powerful browser features a page, and the frames embedded in it, may use: camera, microphone, geolocation, payment, USB and many more. If your site doesn’t use a feature, you can disable it so that no injected script or embedded third party can ask for it either.

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

The syntax uses structured header lists:

  • () means the feature is disabled for everyone, including your own page.
  • (self) allows your own origin and same-origin frames.
  • (self "https://maps.example.net") also allows a specific embedded origin.

Start with the features you are sure you don’t need. A store with an embedded payment provider will need payment for that provider’s origin; a store locator may need geolocation=(self). Don’t copy long lists from old blog posts: some of the feature names in them were experimental and never shipped, and some, like interest-cohort from the abandoned FLoC proposal, no longer mean anything. Unknown features are ignored by browsers, but they clutter the header and suggest protection that isn’t there.

The older Feature-Policy header is the predecessor with a different syntax. If you still send it, replace it rather than keeping both.

Cross-Origin-Opener-Policy: isolate your window

When your page opens a popup, or is opened by another site with window.open, the two windows can keep a reference to each other. Through that reference the other page can, for example, navigate your window to a different URL. Cross-origin windows sharing a browsing context group also make some side-channel attacks easier.

Cross-Origin-Opener-Policy: same-origin

With same-origin, a cross-origin page that opens yours, or that you open, ends up in a separate browsing context group. window.opener becomes null, and the link between the windows is cut.

This one can break things, so check before deploying:

  • OAuth and social sign-in popups. If your login flow opens a provider’s popup and waits for it to post a message back, same-origin cuts that channel. Use same-origin-allow-popups on the page that opens the popup; it keeps the protection against being opened by others while allowing your own popups to talk back.
  • Payment popups from some providers behave the same way. Test the full checkout.

COOP: same-origin together with Cross-Origin-Embedder-Policy: require-corp makes a page “cross-origin isolated”, which unlocks features like SharedArrayBuffer. That’s a separate project with its own requirements for every embedded resource. You don’t need it for the security benefit of COOP alone. Our cross-origin opener policy guide goes into more detail on the values.

Headers you can leave alone

A few headers still show up in copy-pasted configs:

  • X-XSS-Protection. It controlled an XSS filter that modern browsers have removed, and the filter itself could be abused. Don’t send it, or send X-XSS-Protection: 0 if a tool insists.
  • X-Frame-Options. Still useful as a fallback, but the CSP directive frame-ancestors is the modern way to control who can frame your pages. See clickjacking protection.
  • Expect-CT. Certificate Transparency is enforced by browsers without it, and the header is obsolete.

Setting them in nginx without losing any

nginx has one rule that catches almost everyone: add_header directives are inherited from the enclosing block only if the current block defines none of its own. Add a single add_header in a location and every header from the server block disappears for that location.

A practical pattern is a shared snippet included wherever you add headers:

# /etc/nginx/snippets/security-headers.conf
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
server {
    include snippets/security-headers.conf;

    location /assets/ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=31536000, immutable" always;
    }
}

The always parameter matters too. Without it, nginx only adds the header to successful and redirect responses, so your error pages ship without protection.

If a CDN or load balancer sits in front of the server, decide on one layer to own these headers. Setting them in two places leads to duplicates, and a duplicated header can be interpreted in surprising ways.

Verifying the result

After deploying, check real responses rather than the config file:

curl -sI https://example.com/ | grep -iE 'x-content-type|referrer-policy|permissions-policy|cross-origin-opener'

Repeat it for a 404 page, a redirect and a static asset, because those are the responses most likely to be served by a different block or layer. Our security headers guide lists the headers a passive scan looks at and the values it expects. For the full list of directives and browser support, MDN’s HTTP headers reference is the most reliable source.

None of these headers replaces fixing a vulnerability in your code. They narrow what an attacker can do with one, and they cost a few lines of configuration. Set them once, at the edge, and check them again whenever your server or CDN setup changes.

Tags:security headersReferrer-PolicyPermissions-PolicyCOOPnginx