We scanned 364 Australian business domains in June 2026. Of the 330 with a reachable website, 77% scored D or F on HTTP security headers. 91% had no Content-Security-Policy at all, and 77% had no HSTS. The full numbers are in the Australian SMB Security & Digital Readiness Benchmark.
That failure rate isn’t because these headers are hard. Four of them are one static line of config each and carry essentially no risk. Sites fail because the two headers that matter most, HSTS and CSP, are the two that can break things if you get them wrong, so they get postponed indefinitely. And some of the sites that had configured them had the whole set silently voided by a web-server quirk almost nobody warns you about.
This article is about that second half: what actually breaks, and what silently doesn’t work. If you just want to know what each header does and see your current grade, run the security headers checker instead. It grades any URL and explains each header individually.
Examples here are nginx, because that’s what we run. Apache and Caddy have direct equivalents.
The four you can ship this afternoon
These four have no meaningful failure mode on a normal site. There is no reason to stage them, no reason to test them in report mode, and no reason for them to be missing:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
nosniff stops browsers from second-guessing your Content-Type and executing a file you served as text because it looked like JavaScript. This matters most on anything that serves user uploads.
X-Frame-Options: SAMEORIGIN stops other sites from loading yours in an iframe and tricking users into clicking things. That is clickjacking. Note that CSP’s frame-ancestors directive supersedes this header and modern browsers prefer it. Set both: frame-ancestors for current browsers, X-Frame-Options for anything old enough not to support it. They cost nothing together.
Referrer-Policy: strict-origin-when-cross-origin stops full URLs, including query strings that often carry tokens and identifiers people never meant to leak, from being sent to third-party sites in the Referer header. Off-site requests get your bare origin instead.
Permissions-Policy switches off browser features your site doesn’t use. If you never ask for the camera, an injected script shouldn’t be able to either. Add whatever else applies: payment=(), usb=(), interest-cohort=().
The always flag on each line is not optional. Without it, nginx only attaches the header to 2xx and 3xx responses. Your 404s and 500s go out bare. Error pages are exactly where injected content tends to surface, so they need the headers most.
HSTS: easy header, one-way decision
Strict-Transport-Security tells browsers to refuse plain HTTP to your domain for a set period. Once a browser has seen it, it upgrades http:// to https:// itself, without a round trip that could be intercepted.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
The header itself won’t break a site that already serves HTTPS correctly. The two things that will:
includeSubDomains. This applies the rule to every subdomain, including ones you forgot about. An internal tool on legacy.example.com still running HTTP becomes unreachable in any browser that has seen the header. Inventory your subdomains before adding this directive, not after the support calls.
preload. Submitting to hstspreload.org bakes your domain into the browser’s built-in list, so even a first-time visitor who has never touched your site can’t be downgraded. It closes the last gap, and it is effectively a one-way door. Removal is a request, not a switch, and it takes months to propagate through browser release cycles. Every subdomain you ever create must serve valid HTTPS from day one, forever.
Preload is the right call for most business sites. Just make it deliberately. A sensible ramp is max-age=300 for a day, then max-age=31536000, then add includeSubDomains, and only then preload.
CSP: the one that breaks things
Content-Security-Policy is missing from 91% of the sites we scanned, and it’s the one worth the effort. It’s an allow-list of where the browser may load scripts, styles, images, fonts and API calls from. A script injected into your page can’t call out to an attacker’s server.
It also means that anything you forgot to list stops working.
Deploy in Report-Only first. Content-Security-Policy-Report-Only does everything except block. The browser reports what would have been blocked and the site keeps functioning:
add_header Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report;" always;
Leave it for a week of real traffic. Analytics tags, embedded maps, chat widgets, a font a designer added two years ago, a payment iframe that only appears at checkout. You will not find all of these by clicking around yourself.
Then resist unsafe-inline. When the reports come in, the fastest way to make them stop is script-src 'self' 'unsafe-inline'. This is the single most common way a CSP ends up doing nothing useful. XSS works by getting the browser to run injected inline script; 'unsafe-inline' tells the browser inline script is fine. You keep the benefit of the other directives (restricting image, font, frame and connect-src origins, and blocking form hijacking with form-action), but the headline protection is gone.
The alternatives are nonces (a random value regenerated per request, which needs server-side rendering) or hashes (a SHA-256 of each inline block). For a static site, hashes are the practical option. Ours are generated from the built output and patched into the nginx config automatically at deploy time, because a hand-maintained hash list breaks the first time someone edits a script tag.
One honest concession in our own policy: style-src-attr 'unsafe-inline'. Inline style="" attributes are far lower risk than inline script and eliminating them entirely wasn’t worth the refactor. Inline scripts are all hashed. Knowing which corner you cut, and why, is the difference between a considered policy and a decorative one.
The nginx trap that voids all of it
This is the part that catches people who have done everything above correctly.
In nginx, add_header does not merge. If a location block contains any add_header directive of its own, nginx discards every add_header inherited from the parent server block for requests that match that location. Not the matching ones. All of them.
So a config like this:
server {
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'self'; ..." always;
location ~* \.(css|js|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
…serves your HTML with a full set of security headers and your CSS and JavaScript with nothing but a cache header. One innocuous caching rule silently stripped the protection from every asset on the site.
It’s insidious because the homepage still grades A. Any checker pointed at https://example.com/ reports a clean result while the assets, the admin area, the PHP endpoints and the upload directory serve bare responses. Every location block that sets any header must repeat the full set. Verbose, but there is no inheritance to rely on.
Check for it directly:
curl -sI https://example.com/ | grep -i -E 'strict-transport|content-security|x-frame'
curl -sI https://example.com/assets/style.css | grep -i -E 'strict-transport|content-security|x-frame'
curl -sI https://example.com/nonexistent-page | grep -i -E 'strict-transport|content-security|x-frame'
If the second and third come back thinner than the first, you’ve found it. Apache’s Header set does inherit, so this specific trap is nginx-flavoured. Apache has its own version of the problem when .htaccess files override a parent directory.
CSP failures are silent
The other reason CSP goes wrong: when it blocks something, the user sees no error. The browser refuses the request, logs a violation in a console nobody has open, and the page carries on looking fine.
We hit this on this site on 30 April 2026. We shipped a DNS lookup tool, tested it, and it reported “DNS query failed.” Nothing was actually wrong with the tool. The CSP connect-src directive didn’t include the DNS-over-HTTPS endpoint it called, so the browser blocked the fetch() before it left the page. curl couldn’t reproduce it, because curl doesn’t enforce CSP. Only a real browser does.
Two things follow from that:
Set report-uri or report-to and actually read them. Violations you never see are violations you never fix.
Test in a real browser after shipping anything that makes a third-party request. A passing curl proves the header is present. It proves nothing about whether the page still works.
Verify what you shipped
Order of operations that works:
- Ship the four safe headers. Today. There’s no staging argument for them.
- Add HSTS with a short
max-age, raise it, then decide onincludeSubDomainsandpreloaddeliberately. - Run CSP in Report-Only for a week of real traffic, then enforce, with hashes or nonces, not
'unsafe-inline'. - Re-test every URL type on the site, not just the homepage: an asset, an error page, an API endpoint, the admin area.
Step 4 is the one people skip, and it’s where the nginx inheritance trap lives.
The security headers checker grades any URL against all six headers plus cookie flags and the HTTP-to-HTTPS redirect chain. Run it on a few different paths, not just /. The full website security scan covers these alongside TLS configuration, DNS and email authentication.
If the answer is that the site needs rebuilding rather than patching, that’s what our Websites service does: hardened by default, headers included, from AUD $1,499. For an existing site that has to stay where it is, Website Security covers remediation in place.