Security headers check: grade your site, then copy the fix
A security headers check reads the HTTP response headers your site sends to browsers and reports which protective ones are present, missing, or weakly configured. It covers Content-Security-Policy, Strict-Transport-Security, clickjacking protection, MIME-sniffing, referrer and permissions policy. It grades your browser-facing defences only. It does not test your database, your auth, or your API keys.
Nothing checked yet. Paste a URL and this fetches your site live, then grades the headers that come back.
What this checks
Eleven checks, run against the live response your visitors get. Each one is a header the browser acts on, or the transport that carries it.
- Content-Security-Policy. Controls which resources the browser is allowed to load for the page. Without it, an injected script runs with the same privileges as your own code.
- Strict-Transport-Security. Tells the browser this host is HTTPS-only, for a stated number of seconds. Without it, a first visit over http:// can be downgraded and read in transit.
- Clickjacking control (CSP frame-ancestors, or X-Frame-Options). Decides who may put your page inside an iframe. Without it, anyone can frame your app and overlay their own buttons on your controls.
- X-Content-Type-Options: nosniff. Forces the browser to respect the Content-Type you sent instead of inferring one from the bytes. Without it, user-uploaded content can be executed as an HTML document.
- Referrer-Policy. Controls how much of the current URL is sent to the next site. Missing is mild, browsers already default to strict-origin-when-cross-origin. The real problem is unsafe-url, which sends the path and query string to insecure origins.
- Permissions-Policy. Allows and denies browser features in your document and in any iframe inside it, camera, microphone, geolocation. Without it, an embedded frame inherits whatever your page is allowed to do.
- Cross-Origin-Opener-Policy. Controls whether a document opened from your page shares a browsing context group with it. The default is unsafe-none, which leaves references between the two documents intact, the opening for the class of attacks called XS-Leaks.
- Cross-Origin-Resource-Policy. States which origins may embed this response at all. Absent is not a hole here, present and scoped is a bonus.
- Cookie flags on Set-Cookie. HttpOnly, Secure and SameSite on the cookies your site hands out. A session cookie without HttpOnly is readable by any injected script, which turns a small XSS into a full account takeover.
- CORS (Access-Control-Allow-Origin and Access-Control-Allow-Credentials). Decides which other websites may read your responses in a signed-in visitor's browser. Reflecting the caller's own Origin while allowing credentials means any page your user visits can read their private data from your app.
- HTTPS, and the http:// redirect. Every header above is only a promise until it arrives intact. An HSTS header returned over plain HTTP is ignored by the browser outright.
This tool grades leakrank.com the same way it grades you. Our own CSP carries 'unsafe-inline' in script-src, because Next.js injects inline bootstrap scripts and the nonce alternative disables static optimization. That costs us 20 points and it keeps the CSP credit. A weak CSP is better than no CSP, and any tool that tells you otherwise is grading a slogan.
Where to add these headers
Next.js ships none of these by default. That is not an oversight you caused, it is an open request on the framework, and Vercel's own conformance rule NEXTJS_MISSING_SECURITY_HEADERS exists because of it. Pick your platform, paste the block, redeploy, run the check again.
Vercel and Next.js: next.config.mjs
// next.config.mjs
const CSP = [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
// 'unsafe-inline' here is required for the App Router without a nonce: Next injects an
// inline bootstrap script and inline streaming chunks, and 'self' alone blocks hydration.
// This is the shape Next's own docs give for the no-nonce case.
"script-src 'self' 'unsafe-inline'",
"connect-src 'self'",
"form-action 'self'",
].join("; ");
const securityHeaders = [
{ key: "Content-Security-Policy", value: CSP },
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "X-Frame-Options", value: "DENY" }, // legacy belt-and-braces; frame-ancestors above is what modern browsers use
];
export default {
async headers() {
return [{ source: "/:path*", headers: securityHeaders }];
},
};- source: '/:path*' matches every route. Next.js docs: "Headers are checked before the filesystem which includes pages and /public files."
- 'unsafe-inline' in style-src is deliberate. Next injects inline styles. It costs zero points in this grade and it is not a defect.
- 'unsafe-inline' in script-src is deliberate too, and it costs you 20 points. We ship it anyway because the alternative is worse for most apps. Without a nonce, script-src 'self' blocks the inline bootstrap script the App Router injects and your page never hydrates. Next.js documents exactly this config for the no-nonce case.
- The nonce alternative, and what it costs. The stricter version is script-src 'self' 'nonce-{value}' 'strict-dynamic', with the nonce generated per request in middleware. Next.js then attaches it to framework scripts, page bundles and its own inline tags for you. The price, in their words: all pages must be dynamically rendered, static optimization and ISR are disabled, pages cannot be cached at the edge by default, and Partial Prerendering is incompatible. Take it if you handle sensitive data or a compliance rule forbids 'unsafe-inline'. Otherwise the 20 points are the cheaper trade.
- A third option, still experimental. Next.js can hash your bundles at build time via experimental.sri, which keeps static generation and lets script-src drop 'unsafe-inline'. It is App Router only and marked experimental, so read their page before you rely on it.
- Add preload only if you intend to submit to hstspreload.org and you are certain about every subdomain. It is hard to undo.
Netlify: _headers
/* Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; form-action 'self' Strict-Transport-Security: max-age=63072000; includeSubDomains X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=() X-Frame-Options: DENY
- The file goes in your publish directory, the post-build output, not your source root.
- The underscore gotcha. Build tools that ignore files beginning with _, Jekyll and friends, drop _headers silently. You deploy, nothing changes, the checker still says missing. Netlify's docs call this out.
- This block is framework-neutral, so script-src is 'self'. If what you are publishing here is a Next.js App Router build, add 'unsafe-inline' to script-src as in the Vercel block above, or the page will not hydrate.
Cloudflare Pages: _headers
/* Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; form-action 'self' Strict-Transport-Security: max-age=63072000; includeSubDomains X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=() X-Frame-Options: DENY
- The file goes in your static asset directory, public/ or static/ for framework projects, otherwise the build output directory. Same syntax as Netlify.
- Limits: 100 header rules per file, 2,000 characters per line.
- The Functions gotcha. Custom headers in _headers do not apply to Pages Functions responses. If your API routes run as Functions, set the headers inside the function too, or your JSON endpoints ship naked while your HTML looks perfect.
- To strip a header rather than add one, prefix it with ! , for example ! X-Powered-By. That is the clean way to act on OWASP's headers-to-remove list.
- Same script-src caveat as Netlify: a Next.js App Router build needs 'unsafe-inline' in script-src, or a nonce.
Plain nginx
server {
listen 443 ssl;
server_name example.com;
# nginx refuses to start on a listen ... ssl block with no certificate
# ("no \"ssl_certificate\" is defined"), so point these at your real files.
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; form-action 'self'" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header X-Frame-Options "DENY" always;
server_tokens off; # stops nginx advertising its version in the Server header
}Both of these are the usual answer to "I added the header and the scanner still says it is missing":
- always is not optional. Without it, nginx only adds the header on responses 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308. Your 403 and 404 pages go out naked.
- Inheritance is all or nothing. The nginx docs: "These directives are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level." Add a single add_header inside one location {} block and every server-level header disappears for that location.
Headers rot quietly. One add_header inside a location block, one route moved to a Pages Function, and the config you just pasted stops applying to half your site. Nobody notices for months, because nothing breaks. Guard re-runs this check for you continuously.
How this is scored, and where it differs from Observatory
The weights come from MDN HTTP Observatory's published modifier table, the one in the mdn/mdn-http-observatory repository. You start at 100, penalties come off, and bonuses are added only if you are still at 90 or more after the penalties. The maximum is 145, which is Observatory's ceiling and ours. The result card prints that arithmetic line by line so you can check it against the rows.
Four rows deliberately do not match Observatory. They are the only four:
- Subresource Integrity is reported and never scored. Observatory charges up to 50 points for external scripts with no integrity attribute. Framework bundles are content-hashed and served from your own origin, so that one row would decide the grade of almost every Next.js app, for a risk it does not carry.
- X-Frame-Options: ALLOW-FROM costs 20 points here, and nothing at Observatory. No browser shipping today honours ALLOW-FROM. A site carrying only that header has exactly as much clickjacking defence as a site carrying no header at all, so it is graded the same way.
- A clean script-src still earns +5 when style-src carries 'unsafe-inline'. Observatory buckets that shape at zero. Inline styles are not an XSS vector on their own, and every mainstream framework emits them.
- Permissions-Policy is worth +5, which is our editorial call. Observatory has no row for that header at all. Cross-Origin-Opener-Policy is in the same position and we score it zero, because same-origin severs window.opener and paying for it would push you toward a setting that breaks OAuth and payment popups.
Everything else traces to the table, including the 20 points for 'unsafe-inline' in script-src rather than the 25 for no CSP at all. Those are two separate published rows, not a softening we invented.
What a good grade does not mean
An A+ here means your HTTP response headers are well configured. That is the whole claim, and it is one layer.
Trap 1: "an A+ on a header scanner means the site is secure." It does not. This tool runs the eleven checks listed above, and no header checker, this one included, opens your database, reads your API routes, checks your auth, or looks at what is in your JavaScript bundle. The grade describes how you talk to browsers.
Trap 2: "headers protect the database." They do not. Not one of these headers is ever evaluated by your Postgres or your Firestore. A Supabase project with row-level security switched off is fully readable with the public anon key, whatever your CSP says. Supabase's own documentation frames the publishable key as safe to expose because access is checked against RLS policies. With RLS off, that condition is gone and the key is just a key.
That is the check this tool cannot run for you.
FAQ
How do I check my website's security headers?
Paste your URL into the tool above. It fetches your site and grades what comes back. To do it by hand, run curl -I against your URL, or open DevTools, go to the Network tab, click the document request and read Response Headers. Both show exactly what this tool reads.
Which security headers do I actually need?
Content-Security-Policy, Strict-Transport-Security, clickjacking protection (CSP frame-ancestors or X-Frame-Options) and X-Content-Type-Options: nosniff are the load-bearing ones. MDN's HTTP Observatory assigns them the heaviest penalties when they are missing. Referrer-Policy and Permissions-Policy are worth adding, though browsers already default Referrer-Policy to strict-origin-when-cross-origin, so a missing one is not a hole.
Do I still need X-Frame-Options if I have CSP frame-ancestors?
No. OWASP's clickjacking guidance quotes the CSP spec: if a page is delivered with an enforcing frame-ancestors directive, the X-Frame-Options header must be ignored. Keeping X-Frame-Options is harmless legacy support for very old browsers and nothing more. The ALLOW-FROM value is obsolete, and modern browsers that see it ignore the header completely, which leaves you with no clickjacking defence at all.
Should I add X-XSS-Protection?
No. MDN marks it non-standard and deprecated, and warns that in some cases X-XSS-Protection can create XSS vulnerabilities in otherwise safe websites. OWASP's recommended value is zero, meaning switch it off. Use a Content-Security-Policy that avoids unsafe-inline instead. Any checker that reports this header as missing is out of date, and this tool never does.
Does an A+ on a security headers scan mean my site is secure?
No. It means your response headers are well configured. Headers tell the browser how to behave, and they are never evaluated by your database. A Supabase or Firebase project with row-level security or rules switched off is readable by anyone holding the public key that already ships in your JavaScript bundle, whatever grade your headers get.