CMS & Web/Explainer

Security headers explained: what to send, what to retire and how to roll out CSP

A handful of HTTP response headers tell browsers to enforce HTTPS, block framing and limit what scripts may load. Here is what each one does, which ones to drop and how to add them on Apache and Nginx.

Medium impact5 min readDevelopers, AdminsUpdated 31 October 2026

Fingerprint of this page: sha256("/cms/security-headers/")

Every web page arrives with a set of HTTP response headers, and some of them are instructions to the browser: only use HTTPS for this site, do not let other sites put this page in a frame, do not guess file types. These security headers cost nothing to send and protect your visitors against whole classes of attack. They do not fix a vulnerable application, but they make many flaws much harder to exploit.

What a security header actually does

A security header changes how the browser treats your page. The server sends it, the browser enforces it. That has two consequences. First, the protection only works in browsers that understand the header. Support is broad for most of the headers below, but check the browser compatibility tables on MDN for newer ones such as Permissions-Policy. Second, a wrong value can break your own site, so each header needs a short test before it goes live.

Headers are set by the web server, a reverse proxy, a CDN or the application. Set each header in one place only. If two layers both add the same header, browsers may receive two conflicting values.

Six headers worth sending

HeaderWhat it doesSensible starting value
Strict-Transport-SecurityTells the browser to use only HTTPS for this host for the given timemax-age=31536000; includeSubDomains
Content-Security-PolicyLists the sources from which scripts, styles, images and frames may load, and who may embed the pageStart in report-only mode, see below
X-Content-Type-OptionsStops the browser from guessing a file type that differs from the declared onenosniff
Referrer-PolicyControls how much of the current address is passed on when a visitor follows a linkstrict-origin-when-cross-origin
Permissions-PolicySwitches off browser features such as camera, microphone or location for your pages and embedded framescamera=(), microphone=(), geolocation=()
Cross-Origin-Opener-PolicySeparates your page from windows opened by or from other sitessame-origin

(HTTP Strict Transport Security) only counts when sent over HTTPS; browsers ignore it on plain HTTP. With includeSubDomains it covers every subdomain, so confirm first that all of them serve HTTPS. Start with a short max-age, such as one day, and raise it to a year once nothing breaks.

Content-Security-Policy () is the most powerful header and the hardest to get right, because a strict policy blocks inline scripts that many sites rely on. Its frame-ancestors directive decides which sites may show your page in a frame, which defends against : tricking a visitor into clicking something on your page through an invisible overlay.

Cross-Origin-Opener-Policy (COOP) with same-origin can break sign-in or payment flows that open a popup and talk back to your page. If that happens, use same-origin-allow-popups instead.

The other three are simple. nosniff stops a browser from running an uploaded text file as a script just because its content looks like one. strict-origin-when-cross-origin passes the full address within your own site, only the domain to other sites and nothing on a step from HTTPS down to HTTP. Current browsers use it as their default, but setting it explicitly keeps the behaviour predictable. In Permissions-Policy, an empty list () switches a feature off for your pages and for every frame they embed.

Headers to retire: X-XSS-Protection and X-Frame-Options

X-XSS-Protection switched on a filter in some older browsers that tried to detect cross-site scripting. It is deprecated and non-standard, and the filter itself could create vulnerabilities on otherwise safe pages. The OWASP guidance is to leave the header out or send 0, and to rely on a CSP that blocks inline scripts instead.

X-Frame-Options still works, but it is superseded. It knows only DENY and SAMEORIGIN, while frame-ancestors can list exactly which sites may embed you. The CSP standard says an enforced frame-ancestors directive overrides X-Frame-Options. A report-only policy does not, so keep X-Frame-Options until frame-ancestors is enforced.

Apache: .htaccess with mod_headers

On Apache the Header directive comes from mod_headers. In .htaccess it works only if the server configuration allows it with AllowOverride FileInfo or All. Use the always condition so the headers are also sent on redirects and error pages:

