sitekits.dev
csp

CSP tools

Content-Security-Policy in practice: what each directive governs, how nonces, hashes and source expressions match, and how to roll a policy out with Report-Only.

1 tools

§01 FIELD GUIDE

What a policy actually is

Content-Security-Policy is a response header made of directives, each holding a list of source expressions. Before the browser fetches a subresource, runs inline code, submits a form, or lets the page be framed, it consults the governing directive; if nothing matches, the request never happens and a violation is reported. Enforcement happens in the browser, and a policy only removes capabilities, never grants them.

Matching works on origins — scheme, host, port — not on the URL you had in mind: 'self' on https://app.example.com covers neither https://cdn.example.com nor its subdomains. A policy is also worth only as much as its weakest escape hatch: script-src 'self' 'unsafe-inline' hands anyone who can inject markup a working script tag — the attack CSP exists to stop. Replace 'unsafe-inline' with a per-response nonce or a content hash.

Policies compose by intersection, never union: when a response carries two Content-Security-Policy headers — yours plus one a CDN added — a resource must satisfy both, so an extra header only tightens the page.

Directive reference

DirectiveGovernsdefault-src fallback?
script-srcscript elements, eval, inline handlersyes
style-srcstyle elements, stylesheet links, @import, style attrsyes
connect-srcfetch, XMLHttpRequest, WebSocket, EventSource, sendBeaconyes
img-src / font-src / media-src / manifest-srcimages and srcset; webfonts; audio, video, track; app manifestyes
object-srcobject and embed — always 'none'yes
frame-src / child-src / worker-srcnested documents; workersyes, workers via child-src
base-uri / form-actionvalues a base tag may set; where forms may submitno
frame-ancestorswho may embed this page; supersedes X-Frame-Optionsno
sandbox / require-trusted-types-forsandbox flags on this document; DOM XSS sinksno
report-uri / report-towhere violation reports are POSTedno

The right column bites: default-src 'self' still leaves base-tag hijacking, form exfiltration and clickjacking open. script-src-elem and script-src-attr split elements from inline handlers.

Source expressions

ExpressionMatchesWatch out
'self' / 'none'the document’s exact scheme, host and port / nothingnot subdomains; 'none' is void beside any other value
https:any host on that schemeevery CDN on the internet
https://cdn.example.comthat origin, default port impliedpath prefixes are not enforced across redirects
*.example.com / *any subdomain, any depth / any hostnot bare example.com; * excludes data:, blob:
'nonce-…'elements carrying the matching nonce attribute128+ random bits, fresh per response
'sha256-…'inline code whose exact bytes hash to thisone byte of whitespace changes the hash
'strict-dynamic'scripts created by an already-trusted scripthost and scheme sources become ignored
'unsafe-inline'any inline script or styleignored when a nonce or hash is present
'unsafe-eval' / 'unsafe-hashes'eval and new Function / hashes on onclick, style'wasm-unsafe-eval' is the narrow version

Rolling out with Report-Only

Ship the candidate as Content-Security-Policy-Report-Only: evaluated identically, blocking nothing, and servable next to an enforcing policy, so you can tighten a second while the live one protects users. Collect violations with report-uri /csp-reports (deprecated, universally supported) or report-to, which needs a Reporting-Endpoints header; report delivery is exempt from connect-src. Cross-origin blocks collapse to an origin in blocked-uri, naming the host refused, not the file; add 'report-sample' for a code snippet and group by effective-directive.

Which tool for which job

When a site is live, the question is what policy is actually delivered. The HTTP Headers Checker fetches the URL from api.sitekits.dev and returns status, redirect count and every response header, never the body — revealing a proxy that rewrote your policy. Internal addresses are refused by its SSRF guard.

If you are drafting instead, the Content Security Policy Generator gives 14 directive fields over a hardened default-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none' baseline, plus an upgrade-insecure-requests checkbox that is on by default — so an untouched form already emits that baseline with upgrade-insecure-requests appended. The string is rebuilt as you type, in the browser. Directives outside those fields (report-to, sandbox, require-trusted-types-for) get appended by hand.

