Skip to content
Back to the blog

HTTP security headers: what to set and how to check

Nonce-based CSP, HSTS and preload, cookies, cross-origin isolation and obsolete headers: what to set, how to deploy it on Nginx, Apache and Cloudflare Pages without breaking your site, and how to check it.

Every response from your server can give the browser instructions: use HTTPS only, don't run scripts you didn't authorize, don't let another site show you inside an iframe. These are HTTP security headers. They are cheap to set up, but a bad one can leave your site blank or, in the case of HSTS, lock your visitors out until the period you set expires.

Chasing a grade isn't the point either. Mozilla's HTTP Observatory warns in its own documentation that its grade ranges are "essentially arbitrary", and securityheaders.com explains that its score reflects how many security headers your site sends. A high grade does not guarantee they are configured correctly for your site.

This guide covers what to set, what no longer helps, how to deploy without breaking anything and how to check it, based on MDN, OWASP, the W3C, the IETF and Chrome, consulted on November 4, 2026.

Content-Security-Policy: the one that protects most and breaks most

CSP defines what code the page may run. It is mainly a defense against cross-site scripting (XSS) and also against clickjacking, but OWASP makes clear it should not be your only defense against XSS.

Strict policy with a nonce

Maintaining a policy built on allowlists of domains is laborious and, according to web.dev, it can be bypassed in most configurations. web.dev, MDN, OWASP and the W3C recommend a "strict CSP" instead:

Content-Security-Policy:
  script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'none';
  • The nonce is a random value the server generates for each response and repeats in every legitimate <script nonce="...">. web.dev asks for a cryptographically strong value, ideally 128 bits or more, generated fresh every time. If it can be guessed, it doesn't protect anything.
  • 'strict-dynamic' lets an authorized script load others. It makes third-party tools easier to use, although the specification recommends avoiding it when possible.
  • https: and 'unsafe-inline' are only fallbacks for older browsers. Browsers that understand nonces and 'strict-dynamic' ignore them.
  • object-src 'none' blocks plugins and base-uri prevents injected <base> tags. The specification accepts 'none' or 'self'; use 'self' if your site has its own <base> tag.

A nonce requires generating HTML on the server. For static sites or cached pages, web.dev recommends hashes ('sha256-...') of each inline script. And before enabling it, move handlers such as onclick="..." and javascript: links into JavaScript files: a strict CSP blocks them.

Test first in Report-Only mode

Content-Security-Policy-Report-Only evaluates the policy without blocking anything and reports what it would have blocked. According to MDN, without the report-to directive no reports are sent. The destination is declared in another header:

Reporting-Endpoints: csp="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'self'; report-to csp; report-uri /csp-reports

report-uri is deprecated, but MDN recommends declaring both until report-to works in every browser. The specification allows sending an enforced policy and a report-only one at the same time.

frame-ancestors against clickjacking

frame-ancestors 'none' stops any site from showing your page in an iframe; 'self' allows it only for your own origin. MDN recommends 'none' unless you need your site to be embeddable.

Watch out: in a <meta> tag the specification does not accept frame-ancestors, report-uri, sandbox or Report-Only mode. They must be sent as HTTP headers.

Strict-Transport-Security and preload

HSTS tells the browser to use only HTTPS with your domain for max-age seconds and not to let the user bypass certificate errors. It is defined in RFC 6797, from November 2012, with two directives: max-age and includeSubDomains.

  • It only counts over HTTPS. The RFC requires the browser to ignore the header if it arrives over HTTP.
  • The first visit is exposed. Until the browser has received the header, a first request over HTTP can be intercepted. The preload list exists to close that gap.
  • includeSubDomains covers every subdomain, including old ones that still lack HTTPS.

The preload directive is not part of the RFC. The list is managed at hstspreload.org, a service hosted by Google, and according to MDN all browsers use it. To get in, the domain must serve HTTPS on all its subdomains, including www if it exists, and send a max-age of at least 31536000 seconds (one year) with includeSubDomains and preload.

The risk is in getting out of the list. hstspreload.org warns that inclusion cannot easily be undone: removal can take 6 to 12 weeks to reach most Chrome users, and longer for other browsers. It also asks that templates not include preload by default: don't copy it from an example.

On top of that, hstspreload.org recommends HSTS but does not recommend preloading: its added benefit is minimal compared with HSTS itself. Consider it only if you accept that getting off the list takes months.

