Safeguard
Application Security

A practical guide to HTTP security headers: CSP, HSTS, and beyond

A misconfigured checkout page let attackers skim 380,000+ card payments from British Airways in 2018. Here's how CSP, HSTS, and frame-ancestors actually stop attacks like that.

Safeguard Research Team
Research
6 min read

In September 2018, attackers modified a single third-party JavaScript library — Modernizr — loaded on British Airways' checkout page, and used it to skim payment details from roughly 380,000 transactions in real time. The UK's Information Commissioner's Office (ICO) fined BA £20 million in 2020, down from an initially proposed £183 million notice. Researchers who later analyzed the incident, including a widely cited case study published by report-uri.com, concluded that a properly scoped Content-Security-Policy would very likely have stopped the attack outright: it would have blocked the injected script from executing, or at minimum blocked its outbound connect-src request to the attacker's exfiltration domain. That single missing header is the throughline of this post. HTTP security headers are not exotic controls — they're free, browser-enforced directives your server already has the ability to send on every response. Yet most production sites still ship with an incomplete or absent CSP, no HSTS preload, and clickjacking protection copied from a decade-old Stack Overflow answer. This guide walks through the headers that matter most, what attack each one blocks, and how to deploy them without breaking your own site.

What does Content-Security-Policy actually block?

CSP is a browser-enforced allowlist that tells the browser which sources are permitted to supply scripts, styles, frames, and network connections for a page — anything not on the list simply doesn't execute or load, regardless of how it got injected into the DOM. Before CSP, an XSS payload or a compromised third-party script tag ran with the same privileges as your own first-party JavaScript, because the browser had no way to distinguish "code the site author wrote" from "code that ended up in the page somehow." A policy like Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted-cdn.example; connect-src 'self' blocks two distinct attack stages: it stops an injected <script> tag from running if its source isn't 'self' or the named CDN, and it stops a script that does run from exfiltrating stolen data to an attacker-controlled domain via fetch() or XMLHttpRequest, because connect-src only permits 'self'. The catch is that unsafe-inline and wildcard sources like script-src * defeat the policy entirely — and both are still common because retrofitting a CSP onto an app full of inline <script> tags and inline event handlers is genuinely tedious work.

Why is HSTS necessary if a site already redirects HTTP to HTTPS?

An HTTP-to-HTTPS redirect happens after the browser has already made an insecure first request, which gives a network-level attacker — someone on the same coffee-shop Wi-Fi, or controlling a router in the path — a window to intercept that plaintext request and simply never let the redirect happen, a classic SSL-stripping downgrade attack. Strict-Transport-Security: max-age=31536000; includeSubDomains tells the browser to rewrite every future request to that domain as HTTPS internally, before any network packet is sent, closing that window entirely — but only starting from the second visit, since the browser has to see the header once first. The HSTS preload list, maintained at hstspreload.org and operated by the Chromium team, solves the "first visit" gap by baking a hardcoded list of HTTPS-only domains directly into the browser binary itself; Chromium's rules (also consumed by Firefox, Safari, and Edge) require max-age of at least 31536000 seconds (one year), plus both includeSubDomains and preload directives, before a domain qualifies for submission. Submission is essentially permanent in practice — removal is slow and painful — so teams should confirm every subdomain genuinely supports HTTPS before opting in.

Is X-Frame-Options still worth setting in 2026?

X-Frame-Options is now a legacy header that modern browsers only fall back to when a more capable directive is absent. Per MDN's current guidance, in browsers that support CSP Level 2, the frame-ancestors directive of Content-Security-Policy takes precedence over X-Frame-Options whenever both are present on a response — the browser evaluates frame-ancestors and simply ignores X-Frame-Options in that case. The ALLOW-FROM value, once used to permit a single named origin to frame the page, is obsolete and non-functional in every current major browser, which is why it still shows up in outdated hardening guides that predate frame-ancestors support. The modern, correct approach is Content-Security-Policy: frame-ancestors 'none' to block all framing (preventing clickjacking, where an attacker overlays your login or payment page inside an invisible iframe on their own site to trick users into clicking through to actions they never intended), or frame-ancestors 'self' https://partner.example for a specific allowlist. Keep a legacy X-Frame-Options: DENY alongside it only as a fallback for the shrinking set of clients that don't parse frame-ancestors — never rely on it alone, and never set either control via an HTML <meta http-equiv> tag, since MDN confirms both are enforced solely through real HTTP response headers and have no effect when delivered in markup.

What other headers close gaps CSP and HSTS leave open?

Three more headers round out a hardened baseline, each closing a distinct gap. X-Content-Type-Options: nosniff stops the browser from MIME-sniffing a response and reinterpreting, say, a user-uploaded image as executable JavaScript or HTML because the browser guessed at content type instead of trusting the declared Content-Type. Referrer-Policy: strict-origin-when-cross-origin (a widely adopted default) strips full URLs — which often contain session tokens, search queries, or internal path structure — from the Referer header sent to third-party sites your page links out to, sending only the origin instead. Permissions-Policy (the successor to Feature-Policy) lets you explicitly disable browser capabilities the page never needs, such as camera=(), microphone=(), geolocation=(), which shrinks the blast radius if a third-party script embedded on the page — an ad, an analytics tag, a widget — is later compromised and tries to invoke a sensitive API it has no legitimate reason to touch.

How do you roll these headers out without breaking your own site?

Deploy CSP in Content-Security-Policy-Report-Only mode first, pointed at a report-uri or the newer report-to endpoint, and run it for at least one full release cycle before switching to enforcing mode — this surfaces every legitimate inline script, third-party widget, and CDN dependency your policy would otherwise break, without actually breaking anything yet. Add HSTS with a short max-age initially (a day or a week) and only increase it toward the one-year, preload-eligible value once you've confirmed every subdomain — including ones marketing or a forgotten staging environment might have spun up — genuinely serves HTTPS, since HSTS with includeSubDomains will break plain-HTTP access to any subdomain that doesn't. Test frame-ancestors and X-Frame-Options changes against any legitimate embedding use case (a support widget iframe, a partner integration) before deploying 'none' broadly. All of this is configuration your web server, reverse proxy, or CDN already supports natively — the work is in writing an accurate policy for your specific app, not in acquiring new tooling.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.