Allowlisting an existing app is an inventory problem: export a HAR from DevTools, read it in the HAR File Viewer, then run surprising URLs through the URL Parser to reduce each to the scheme-host-port form a source expression needs. Sanitize with the HAR File Sanitizer before attaching it to a ticket — HARs carry cookies and Authorization headers. The Text Diff Checker shows what changed between report-only and enforcing versions; the JSON Formatter makes a csp-report payload readable; and the REST API Tester calls your target directly from the browser, with no sitekits server in the path — and under its page policy, which relaxes connect-src to 'self' https:, not under yours. A call that succeeds there but fails inside your app points at your own header. It will not name the cause: a connect-src block and a CORS rejection both surface as one fetch TypeError, and the tool prints a single message covering both. Also: HTTP hub, security hub, security engineer toolkit.

Common breakages

Adding a nonce silently disables 'unsafe-inline'

Tag managers and chat widgets that inject their own script tags stop executing, since they never see your per-response value; 'strict-dynamic' is the fix.

CSP hashes are base64 of the digest, not hex

'sha256-…' wants base64 of the raw 32 digest bytes, so hex from the Hash Generator run through Base64 Encode / Decode gives a wrong string. Copy it from the browser’s console error.

A hard-coded nonce is 'unsafe-inline' with extra steps

Nonces must be regenerated per response, which makes them server-side; a constant in a template — or HTML cached at the edge while the header regenerates — is guessable. The UUID v4 Generator suits local hand-testing, never a rollout.

A meta-tag policy cannot express half of CSP

frame-ancestors, sandbox, report-uri and Report-Only are ignored in meta http-equiv, and the policy covers only markup after it.

FAQ
Why is my inline script still blocked when script-src has 'unsafe-inline'?
Because the same directive also contains a nonce or a hash. Browsers deliberately ignore 'unsafe-inline' whenever any 'nonce-…' or 'sha256-…' source appears in that directive, so the fallback silently stops applying. Either stamp the current nonce onto the inline script, add its hash, or add 'strict-dynamic' so scripts loaded by an already-trusted script inherit trust.
Does default-src cover every directive?
No, and this is the most common hole in an otherwise strict policy. base-uri, form-action, frame-ancestors, sandbox, report-uri and report-to do not fall back to default-src, so default-src 'self' alone still permits base-tag hijacking, form submission to any host, and framing by any site. Those have to be written out explicitly.
How do I deploy CSP without breaking production?
Send the candidate as Content-Security-Policy-Report-Only, which is evaluated exactly like the real header but blocks nothing and can be served alongside an enforcing policy. Collect reports for one to two weeks, then swap the header name. Expect a large share of unactionable noise from browser extensions, which inject inline scripts into your pages.
Should I use a nonce or a hash for inline scripts?
Use a nonce when HTML is generated per response, since the value must be unpredictable and fresh every time. Use hashes when the HTML is static or cached at a CDN, because a hash needs no server-side randomness — but it covers the exact bytes of the inline block, so one changed space invalidates it. Both pair with 'strict-dynamic', which propagates trust from whatever the directive already allowed: script-src 'sha256-…' 'strict-dynamic' https: 'unsafe-inline' is the documented hash-based strict policy for that static case. Trust reaches only scripts the hashed block creates at runtime, not tags already in the markup.
Why does my violation report name only a domain in blocked-uri instead of the file that was blocked?
Because cross-origin violations are deliberately stripped to the origin — reporting the full URL would hand the page information CSP just stopped it from reading. You learn which host was refused, not which resource, and after a redirect you may get nothing but the initial origin. Add 'report-sample' to the directive so reports carry a short snippet of the offending code, and group by effective-directive to see which rule is actually firing. Same-origin violations are not truncated, so those reports do carry the full path.