The rollout hstspreload.org proposes raises max-age in stages: 300 seconds (5 minutes), 604800 (one week) and 2592000 (one month), waiting out the full period of each stage before moving to the next. If something goes wrong, max-age=0 disables HSTS, but only in browsers that visit you again over HTTPS.

Three simple headers

  • X-Content-Type-Options: nosniff makes the browser respect the declared Content-Type. It blocks scripts and stylesheets that arrive with another type, so first check that your server declares them correctly.
  • Referrer-Policy: strict-origin-when-cross-origin sends the full URL within your site, only the origin to other HTTPS sites and nothing when moving to HTTP. It is already the browser default, but OWASP recommends declaring it. If your URLs carry sensitive data, no-referrer sends nothing.
  • Permissions-Policy disables features you don't use, such as camera, microphone or geolocation, including inside third-party iframes. MDN flags it as experimental with partial support.

Cross-origin isolation: COOP, CORP and COEP

  • Cross-Origin-Opener-Policy: same-origin puts your page in a browsing context group separate from windows of other origins, which lose the reference through window.opener. If you use third-party sign-in or payment popups, MDN points to same-origin-allow-popups to keep that reference.
  • Cross-Origin-Resource-Policy states who can embed your resources. web.dev recommends sending it on all of them, because an attacker can load them from another site to infer information. OWASP suggests same-site; use cross-origin on resources other sites need to load.
  • Cross-Origin-Embedder-Policy: require-corp is only needed if you require cross-origin isolation, for example to use SharedArrayBuffer. OWASP warns that it blocks cross-origin resources that aren't configured correctly. If you don't need it, don't enable it.

Cookies

Session cookies are protected in Set-Cookie itself:

Set-Cookie: __Host-session=VALUE; Path=/; Secure; HttpOnly; SameSite=Lax
  • Secure: the cookie only travels over HTTPS.
  • HttpOnly: JavaScript can't read it, which reduces the impact of XSS. It is still sent with the site's requests.
  • SameSite: Strict sends it only on requests from your own site; Lax also on some navigations from other sites; None on all of them and requires Secure. Some browsers apply Lax if you don't declare it, so declare it.
  • __Host- prefix: the browser only accepts the cookie if it has Secure, comes from an HTTPS page, has no Domain and uses Path=/. Without Domain, the cookie is returned only to the host that set it, not to its subdomains.

What you should no longer send

  • X-XSS-Protection: MDN marks it as deprecated and warns that in some cases it creates XSS vulnerabilities. OWASP recommends not sending it or sending it as 0, and using CSP.
  • X-Frame-Options: it is not deprecated, but frame-ancestors replaces it in browsers that support it. If you send both and frame-ancestors is in an enforced policy (not Report-Only), the CSP specification says X-Frame-Options is ignored. You can keep DENY for older browsers; ALLOW-FROM no longer works.
  • Expect-CT: according to MDN it has been mostly obsolete since June 2021, and Chromium deprecated it as of version 107 because it now enforces Certificate Transparency by default. OWASP: don't use it.
  • Public-Key-Pins: Chromium removed it in 2018 and no modern browser supports it, according to OWASP. Remove it if it's still there.
  • Server and X-Powered-By with versions: OWASP recommends removing X-Powered-By and leaving Server without useful details, while noting there are other ways to fingerprint your stack.

How to deploy them without breaking your site

  1. Take inventory of third-party scripts, iframes and popups.
  2. Start with what rarely breaks anything: nosniff, Referrer-Policy, Permissions-Policy and frame-ancestors.
  3. Keep the script CSP in Report-Only and review the reports before enforcing it.
  4. Raise HSTS in stages; preload is optional and not recommended by default.
  5. Test errors and redirects, not just the home page.

Nginx

add_header only acts on some response codes (200, 201, 204, 206, 301, 302, 303, 304, 307 and 308) unless you add always. Also, a block that defines its own add_header stops inheriting those from the level above. Since version 1.29.3, the add_header_inherit directive lets you change that rule.

server {
    listen 443 ssl;
    server_name example.com;
    server_tokens off;  # hides the nginx version

    add_header Strict-Transport-Security "max-age=300; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Content-Security-Policy "object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;

    location /downloads/ {
        # This add_header cancels inheritance: repeat the headers above here
        add_header Cache-Control "no-store" always;
    }
}

Apache

