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.

Read-only. Three requests: a GET of your page, a CORS probe, and one hit on http:// to see where it redirects. Nothing is written, nothing is stored against your domain.

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.

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 }];
  },
};

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

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

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":

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:

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.