A strict content security policy with an inline first paint
Inlining critical CSS and a small script is good for first paint and illegal under a strict CSP. Hashes fix it, if you compute them at the right moment.
Inlining the critical CSS and one small script is the cheapest first paint improvement there is. One request, no round trip, text on screen as soon as the HTML arrives. Then a strict content security policy goes on the same site and the page arrives unstyled, with the console repeating that it refused to apply inline style because it violates a directive. Both decisions were correct. They just have to be introduced to each other.
What actually happens
A content security policy lists where content may come from. When the policy has a style-src or script-src directive and that directive does not contain 'unsafe-inline', the browser refuses to run or apply anything written directly into the document. That is the entire point of the directive: an attacker who manages to inject a <script> tag into your markup gets the same refusal.
There are three ways to allow your own inline content back in.
'unsafe-inline', which allows yours and the attacker's equally. This removes the protection you added the policy for.- A nonce, a random value generated per response and repeated on the tag. The browser allows a block whose
nonceattribute matches the policy. - A hash of the content itself, written into the policy as
'sha256-...'. The browser hashes each inline block and allows the ones it recognises.
A nonce sounds like the modern answer, and for a dynamic response it is. For a static page it is theatre. A file on disk serves the same bytes to everyone, so the nonce is baked in at build time and anyone can read it in the page source. A constant nonce is 'unsafe-inline' with extra steps. If the page is rendered per request you can generate one properly, and then the nonce is the right tool.
The hash is what fits a page that does not change per request. The browser computes a SHA-256 of the characters between the opening and closing tag, base64 encodes it, and compares it against the list. The tags themselves are not part of the hashed content, and neither is anything outside them. This is exact: one extra newline at the end of the block is a different hash.
Two details save a lot of time later.
- A script element whose
typeis something likeapplication/ld+jsonis a data block. The browser never prepares it for execution, so the inline script check is never applied to it. Structured data works under a strict policy with no hash at all. - Inline event handlers and
styleattributes are a separate category. A plain hash does not cover them, and allowing them needs'unsafe-hashes', which is a good sign that the handler belongs in your script file instead.
How to see it
The console tells you which directive refused what, but the useful check runs against the deployed bytes rather than the source. Take the inline block out of the live page and hash it the way a browser would:
curl -s https://example.com/ \
| perl -0777 -ne 'print $1 while /<style>(.*?)<\/style>/gs' \
| openssl dgst -sha256 -binary | openssl base64
# 4Vp0Xg6Oq1lWQ2oR8pKb1Jm3nS7uYfD5cE9tGh2iZxQ=
curl -sI https://example.com/ | grep -i content-security-policy
# content-security-policy: default-src 'none'; style-src 'sha256-...'If the value from the first command does not appear in the second, you have found the bug without guessing. When they match and the browser still refuses, look at what is between the tags in the browser rather than in curl: an injected snippet from an edge layer or a proxy is the usual difference.
The fix
Compute the hashes from the final artefact, after every step that can touch the bytes. In a build that renders, minifies and then writes files, the order is render, minify, hash, write:
import { createHash } from 'node:crypto';
const sha = (s) => "'sha256-" + createHash('sha256').update(s, 'utf8').digest('base64') + "'";
const html = await minifyHtml(render(page), options);
const inline = [...html.matchAll(/<(script|style)>([\s\S]*?)<\/\1>/g)];
const script = inline.filter((m) => m[1] === 'script').map((m) => sha(m[2]));
const style = inline.filter((m) => m[1] === 'style').map((m) => sha(m[2]));The regex matches only tags with no attributes, which is exactly the set that needs a hash. A <script type="application/ld+json"> block has an attribute, so it is skipped, which is correct rather than lucky.
The policy itself starts from nothing and adds only what the page proves it needs:
default-src 'none';
base-uri 'none';
img-src 'self' data:;
font-src 'self';
style-src 'sha256-4Vp0Xg...';
script-src 'sha256-9Kd2Rz...';
connect-src 'self';
form-action 'none';
frame-ancestors 'none';
upgrade-insecure-requestsconnect-src 'self' is in that list because of a report I spent an hour on. An auditing tool said the site had no reachable robots.txt, while curl returned it immediately and the file was plainly there. The tool runs inside the page and asks for the file with a fetch from that document, and default-src 'none' with no connect-src refuses the fetch. The tool saw a failed request and reported an unreachable file. Nothing about the file was wrong, the policy simply did not allow the page to ask for anything.
The last piece is a guard in the build, because this class of bug ships silently:
for (const [file, out] of pages) {
const blocks = [...out.matchAll(/<(script|style)>([\s\S]*?)<\/\1>/g)].map((m) => m[2]);
for (const b of blocks) {
if (!csp.includes(sha(b))) throw new Error(`CSP hash mismatch in ${file}`);
}
}Now a minifier added later, a change to whitespace settings or a template that starts emitting a comment inside the style block all fail the build instead of failing the site.
How to check it worked
Compare the live header with the live bytes in one command and let it answer with a word:
p=$(curl -sI https://example.com/ | tr 'A-Z' 'a-z' | sed -n 's/^content-security-policy: //p')
h=$(curl -s https://example.com/ | perl -0777 -ne 'print $1 while /<style>(.*?)<\/style>/gs' \
| openssl dgst -sha256 -binary | openssl base64)
case "$p" in *"sha256-$h"*) echo ok ;; *) echo "mismatch: $h" ;; esac
# okThen open the page and confirm the console is empty rather than mostly empty. One remaining violation usually means one remaining inline attribute. The visual check matters too: an unstyled first paint is the same failure as a hidden one, and a page that arrives correct and does not hide itself behind an animation is the point of inlining in the first place.
What to watch out for
- Anything that edits the HTML after hashing breaks the page. Minifiers, edge workers, injected tags and proxies that normalise whitespace all count. Hash the artefact you actually publish.
- Once a hash or a nonce is present in a directive, browsers that support them ignore
'unsafe-inline'in that same directive. Leaving it in as a fallback does nothing except mislead the next person reading the policy. - The meta element cannot express
frame-ancestors,report-uri,report-toorsandbox. Those values are ignored there. Clickjacking protection therefore has to come from a header. - A meta policy only applies from the point the parser reaches it, so it must sit at the top of the head. If a header and a meta tag are both present, both policies are enforced and the effective result is the intersection, which is a confusing way to find out about a stale tag.
- The header has to survive the server config. Adding a header inside one location block can drop every header inherited from the parent, which is a trap worth knowing before you blame the build.
A strict policy and a fast first paint pull in opposite directions only until the build knows about both. The pattern that keeps working is to treat the policy as an output of the build rather than a file someone edits: the same step that produces the bytes produces the hashes, and a guard refuses to publish a page whose policy and content disagree. The same thinking applies to the rest of the head, where subsetting and preloading fonts belongs to the build rather than to a hand maintained list. When a security control is generated from the thing it protects, it stops drifting away from it.
Questions and answers
- Can I use a CSP nonce on a static site?
- Not meaningfully. A nonce has to be unpredictable and different on every response, and a static file served from disk hands the same bytes to everyone. A nonce baked into the file at build time is a constant that an attacker can read from the page source, which makes it equivalent to unsafe-inline. Hashes are the correct mechanism for content that does not change per request.
- Does a JSON-LD script block need a CSP hash?
- No. A script element with a type such as application/ld+json is treated as a data block and is never prepared for execution, so the inline script check never runs against it. You can add structured data to a page under a policy with no unsafe-inline and nothing will be blocked. If it is blocked, check whether the type attribute is actually there.
- Why did my page lose its styles in production but not locally?
- Almost always because something changed the bytes after the hash was computed. A minifier in the deploy step, a proxy that collapses whitespace, or a tag injected by an edge worker all produce content the hash no longer matches. Compute hashes on the final artefact and fail the build if any inline block is missing from the policy.
- Is the meta tag version of CSP good enough?
- For style and script sources it works, but it has real limits. The meta element cannot express frame-ancestors, report-uri, report-to or sandbox, and it only applies from the point in the document where the parser meets it. Send the policy as a response header and treat the meta tag as a fallback for environments where you cannot set headers.