Syntax highlighting under a strict CSP: what Shiki's inline styles cost
Shiki colours code with inline style attributes, and a strict style-src
blocks them. CSP Level 3 can allow them with 'unsafe-hashes' and one hash
per value, which on this site is nine. I turned highlighting off anyway,
because the nine colours were outside my design tokens and the theme, not
me, chose them.
What Shiki writes into the HTML
Astro highlights fenced code with Shiki by default. Shiki does not emit
classes. It emits a style attribute on every token span and one on the
pre. I rebuilt this site today with highlighting turned on (Astro 7.3.5,
Shiki 4.3.1, the default github-dark theme) and compared it with the build
that ships:
| Build | Pages | Pages with style="…" |
style attributes |
Distinct values |
|---|---|---|---|---|
| Shipped, highlighting off | 76 | 0 | 0 | 0 |
| Highlighting on | 76 | 12 | 699 | 9 |
The 699 attributes come from 19 code blocks. Seventeen of them declare a
language (six ts, four tsx, and one each of seven others). The two
blocks with no language still get the dark pre. The nine distinct values
are one background, #24292e, and eight token colours. The most frequent
one, color:#E1E4E8, appears 270 times.
When I made this decision on 2026-08-11, the same check found inline styles on 2 pages. Now it is 12, because the posts since then carry more code.
Why a hash alone does not cover a style attribute
This site sends its CSP in a <meta> tag that Astro generates at build.
style-src lists 'self' and one SHA-256 hash for each inline <style>
block, and nothing else. A hash in that list matches the content of a
<style> element. It does not match a style attribute.
I tested this in headless Chrome 157 with one span,
<span style="color:#F97583">, and four policies:
style-src |
Colour applied |
|---|---|
'self' |
No |
'self' 'sha256-…' (hash of the value) |
No |
'self' 'unsafe-hashes' 'sha256-…' |
Yes |
'self' 'unsafe-inline' |
Yes |
My decision entry in August said no CSP hash could cover these attributes.
That was wrong. The second row is what I had in mind, and it fails. The
third row works: 'unsafe-hashes' tells the browser to match hashes against
attribute values too. With highlighting on, this site would need nine of
them.
Why nine hashes did not change my decision
The cost of 'unsafe-hashes' is small. Injected markup could use only
those nine exact declarations, so an attacker gets eight text colours and
one dark background. That is much better than 'unsafe-inline', which
allows any style at all. The security argument by itself was weaker than I
wrote in August.
Three other facts decided it.
The colours are not mine. src/styles/tokens.css is the source of
truth for every colour the site renders, and check-a11y asserts the two
static copies of it against that file. None of Shiki’s nine values is in
tokens.css. The highlighting was the largest break of the palette rule on
the site, and it was there by accident: Astro turns Shiki on by default.
The theme picks the hash list. The nine values come from github-dark,
not from a decision of mine. A Shiki update that changes one hex value, or
a new post in a language whose grammar uses a colour no other post uses,
adds a value. Each new value blocks that colour until someone adds its
hash. The build would still pass, because the build does not render code
in a browser.
The bytes are real. Highlighting adds 29,970 bytes of HTML across the
12 pages. The largest page, url-state-in-react, goes from 26,485 to
36,161 bytes, against a 40,960-byte html-max budget. That is 88% of the
budget for one post, up from 65%.
What plain code blocks cost
Code blocks now render in the mono face with a hairline border, padding,
and horizontal scroll, all from base.css and tokens. There is no colour
in the code. A reader who skims a long tsx sample gets less help than
before, and I count that as a real loss.
The change was one line in astro.config.mjs:
markdown: {
syntaxHighlight: false,
},
To bring highlighting back, I would not turn Shiki on again. I would map a
small set of token types to classes, colour those classes from
tokens.css, and let the existing <style> hash cover them. Then the CSP
stays as it is, and the palette rule stays true.
How the build keeps the CSP honest
A CSP that stops being generated looks the same as one that works: the
pages still render. So check-content asserts that every built page has a
content-security-policy meta tag and that the tag carries at least one
sha256- hash. Today all 77 pages pass, this one included. The policy itself has a cost too:
in August the hash list added 105 bytes to the largest page and 1,275 bytes
to the critical path.
Revisions
- Created from the 2026-08-11 decision entry and two builds of this site that day (Astro 7.3.5, Shiki 4.3.1), one with highlighting on, plus a Chrome 157 headless test of the CSP keywords.
- Published on its Nov 11 slot; auto-drafted by the publishing run because no draft existed for the row.