With mod_headers, Header set (onsuccess by default) and Header always set use separate tables; only always applies to errors and redirects generated by Apache. To avoid duplicate headers, OWASP suggests removing the onsuccess version and setting it with always.

<VirtualHost *:443>
    ServerName example.com
    <IfModule mod_headers.c>
        Header always set Strict-Transport-Security "max-age=300; includeSubDomains"
        Header always set X-Content-Type-Options "nosniff"
        Header always set Referrer-Policy "strict-origin-when-cross-origin"
        Header always set Content-Security-Policy "object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
        # X-Powered-By can end up in either table
        Header unset X-Powered-By
        Header always unset X-Powered-By
    </IfModule>
</VirtualHost>
# In the global configuration: Server only says "Apache"
ServerTokens Prod

Cloudflare Pages

The _headers file, with no extension, goes in the static asset folder that gets published; without a framework, directly in the build output folder. It allows up to 100 rules and 2,000 characters per line, a limit to watch with long CSP policies. Its headers are not applied to Pages Functions responses: those are set in code.

/*
  Strict-Transport-Security: max-age=300; includeSubDomains
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Content-Security-Policy: object-src 'none'; base-uri 'self'; frame-ancestors 'none'

How to check them

Chrome DevTools. In Network, select the request and open Headers: Response Headers shows what arrived, and there you can override a header locally to test it before deploying. The Issues panel reports CSP violations as well as cookie and COEP problems.

curl. curl -I requests headers only, using the HEAD method. To see those of a GET, on the home page, on an error page and over HTTP:

curl -s -D - -o /dev/null https://example.com/
curl -s -D - -o /dev/null https://example.com/does-not-exist
curl -sI http://example.com/   # should redirect to HTTPS, with no HSTS

HTTP Observatory. It lives at developer.mozilla.org/en-US/observatory. It launched on MDN on July 2, 2024, and the old observatory.mozilla.org address redirects there. Each domain's scan history is public, and it is designed for websites, not APIs.

securityheaders.com. Still running; it was created by Scott Helme and is now part of the company Snyk. It grades from A+ to F based on how many security headers you send; an R means the response was a redirect.

No grade tells you whether your CSP really protects you: the reports do.

Commented base configuration

For a site that isn't embedded elsewhere and doesn't need cross-origin isolation. Adjust it to your case:

# HTTPS for one year, no preload. Get here after the test stages
Strict-Transport-Security: max-age=31536000; includeSubDomains

# Enforced from day one: no plugins, no foreign <base>, and nobody (not even your own site) can frame your pages
Content-Security-Policy: object-src 'none'; base-uri 'self'; frame-ancestors 'none'

# In testing: the strict script policy, with reports
Reporting-Endpoints: csp="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'self'; report-to csp; report-uri /csp-reports

# The browser respects the declared Content-Type
X-Content-Type-Options: nosniff

# Only the origin to other sites
Referrer-Policy: strict-origin-when-cross-origin

# Features the site doesn't use
Permissions-Policy: camera=(), microphone=(), geolocation=()

# Window separated from other origins (same-origin-allow-popups if you use payment or OAuth popups)
Cross-Origin-Opener-Policy: same-origin

# Your resources, only for your site and subdomains
Cross-Origin-Resource-Policy: same-site

# Only for older browsers; frame-ancestors takes precedence
X-Frame-Options: DENY

# Session cookie
Set-Cookie: __Host-session=VALUE; Path=/; Secure; HttpOnly; SameSite=Lax

Checklist

Check

Header

Present on errors and redirects too (always)

All

In Nginx, every location with its own add_header repeats them

All

Strict CSP tested in Report-Only, with no onclick or javascript:

CSP

frame-ancestors sent as a header, not in <meta>

CSP

max-age raised in stages and every subdomain on HTTPS

HSTS

preload only if you accept that getting out takes months

HSTS

Correct Content-Type on scripts and styles

nosniff

Session with Secure, HttpOnly, SameSite and __Host-

Cookies

No active X-XSS-Protection, no Expect-CT, no Public-Key-Pins

Obsolete

Server and X-Powered-By without versions

All

DevTools, curl and a public tool after every change

All

Sources


If you want to review your site's headers and plan how to enable them without disrupting it, let's talk.

Share this post
Send by email

Keep reading

Web Development

WCAG 2.2: common accessibility errors and how to fix them

10 min
Web Development

Core Web Vitals: how to measure them and what to fix first

10 min