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

  1. 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.
  2. Published on its Nov 11 slot; auto-drafted by the publishing run because no draft existed for the row.