Most policies allow more than their author thinks.
Paste a Content-Security-Policy and see what a browser will actually let through. Nothing is sent to us: the policy is parsed in your browser, so you can check a header you have not deployed yet.
What this checks, and what a good answer looks like
Which directive actually governs your scripts
Browsers use script-src if it is present and fall back to default-src if it is not. That fallback is a common source of surprise: a policy with a strict default-src and a permissive script-src is exactly as permissive as the second one, and the strict line above it does nothing for scripts. We show you which directive we read so the verdict is checkable rather than something you have to take on trust.
unsafe-eval
Permits turning strings into executable code, which is what most injection payloads rely on. It is usually present because a bundler setting or a templating library asked for it, and it is usually removable by changing that setting. While it is there, a large part of the rest of the policy is decoration.
Wildcards, and when they do not count
A bare *, or a scheme source like https:, permits script from any host on the internet. But a host wildcard sitting next to 'strict-dynamic' is not a hole, because that keyword tells the browser to ignore host sources entirely and trust only what a nonce-approved script loads. We check for whole tokens rather than searching the text, so https://cdn.example.com/* is correctly read as a specific host and not as a wildcard just because it contains a star.
unsafe-inline, and when it is already ignored
The most common reason a strict-looking policy does not hold. It permits inline scripts, so injected markup executes. The nuance that trips up most checkers: if the same directive carries a nonce or a hash, browsers ignore 'unsafe-inline' completely. Policies are deliberately written that way so that older browsers, which do not understand nonces, still load the page. Flagging that shape would be a false positive on the single most common correct strict policy, so we report it as fine and say why.
base-uri, the one that is almost always missing
base-uri is not covered by default-src, so a policy can look complete and still omit it. Without it, an injected <base> tag repoints every relative script and link on the page at another host, which turns a small HTML injection into full script execution and neatly steps around a script-src you worked hard on. It costs one directive.
object-src and frame-ancestors
object-src 'none' closes plugin content, which almost no application needs and which can otherwise run a document in your origin. frame-ancestors decides who may embed your page; it is the modern replacement for X-Frame-Options and the only one of the two that can express a list of allowed origins.
How to fix it
A strict policy is easier to reach than its reputation suggests, as long as you deploy it in report-only mode first and read what breaks. The nonce approach below is what this very site uses, so it is a pattern we run in production rather than one we merely recommend.
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{RANDOM}' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
object-src 'none';
base-uri 'self';
frame-ancestors 'none'
# 'strict-dynamic' lets a nonce-approved script load what it needs
# without you listing every CDN, which is what makes a strict policy
# survivable in a real app with a bundler.# Same policy, different header name. Browsers report violations
# to the console and to report-to, and block NOTHING. Run it for a
# week, read what shows up, then rename the header.
Content-Security-Policy-Report-Only:
default-src 'self'; script-src 'nonce-{RANDOM}' 'strict-dynamic'; object-src 'none'// The nonce must be new on EVERY request. A nonce baked into a
// static file is not a nonce, it is a password the attacker also has.
const nonce = crypto.randomUUID().replaceAll("-", "");
const policy = [
"default-src 'self'",
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
].join("; ");
// Then stamp the same nonce onto every <script> as you stream the HTML.
return new HTMLRewriter()
.on("script", { element: (el) => el.setAttribute("nonce", nonce) })
.transform(new Response(html, { headers: { "content-security-policy": policy } }));// middleware.ts
import { NextResponse } from "next/server";
export function middleware(request: Request) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const policy = `default-src 'self'; script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'`;
const headers = new Headers(request.headers);
headers.set("x-nonce", nonce);
const response = NextResponse.next({ request: { headers } });
response.headers.set("content-security-policy", policy);
return response;
}This reads the policy you pasted. It cannot tell you whether that is the policy your server actually sends, and the two disagree more often than you would expect: a proxy, a CDN rule or a second framework-level header can override or duplicate it, and when two policies arrive the browser enforces the intersection of both. It also cannot tell you whether the policy breaks your app, which only a report-only deployment will. To see what your live site sends, run the security headers checker against the URL.