Most of the points a security headers scanner takes off a hosted blog are for HTTP response headers. Those come from the host. A theme only controls HTML, and only two of these policies work from HTML at all: a referrer policy via <meta name="referrer"> and a Content Security Policy via <meta http-equiv="Content-Security-Policy">.
HSTS, X-Frame-Options and X-Content-Type-Options only work as real headers. Browsers ignore them in a meta tag.
I run a blog host (JustBlogged), so people send me these reports and ask which lines they can fix themselves. On October 24, 2026 I sat down and actually checked, with the scanner and with a real browser. The part I didnt expect... MDN's HTTP Observatory gives you credit for a frame-ancestors directive in a meta tag, which browsers ignore. Your grade goes up and nothing about framing changes.
How the Observatory scores
The Observatory's scoring is public and its source is on GitHub. Every site starts at 100 and loses points per failed test. Bonuses only apply if you're still at 90 or above after penalties. For a typical hosted blog that means the grade is just 100 minus penalties.
I ran its public API against one blog per platform. Every scan hits the front page, so read these as platform defaults on that day:
| Blog tested | Platform | Grade | Where the points went |
|---|---|---|---|
| demo.ghost.io | Ghost(Pro), platform subdomain | F (20) | No CSP -25, no HSTS -20, no X-Frame-Options -20, no X-Content-Type-Options -5, referrer policy -5, SRI -5 |
| www.404media.co | Ghost(Pro), custom domain | F (20) | Same six deductions |
| en.blog.wordpress.com | WordPress.com | F (20) | No CSP -25, SRI -50, no X-Content-Type-Options -5 |
| octocat.github.io | GitHub Pages | F (10) | No CSP -25, no HTTPS redirect -20, no HSTS -20, no X-Frame-Options -20, no X-Content-Type-Options -5 |
| jamstack.org | Netlify | C- (45) | No CSP -25, no X-Frame-Options -20, no X-Content-Type-Options -5, SRI -5 |
| townhall.hashnode.com | Hashnode | C (50) | No CSP -25, no X-Frame-Options -20, no X-Content-Type-Options -5 |
| herman.bearblog.dev | Bear Blog | C (50) | No CSP -25, invalid X-Frame-Options -20, referrer policy -5 |
| on.substack.com | Substack | B (70) | CSP allows unsafe inline -20, no X-Content-Type-Options -5, SRI -5 |
A few rows are worth reading before you try to fix anything:
- Neither Ghost(Pro) blog sent a single security header, subdomain or custom domain. Ghost's head output includes
<meta name="referrer" content="no-referrer-when-downgrade">, which sends the full page URL to other HTTPS sites. The browser default isstrict-origin-when-cross-origin, so that tag is less private than sending nothing. Thats the -5. - WordPress.com's -50 is from scripts loaded with protocol-relative URLs like
//stats.wp.com/w.js. On an HTTPS page those still load over HTTPS, but the rule penalizes any external script without an explicithttps:. - The GitHub Pages site didnt redirect HTTP to HTTPS. Thats the "Enforce HTTPS" checkbox in the repo settings, so ticking it removes that -20.
- Bear Blog sends
X-Frame-Options: ALLOWALL, which isnt a defined value. The scanner calls it invalid, and in my Chromium test a page with that header framed exactly like a page with no header.
Which deductions a theme can remove
| Test | Typical penalty | Works from a meta tag? |
|---|---|---|
| Content Security Policy | -25 if missing | Yes, with limits. The CSP spec excludes frame-ancestors, report-uri and sandbox from meta delivery |
| Referrer Policy | -5 if unsafe | Yes, via <meta name="referrer">
|
| Strict-Transport-Security | -20 if missing | No. RFC 6797 section 8.5 tells browsers not to heed it in a meta element |
| X-Frame-Options | -20 if missing | No. Setting it in a meta tag has no effect |
| X-Content-Type-Options | -5 if missing | No. Header only |
| HTTP to HTTPS redirect | -20 if missing | No. Host or server setting |
| Subresource Integrity | -5 to -50 | Only for scripts your theme adds, not ones the platform injects |
So on a host that doesnt let you set headers, a theme can win back at most the CSP and referrer points. Thats 30 of the 80 Ghost(Pro) lost. The other 50 have to come from the host.
What Chromium actually does with each meta tag
I ran headless Chromium 153.0.8010.12 through Playwright 1.63.0, served test pages from one local origin and framed them from another. Each protection tried once as a header and once as a meta tag:
| Page sent | Loaded in a cross-origin iframe? |
|---|---|
| No header, no meta tag | Yes |
Content-Security-Policy: frame-ancestors 'none' header |
No, blocked |
| Same policy in a meta tag | Yes |
X-Frame-Options: DENY header |
No, blocked |
<meta http-equiv="X-Frame-Options" content="DENY"> |
Yes |
X-Frame-Options: ALLOWALL header |
Yes |
Chromium even tells you in the console:
The Content Security Policy directive 'frame-ancestors' is ignored when delivered via a <meta> element.
X-Frame-Options may only be set via an HTTP header sent along with a document.
The meta tags that are supposed to work did. <meta name="referrer" content="no-referrer"> removed the Referer header from an image request, and with two referrer meta tags on a page the second one won.
The scanner and the browser disagree
The Observatory doesnt treat the ignored frame-ancestors tag the way the browser does. Its HTML parser reads CSP meta tags, and the X-Frame-Options test passes whenever the merged policy contains frame-ancestors, without checking where it came from.
I ran the analyzer's test functions locally against a page whose only protection was a meta CSP with frame-ancestors 'none'. The X-Frame-Options result came back "implemented via CSP" at +5 instead of -20. Thats 25 points for a tag Chromium ignores.
I'd leave it out. It makes the report say your pages cant be framed when they still can.
A meta CSP on a platform that injects its own scripts
The CSP deduction only fully clears when script-src blocks inline scripts. In the analyzer, a policy allowing inline scripts still scored -20 and one limiting scripts to 'self' moved to +5.
On a hosted blog there are inline scripts whether you added them or not. I took a real post on a blog I control, inserted each candidate policy right after <meta charset>, and counted CSP errors in the console:
| Policy inserted | CSP errors |
|---|---|
| None | 0 |
upgrade-insecure-requests; object-src 'none'; base-uri 'self' |
0 |
default-src 'self'; script-src 'self' plus font, image and style sources |
7: three inline scripts, three inline styles, Cloudflare's analytics beacon |
Same, with 'unsafe-inline' added to style-src
|
4: three inline scripts and the beacon |
Two of those inline scripts were the blog's own site-level custom code. I could allow them with hashes, but then every edit means updating the hashes.
The third cant be allowed that way at all. Cloudflare's bot detection injects it, it carries a request ID and a timestamp, and its hash changed between two consecutive page loads. Cloudflare's JavaScript detections docs say it copies nonces from a CSP response header into the scripts it injects, and that nonces set through meta tags arent supported. So any blog behind Cloudflare with that feature on hits the same wall. Platform scripts for members, search or analytics are a smaller version of the same problem, since their hashes change whenever the platform updates them.
The policy I'd actually put on a hosted blog is the one with zero errors:
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests; object-src 'none'; base-uri 'self'">
<meta name="referrer" content="strict-origin-when-cross-origin">
It upgrades leftover http:// subresources, blocks plugin content, and stops injected markup from changing the base URL for relative links. The scanner still takes 20 points for it because scripts arent restricted. I think thats the right trade on a blog you dont fully control... none of it can break the page.
Put the CSP tag as high in <head> as you can. The spec says a meta policy doesnt apply to content before it, and in my test an inline script above the tag ran while the same script below it was blocked.
The referrer tag matches the browser default, so on most platforms it changes nothing. On Ghost it overrides the looser platform tag, as long as yours comes later in the page.
Where those tags go
- Ghost: Settings > Code Injection > Site header. Ghost writes its own referrer tag near the top of its head output, so view source after saving and check yours comes after it.
-
Netlify and Cloudflare Pages: both read a
_headersfile from the publish directory. These are real headers, so X-Frame-Options,frame-ancestorsand X-Content-Type-Options all work there. Cloudflare notes the file doesnt apply to responses from Pages Functions. - GitHub Pages: no custom headers and no headers file. Use the meta tags and tick "Enforce HTTPS".
-
Self-hosted WordPress: you control the server, so set real headers there. Core's
send_frame_options_header()sends X-Frame-Options andframe-ancestors 'self', but it's hooked to the login and admin screens, not public posts.
One caution even where you can set headers: HSTS with includeSubDomains and preload is hard to undo. Dont add preload on a whim.
When the grade is worth a host change
For a blog that just publishes posts, I think the protections that matter most to readers are the HTTPS redirect and HSTS. Both come from the host. If yours sends neither, thats worth raising with them.
CSP, X-Frame-Options and SRI deductions mostly measure how much of the response the host lets you control. Chasing them where you cant set headers ends in either a policy that breaks platform scripts or a meta tag that only fools the scanner.
If a specific grade is a hard requirement, like a client security review, you need a host where you write the headers yourself: Netlify or Cloudflare Pages with _headers, or your own server. And then check it in the Observatory and in the browser console, because as above, the two dont always agree
Top comments (0)