A Content-Security-Policy header is long, full of quoted keywords, and hard to read quickly — which means it tends to be graded on appearance. A policy that fills three lines looks like a policy that is doing something. Two specific mistakes make that impression wrong, and neither produces an error anywhere.
unsafe-inline defeats most of the point
The main thing a CSP is for is stopping injected script from running. 'unsafe-inline' in script-src permits any inline <script> element to execute — which is exactly what an injected script is. With it present, an attacker who can get markup onto your page can run code, policy or no policy.
# Looks like a policy. Barely is one.
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'It is usually there because something on the page needs an inline script and removing it was more work than adding the keyword. The fix is a per-response nonce: generate a random value, put it on the header and on the script tag, and the browser runs that script and no other.
Three directives do not fall back to default-src
Most fetch directives inherit from default-src when unset, which makes default-src feel like a floor. Three do not inherit from anything: base-uri, frame-ancestors and form-action. A policy that sets only default-src reads as complete and leaves all three entirely unrestricted.
base-uriunset — an injected<base>tag repoints every relative URL on the page, including script sources, without violating the rest of the policy.frame-ancestorsunset — anyone may frame the page. This is the modern replacement for X-Frame-Options, and its absence is a clickjacking opening.form-actionunset — an injected form may post anywhere it likes, including a server the attacker controls.
Content-Security-Policy:
default-src 'none';
script-src 'self';
style-src 'self';
img-src 'self';
connect-src 'self';
base-uri 'none';
frame-ancestors 'none';
form-action 'self'Smaller traps worth knowing
A misspelled directive is ignored silently, so a typo leaves whatever it was meant to restrict completely unrestricted — there is no error and no warning. 'none' combined with any other source is meaningless, because 'none' means nothing at all and cannot be combined. A repeated directive is honoured once and ignored thereafter, so the second copy is dead.
'strict-dynamic' is the sharpest of these. It discards every host source in the directive and trusts only script loaded by already-trusted script. Without a nonce or a hash to establish that initial trust, nothing is trusted and nothing loads — which usually gets diagnosed as a broken deploy rather than a policy error.
There is no useful score for a policy. It is either appropriate for the page it protects or it is not, and a number invites tuning the number rather than closing the gap. What is worth having is a list of what each directive does and what is missing, which takes about a minute to read and is the whole review.