apache.htaccess in the document root (or the HTTPS VirtualHost)
# Requires mod_headers and AllowOverride FileInfo
# HSTS only on HTTPS requests
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" "expr=%{HTTPS} == 'on'"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
Header always set Cross-Origin-Opener-Policy "same-origin"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Content-Security-Policy-Report-Only "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'"

If the site answers with error 500 after the change, mod_headers is not loaded or the override is not allowed. Behind a proxy or CDN that ends TLS, %{HTTPS} may be off, so set HSTS at that layer instead. If you put the lines into the VirtualHost, run apachectl configtest before reloading; .htaccess files are only read when a request arrives.

Nginx: add_header with always

On Nginx, add_header without the always parameter only applies to a list of success and redirect codes, so error pages would go out without protection. Add the lines to the server block that listens on port 443:

nginxNginx: inside the server block for HTTPS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
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=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;

Watch the inheritance rule: a location block that sets any add_header of its own drops every header from the server level. Repeat the lines there, or keep them in a snippet file that you include in each place. Since version 1.29.3, Nginx also offers add_header_inherit merge; to change this behaviour. Run nginx -t before every reload.

bashCheck the headers a page really sends
curl -sI https://www.example.com/ | grep -iE 'strict-transport|content-security|x-content-type|referrer|permissions|cross-origin|x-frame'

Rolling out CSP without breaking the site

A CSP that blocks something your site needs breaks it silently for every visitor. That is why the rollout starts with Content-Security-Policy-Report-Only: the browser reports what it would block but blocks nothing. This header works only as a real HTTP header, not in a meta element.

  1. Send a strict draft policy as Content-Security-Policy-Report-Only, as in the examples above.
  2. Collect violations. The browser console shows them on every page you open; for real traffic, define a reporting endpoint with the Reporting-Endpoints header and the report-to directive, and keep the older report-uri as a fallback because report-to is not yet supported everywhere.
  3. Fix the causes: move inline scripts into files, or allow them with a or hash, and add the third party sources you really need.
  4. Switch the header name to Content-Security-Policy once the reports are quiet for a week or two.
  5. Keep a stricter next version running in report-only mode alongside the enforced one, and tighten step by step.
httpResponse headers during the trial phase
Reporting-Endpoints: csp="https://www.example.com/csp-reports"
Content-Security-Policy-Report-Only: default-src 'self'; object-src 'none'; base-uri 'self'; report-to csp; report-uri /csp-reports

Sources and further reading

  1. MDN: Strict-Transport-Securitydeveloper.mozilla.org
  2. MDN: Content-Security-Policy-Report-Onlydeveloper.mozilla.org
  3. MDN: X-XSS-Protectiondeveloper.mozilla.org
  4. OWASP HTTP Headers Cheat Sheetcheatsheetseries.owasp.org
  5. Apache HTTP Server: mod_headershttpd.apache.org
  6. Nginx: ngx_http_headers_modulenginx.org

General information, checked on 31 October 2026. Test changes on one system first and keep a way back. Found something outdated? Tell the editors.

FAQ

Questions readers ask

Do security headers replace updates or a firewall?

No. Headers tell the browser to apply protections for your visitors, such as forcing HTTPS or blocking framing. They do nothing about a vulnerable plugin or an exposed admin login. See them as one layer among several.

Can I set security headers in an HTML meta tag?

Only partly. A CSP can be delivered in a meta element, but without frame-ancestors, report-uri and sandbox, and report-only mode does not work there. HSTS and X-Frame-Options are ignored in meta tags, so set headers on the server.

Should I still send X-XSS-Protection?

No. It is deprecated, and the filter it controlled could introduce vulnerabilities. Leave it out, or send X-XSS-Protection: 0 if a scanner insists, and invest the effort in a good CSP instead.

Why does my CSP break the site even though the report-only phase was clean?

Usually because some pages or features were never visited during the trial, such as checkout, account pages or rarely used forms. Test those paths deliberately, or run report-only longer with a reporting endpoint so real traffic covers them.

Keep reading

More from CMS & Web