Ten headers decide how much a bug can cost you.
Enter a URL and we will read the response headers your server actually sends. Each one below is a single line of config, and each one turns a whole class of attack from possible into impossible.
What this checks, and what a good answer looks like
Content-Security-Policy
The one that matters most, and the one almost no AI-generated app ships. It tells the browser where JavaScript may come from. Without it, any place your app renders text somebody else supplied, a display name, a comment, a product description pulled from an API, becomes a place code can run with full access to the session. A good answer starts at default-src 'self' and widens deliberately. A bad one is missing entirely, or so wide it permits everything it was added to stop.
unsafe-eval, wildcards and unsafe-inline inside that policy
A policy can be present and still be decoration. 'unsafe-eval' lets the page turn strings into running code, which is the mechanism most injection relies on. A bare * or https: in the script sources means code may load from any host on the internet. 'unsafe-inline' means injected markup executes. The nuance worth knowing: 'strict-dynamic' makes browsers ignore host sources, and a nonce or hash makes them ignore 'unsafe-inline', so a policy carrying both is correct rather than contradictory. We apply that rule rather than pattern-matching the string.
X-Frame-Options and frame-ancestors
Whether someone can load your app inside an invisible frame on their own page, cover it with their own buttons, and collect the clicks. It costs an attacker nothing and it works against any page where a click does something meaningful: confirming a payment, changing a permission, deleting an account. Either header closes it. frame-ancestors is the modern one and can name allowed origins; X-Frame-Options: DENY is the blunt version older browsers still honour.
X-Content-Type-Options
One value, nosniff, and it stops browsers guessing at a file's type when the declared type looks wrong. That guessing is how an uploaded file that your server calls a plain text document gets executed as script. If your app accepts uploads at all and serves them back from your own domain, this line is not optional.
Referrer-Policy
How much of the current URL travels with every outbound click and every third-party request. The default on many stacks sends the full path, which leaks anything you put in a URL: password reset tokens, invitation links, document ids, search terms. strict-origin-when-cross-origin keeps the useful part and drops the rest.
Permissions-Policy
Which browser capabilities the page and anything it embeds may reach for: camera, microphone, geolocation, payment. Setting it to deny what you do not use means a compromised third-party script cannot quietly turn one on. Most apps need none of them, which makes this one of the cheapest lines in the file.
Server version disclosure
Whether your responses announce the exact version of what is running. nginx alone tells an attacker nothing they could not guess. nginx/1.18.0 tells them precisely which advisories to go and read. This is not a vulnerability on its own, it is the step before one.
Access-Control-Allow-Origin
Which other sites are allowed to read your responses. A wildcard here is usually somebody solving a CORS error at three in the morning, and it means any page on the internet can call your API and read what comes back. It is worth checking every time you deploy, because this is the setting most likely to have been widened in a hurry and never narrowed again.
How to fix it
Every one of these is set in one place, and on most hosts that place is a single config file. The policy below is a starting point that is strict enough to be worth having and loose enough to boot a typical app: begin here, watch the browser console, and widen only where something you actually need is blocked.
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Content-Security-Policy", "value": "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" },
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }
]
}
]
}# public/_headers
/*
Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()// next.config.js
module.exports = {
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "Content-Security-Policy", value: "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
],
},
];
},
};export default {
async fetch(request, env) {
const response = await env.ASSETS.fetch(request);
const headers = new Headers(response.headers);
headers.set("content-security-policy", "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'");
headers.set("x-frame-options", "DENY");
headers.set("x-content-type-options", "nosniff");
headers.set("referrer-policy", "strict-origin-when-cross-origin");
headers.set("permissions-policy", "camera=(), microphone=(), geolocation=()");
return new Response(response.body, { status: response.status, headers });
},
};import helmet from "helmet";
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
},
},
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
}),
);Headers are the cheapest security work there is, which is exactly why fixing them proves so little. A perfect score here says your responses are configured well. It says nothing about whether the query behind your dashboard checks who is asking, whether a dependency you installed last spring has a known hole, or whether an API key is sitting in your git history from a commit six months ago. Those live in the code, and no header can compensate for them.