Security
HTTP security headers explained: CSP, HSTS and what each one stops
Security headers are short lines in an HTTP response that tell the browser to refuse things it would otherwise allow. Each one closes a specific hole, and each has a way of quietly doing nothing when the value is wrong. This article goes header by header: what the browser enforces, which values look protective but are not, and how this site's own configuration and the tools linked below expose the difference.
By the MaffeiTools team · Updated · 11 min read
Headers are browser rules, so they only help against attacks the browser can see
A security header does not change what the server does. It is an instruction the browser applies to the document it just received: do not run inline script, do not let another site frame this page, do not send the full URL in a Referer header. A header cannot fix a cross-site scripting bug; it can only limit what injected script is allowed to do. Anything that talks to your server without a browser, such as curl or an attacker's own script, ignores every header on this list.
The failure mode is also silent. A misspelled directive, an unrecognized value, or a policy delivered in the wrong place produces no error on the page: the header shows up in a scan and the protection is absent.
Content-Security-Policy: fetch directives, the default-src fallback, and what does not fall back
A Content-Security-Policy is a list of directives separated by semicolons. Each fetch directive (script-src, style-src, img-src, connect-src, font-src, frame-src and so on) controls where one kind of resource may load from. default-src is the fallback: any fetch directive you leave out inherits its value. A directive you set replaces the fallback for that resource type; it does not merge with it. MDN's example is default-src 'self'; img-src 'self' example.com: images may come from the same origin or example.com, and every other resource type is same-origin only.
Three directives that people assume are covered by default-src are not. The CSP Level 3 specification says frame-ancestors does not fall back to default-src, so a policy of default-src 'none' still lets anyone embed the page. base-uri and form-action are likewise navigation and document directives outside the fetch list, which is why a policy that opens with default-src 'self' still needs base-uri and form-action written out. frame-ancestors also cannot be delivered through a meta element; the specification says it must be ignored there, along with report-uri and sandbox.
The keyword 'none' must stand alone in a directive: the CSP3 specification says it has no effect when other source expressions are present, so a list such as 'none' https://example.com matches example.com. And once a directive lists a source, everything unmatched is blocked, including things you forgot you depended on: web workers, blob: URLs for generated files, a data: image. This is the strict preset from the site's CSP generator.
default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests
Nonces, hashes and unsafe-inline: three ways to allow inline script, and why the weakest one persists
With script-src 'self', inline script blocks and inline event handlers are refused. Allowing specific ones takes one of three mechanisms. 'unsafe-inline' allows all of them, including whatever an attacker injects. A nonce is a random value the server generates for each response, puts in the header as 'nonce-...', and copies onto each legitimate script tag. A hash lists a SHA-256, SHA-384 or SHA-512 digest of the exact script text; MDN adds that external scripts need a matching integrity attribute for this to work. The CSP specification says a nonce must be unique every time a policy is sent and should be at least 128 bits before encoding, from a cryptographically secure generator.
Two interactions matter. When a directive contains a nonce or a hash, browsers ignore 'unsafe-inline' in it, which is why a policy can carry both: newer browsers enforce the nonce, and a browser too old to understand nonces falls back to the permissive keyword. And 'strict-dynamic', per the specification, makes the browser ignore host and scheme sources plus 'self' and 'unsafe-inline' for script, honoring only nonces and hashes and letting a trusted script load further scripts. That is what lets a nonce policy survive third-party tag managers without a long allowlist.
This site's own policy keeps 'unsafe-inline' in script-src, and the reason is documented in lib/security/csp.ts. A per-request nonce was implemented and measured, then reverted: the prerendered pages carry inline bootstrap script tags baked in at build time with no request to mint a nonce for, so a nonce policy blocks every one of them and the page never hydrates. A nonce means rendering each page per request, which defeats prerendering. The script-src list therefore limits which hosts may serve script but gives no protection against injected inline script; that containment sits in the site's HTML sanitizers instead. The same file restricts a script CDN to a version-pinned path prefix rather than the bare host, because a whole-host allowlist for a service that serves arbitrary packages is a known way around a CSP.
high script-src 'unsafe-inline' in script-src defeats much of the protection CSP provides. high img-src A wildcard (*) in img-src allows any host and makes the directive close to meaningless. low frame-ancestors Without frame-ancestors the page can be embedded by any site (clickjacking risk). low base-uri Without base-uri an injected <base> tag can redirect every relative URL on the page.
Worth knowing. The security headers checker and the CSP generator's analyzer both flag 'unsafe-inline' in script-src even when a nonce sits beside it. Browsers that support nonces ignore the keyword there, so those warnings describe the fallback for older browsers, not what current ones enforce.
Rolling out a policy: report-only mode and the breakage it cannot predict
A new policy is the header most likely to break a working page, because it blocks things nobody remembered were loading. Content-Security-Policy-Report-Only evaluates the policy and reports violations without blocking anything. Both headers can be present in one response, so you can enforce the current policy while testing a stricter one. Reports go out only if the policy names a reporting target: MDN describes report-to, paired with a Reporting-Endpoints header, as the recommended method, and the older report-uri as deprecated. The report-only header is not supported inside a meta element.
Some breakage never appears in a trial run, because it depends on what a third-party script loads later. This site's CSP source records one case: a third-party consent-prompt script that a vendor console can switch on with no deploy on the site's side. A policy that does not already name its hosts blocks it, and nothing on the page looks wrong to anyone outside the regions where the prompt is required. Keep watching the console or report endpoint after a policy ships.
Strict-Transport-Security: max-age, includeSubDomains, and the preload commitment
The header tells the browser to use only HTTPS for this host for the next max-age seconds. RFC 6797 defines it. It does not change how the current response is handled, only how future requests are made, and the browser refreshes the stored expiry each time it sees the header again. Over plain HTTP the header must be ignored, a value that does not match the grammar is ignored whole, and if a response carries more than one, only the first is processed. Unrecognized directives are skipped while the recognized ones still apply.
The gap HSTS leaves is the first visit: a browser that has never seen your site has no stored policy, so its first request can still be downgraded. includeSubDomains widens the policy to every subdomain. RFC 6797 section 14.4 explains why that matters: without it, a subdomain the browser holds no policy for can be pointed at an attacker's plain-HTTP server, and a cookie set for the whole domain is sent there even with the Secure flag set, provided the visitor clicks through a certificate warning. max-age=0 deletes the stored policy, and per the RFC includeSubDomains is ignored when it is zero. Removing the header does not remove the policy; the browser keeps the old one until it expires.
The preload list closes the first-visit gap by shipping known HTTPS-only domains inside the browser. The requirements at hstspreload.org are a valid certificate; a redirect from HTTP to HTTPS on the same host, if the site listens on port 80; HTTPS on every subdomain, including internal ones, with www required if a DNS record for it exists; and on the base domain a header with max-age of at least 31536000 (one year), includeSubDomains, and preload. The site's own warning is the reason to hesitate: inclusion cannot easily be undone, removal takes months to reach users, other browsers get no guarantee, and operators have discovered a subdomain that could not do HTTPS only after being listed. It recommends ramping max-age up through 5 minutes, 1 week and 1 month, waiting out each stage and watching for breakage before going higher. The same page now says that preloading itself is not recommended: Chrome and Safari upgrade HTTP navigations to HTTPS on their own, so the list only adds value when those upgrades fail in the presence of an active attacker.
This repo's HSTS preload checker evaluates the header rules and the first redirect hop from two requests to the bare domain: it confirms that the first hop from http:// goes to an https:// URL, without comparing the host. It cannot check every subdomain, which is the hard part of the commitment, and it cannot tell whether a domain is already on the list.
Grade C, score 61
Content-Security-Policy pass 25/25
Strict-Transport-Security fail 6/20 max-age is only 300s — well under the 6-month floor, so protection lapses quickly.
No includeSubDomains — a subdomain can still be served over plain HTTP.
X-Frame-Options pass 15/15 Not set, but CSP frame-ancestors covers framing in every modern browser.
X-Content-Type-Options pass 10/10
Referrer-Policy warn 5/10 "no-referrer-when-downgrade" still leaks the origin cross-site — strict-origin-when-cross-origin is the safe default.
Permissions-Policy fail 0/10 (absent)
Cross-Origin-Opener-Policy fail 0/5 (absent)
Cross-Origin-Resource-Policy fail 0/3 (absent)
Cross-Origin-Embedder-Policy fail 0/2 (absent)X-Content-Type-Options, X-Frame-Options and Referrer-Policy: three narrow jobs
X-Content-Type-Options has one valid value, nosniff, and MDN describes two effects. For requests whose destination is a script or a stylesheet, the browser blocks the response if the Content-Type is not a JavaScript type or text/css. For everything else it uses the declared Content-Type as given instead of guessing from the bytes. It stops a user-uploaded file served as text/plain from being treated as HTML because its contents look like it, and it only helps if the Content-Type you send is correct.
X-Frame-Options accepts DENY or SAMEORIGIN. ALLOW-FROM was meant to name one permitted parent, but MDN says modern browsers ignore the header entirely when they see it, so it fails open. The replacement is the CSP frame-ancestors directive. Neither can be set from a meta element. This site sends both X-Frame-Options: DENY and frame-ancestors 'none'.
Referrer-Policy controls the Referer header sent on navigation and subresource requests. MDN states that when no policy is set or the value is invalid, the default is strict-origin-when-cross-origin (older versions used no-referrer-when-downgrade before the November 2020 spec change): full path and query to the same origin, only the origin to other HTTPS origins, nothing to less secure destinations. One quirk of this repo's checker: it scores an absent Referrer-Policy as 0 of 10 even though the browser default is the same value this site sends.
Permissions-Policy: deny what the site never uses, and what a too-tight rule breaks
Permissions-Policy gates browser features (camera, microphone, geolocation, USB, payment and dozens more) for the page and for every frame inside it. The syntax is a comma-separated list of directive=allowlist pairs. An allowlist is an empty pair of parentheses to disable the feature everywhere, self for this origin, quoted origins for specific hosts, or * for everyone. Each directive has its own default when omitted, so an absent header is not the same as a denied feature.
This site's header sets an allowlist for twenty features, almost all of them empty, and delegates autoplay and encrypted-media to two YouTube hosts for its embed preview tools. The config records a real failure: camera was first denied with an empty list, which made the color picker's From Camera tab always fail, with an alert that blamed the visitor for a header the site had set. It now reads camera=(self).
COOP, COEP and CORP: what cross-origin isolation switches on, and what it breaks
Three headers with similar names do different jobs. Cross-Origin-Opener-Policy sets how a document shares its browsing context group with pages that open it or that it opens. same-origin cuts the window reference to other origins; same-origin-allow-popups keeps a reference to popups it opens itself; unsafe-none is the default. Cross-Origin-Embedder-Policy decides whether the page may load cross-origin resources that have not opted in: require-corp allows a no-cors cross-origin resource only if it sends a permissive Cross-Origin-Resource-Policy, and credentialless allows it but strips cookies and credentials. Cross-Origin-Resource-Policy is the opt-in on the resource itself, with same-origin, same-site or cross-origin.
Together, COOP same-origin and COEP require-corp make a page cross-origin isolated. web.dev's guide lists what that unlocks: SharedArrayBuffer, performance.measureUserAgentSpecificMemory(), and higher-precision timers, and you verify it by reading self.crossOriginIsolated, which should return true. Every cross-origin subresource must then opt in or be loaded with CORS, and the same guide notes that COOP same-origin breaks integrations that need a cross-origin window reference, such as OAuth and payment popups. MDN adds that CORP only applies to no-cors requests, and that same-site lets any origin on the same site load the resource, which is looser than same-origin.
This site sends COOP same-origin and CORP same-site, and no COEP, so it is not cross-origin isolated. The config explains the choice of same-site: crawlers and social platforms fetch the preview image and icons cross-origin, and same-origin would block them. The checker gives these three headers 5, 3 and 2 points out of 100, against 25 for CSP and 20 for HSTS. That weighting is the tool's opinion, not a standard.
Checking a live site: what each tool reads, and where it can mislead you
The security headers checker makes a GET request, follows up to five redirects and grades nine headers on the final response, scoring weak values rather than only absent ones, which is why the HSTS example above lands at 6 of 20 instead of passing. It reads only the URL you give it, and different paths can send different headers. It does not model nonce-plus-fallback policies, as noted earlier, and a correct header says nothing about whether the app still works under it.
The HTTP headers lookup shows the final response's headers plus the redirect hops that led to it, but it sends HEAD requests, so a server that answers HEAD differently from GET will show different headers than a browser sees. The mixed content checker scans the HTML text for http: subresources, labeling scripts, frames and stylesheets as active content and images, audio and video as passive; MDN says current browsers block the first group and try to upgrade the second to HTTPS. It does not run JavaScript, so a script that inserts an http: URL later is invisible to it. The CSP generator builds a header from eleven directives, and lacks fields for frame-src, worker-src or reporting, so a policy like this site's needs those added by hand.
Sources
- Content Security Policy Level 3 (W3C)
- RFC 6797: HTTP Strict Transport Security (HSTS)
- HSTS Preload List submission requirements and deployment recommendations
- Content Security Policy (CSP) guide on MDN
- A guide to enable cross-origin isolation (web.dev)
- X-Content-Type-Options header reference (MDN)
- X-Frame-Options header reference (MDN)
- Referrer-Policy header reference (MDN)
- Permissions-Policy header reference (MDN)
- Cross-Origin-Opener-Policy header reference (MDN)
- Cross-Origin-Embedder-Policy header reference (MDN)
- Cross-Origin-Resource-Policy header reference (MDN)
- Mixed content (MDN)