I Checked the Security Headers on 11 of My Own Sites. 0 Send HSTS, and 5 Can Be Framed by Any Page
A November census from Defaults.Exposed counted 182,295 domains that send at least one security header the browser reads and then throws away. A stray semicolon after SAMEORIGIN, an ALLOWALL that no browser honours, max-
A November census from Defaults.Exposed counted 182,295 domains that send at least one security header the browser reads and then throws away. A stray semicolon after SAMEORIGIN, an ALLOWALL that no browser honours, max-age= with nothing after it. The header is there, a checklist ticks it, and the page behaves as if it was never sent.
One line in there made me stop: of the domains sending the dead ALLOWALL value, 14,884 say Next.js in X-Powered-By. All 11 of the sites I was about to check are Next.js. So this morning I checked them, expecting to find a few broken values.
I didn't find broken values. I found missing ones, which is a different and kind of more embarrassing problem.
What I checked
11 sites: five launch platforms (Aura++, EarlyHunt, IndieHunt, MakerHunt, SideHunt), Uno Directory, four small press sites I started last week, and Posting Dude. Three URLs each, so 33 responses: the homepage, one inner page from the sitemap, and a made-up path that should 404. The 404 is there on purpose. Headers set in one route or one layout can easily miss the error pages.
Ran it on Tue 10 Nov 2026, 10:41 to 10:42 IST, with Node's built-in fetch and a browser user agent. I double-checked one press site with securityheaders.com at 10:43 IST and it agreed (grade F, all six headers missing).
One thing tripped me up before I even started. Three of my launch sites (EarlyHunt, IndieHunt, MakerHunt) answer plain curl with a 2-byte text/plain body that just says ok, and none of the page's headers. With a browser user agent they return the real HTML and the real headers. If I'd trusted the usual curl -sI one-liner I'd have written down "no headers at all" for sites that actually send most of them. So set a UA, or at least compare the content type you got back.
The results
| Header | Sites that send it (of 11) | Notes |
|---|---|---|
| Strict-Transport-Security | 0 | none of the 11 are on the HSTS preload list either (hstspreload.org API, 10:42 IST) |
| X-Content-Type-Options: nosniff | 6 | |
| Framing (X-Frame-Options or CSP frame-ancestors) | 6 | the other 5 can be put in an iframe by any page |
| Referrer-Policy | 6 | all 6 use strict-origin-when-cross-origin, which is the browser default anyway |
| Content-Security-Policy | 7 | all 7 allow inline scripts with 'unsafe-inline' and no nonce, 6 of them also allow 'unsafe-eval'
|
| Permissions-Policy | 1 | only Aura++ |
| X-Powered-By: Next.js | 11 | every single one |
A few things in there I didn't expect:
Zero discarded values. Not one malformed header across 33 responses. So the census problem (headers that look present but do nothing) wasn't mine. Mine was the boring version, where they're just not there.
200 and 404 matched everywhere. Each site sent the exact same set on the homepage, the inner page and the 404. That's actually a good sign: whatever sets them sets them for the whole app, so one change fixes every page.
Uno Directory has a CSP that covers almost nothing. It sends script-src and connect-src and stops there. No default-src, so images, styles, frames and objects aren't restricted at all. And no frame-ancestors, no X-Frame-Options, no nosniff. The funny part is that Uno hosts a free security headers checker. It would fail its own check.
The four press sites send nothing. No security headers at all, just X-Powered-By: Next.js. They launched last week and the headers just never got added.
No HSTS anywhere. This is the one that bugs me most. I wrote last week about Chrome now asking before it opens http:// links, and HSTS is exactly the thing that tells a browser to skip that http:// step on the next visit. I even ended that post with "add HSTS on hosts you control". All 11 sites are HTTPS, and none of them ask the browser to remember it.
The script
No dependencies, Node 18 or newer. Paste your URLs after it. It reads each header the way the spec says browsers do, instead of only checking if the name exists. So nosniff; fails, ALLOWALL fails, max-age=0 gets its own line, and a referrer policy weaker than the browser default gets flagged.
// headers-check.mjs usage: node headers-check.mjs https://a.com https://a.com/pricing
const POLICIES = ['no-referrer','no-referrer-when-downgrade','same-origin','origin',
'strict-origin','origin-when-cross-origin','strict-origin-when-cross-origin','unsafe-url'];
const hsts = v => {
if (v == null) return 'MISSING';
const parts = v.split(';').map(s => s.trim().toLowerCase()).filter(Boolean);
const ma = parts.filter(p => p.startsWith('max-age'));
if (ma.length !== 1) return 'DISCARDED (needs exactly one max-age)';
const raw = (ma[0].split('=')[1] ?? '').replace(/"/g, '').trim();
if (!/^\d+$/.test(raw)) return 'DISCARDED (max-age not a whole number)';
const secs = Number(raw);
if (secs === 0) return 'max-age=0 (tells browser to forget HSTS)';
return `ok ${secs}s${parts.includes('includesubdomains') ? ' +sub' : ''}`;
};
const xcto = v => v == null ? 'MISSING'
: v.split(',')[0].trim().toLowerCase() === 'nosniff' ? 'ok' : `DISCARDED (${v})`;
const framing = (xfo, csp) => {
if (/(^|;)\s*frame-ancestors\s/i.test(csp || '')) return 'ok (CSP frame-ancestors)';
if (xfo == null) return 'MISSING (any page can frame this)';
const vals = [...new Set(xfo.split(',').map(s => s.trim().toLowerCase()))];
if (vals.length > 1) return 'ok (mixed values block framing)';
return ['deny', 'sameorigin'].includes(vals[0]) ? `ok (${vals[0]})` : `DISCARDED (${xfo})`;
};
const referrer = v => {
if (v == null) return 'default (strict-origin-when-cross-origin)';
const known = v.split(',').map(s => s.trim().toLowerCase()).filter(t => POLICIES.includes(t));
if (!known.length) return `DISCARDED (${v})`;
const p = known.at(-1);
return ['unsafe-url', 'no-referrer-when-downgrade'].includes(p) ? `WEAKER than default (${p})` : `ok (${p})`;
};
const csp = v => {
if (v == null) return 'MISSING';
const d = Object.fromEntries(v.split(';').map(s => s.trim()).filter(Boolean)
.map(s => { const [k, ...r] = s.split(/\s+/); return [k.toLowerCase(), r.join(' ')]; }));
const n = [];
if (!('default-src' in d)) n.push('no default-src');
const script = d['script-src'] ?? d['default-src'] ?? '';
if (/'unsafe-inline'/.test(script) && !/'nonce-|'sha(256|384|512)-|'strict-dynamic'/.test(script)) n.push("'unsafe-inline' scripts");
if (/'unsafe-eval'/.test(script)) n.push("'unsafe-eval'");
if (!('base-uri' in d)) n.push('no base-uri');
return n.length ? `present, ${n.join(', ')}` : 'ok';
};
for (const url of process.argv.slice(2)) {
let res;
try { res = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0 headers-check' } }); }
catch (e) { console.log(`\n${url}\n fetch failed: ${e.cause?.code || e.message}`); continue; }
const h = n => res.headers.get(n);
console.log(`\n${url} -> ${res.status} ${h('content-type')}`);
console.log(' HSTS ', hsts(h('strict-transport-security')));
console.log(' nosniff ', xcto(h('x-content-type-options')));
console.log(' framing ', framing(h('x-frame-options'), h('content-security-policy')));
console.log(' referrer ', referrer(h('referrer-policy')));
console.log(' CSP ', csp(h('content-security-policy')));
console.log(' permissions ', h('permissions-policy') ? 'present' : 'MISSING');
if (h('x-powered-by')) console.log(' powered-by ', h('x-powered-by'));
}
It prints the content type on purpose, so a 2-byte text/plain answer like the one I got from plain curl jumps out. Run it against a real page and a 404, not just your homepage.
The fix list, in order
This is a list for small sites, not a bank. Nothing here was an emergency, and I don't want to pretend it was.
- nosniff and framing on the five sites that have neither. Zero risk of breaking anything. The press sites have sign-in and a submit flow, so being frameable by any page matters more there than on a static homepage.
-
HSTS on all 11, starting short. Something like
max-age=300first, then a day, then a year once nothing breaks.includeSubDomainsonly after I've checked every subdomain actually answers on HTTPS. The census found 682,019 domains sendingmax-age=0, which tells browsers to forget the rule, so check what you end up shipping. -
poweredByHeader: falseinnext.config.js. Tiny, but it's one line and it's on all 11. -
Fix Uno's half CSP, at least
default-src,object-src 'none',base-uri 'self'andframe-ancestors. -
The
'unsafe-inline'problem, later. The Next.js CSP guide is clear that nonces need dynamic rendering, because a static page is built before any request exists. 9 of my 11 homepages already come back withcache-control: private, no-store, so they render per request anyway and that cost mostly doesn't apply to me. It's still the biggest change on the list, so it goes last.
For the first four, Next.js lets you set them all in one place with headers() in next.config.js:
// next.config.js
module.exports = {
poweredByHeader: false,
async headers() {
return [{
source: '/:path*',
headers: [
{ key: 'Strict-Transport-Security', value: 'max-age=300' }, // raise later
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
],
}];
},
};
Then run the script again, including the 404 URL.
What I'm not doing
I'm not chasing an A+ grade. Missing headers are also one of the most common findings in those "I found a critical vulnerability on your site, how much is the bounty" emails, and a missing X-Frame-Options on a page with no logged-in actions is low impact on its own. I wrote up how I'd triage beg bounty emails and real security reports if you get those too. The point of fixing headers is that it's cheap, not that it's urgent.
Also, new headers keep arriving. Chrome 152 shipped a Connection-Allowlist header in September for locking down where a page can connect to. I'm not touching that until the four boring ones are in.
If you don't want to run Node, the free security headers checker on Uno Directory (a site I run, and yes, currently one that fails it) shows the same six headers for any public URL plus the OWASP suggested value next to each.
What did your 404 page send when you checked? And did anyone else find their framework's starter template was the reason a whole batch of sites shipped with nothing?
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.