# Syntax highlighting under a strict CSP: what Shiki's inline styles cost

Shiki colours code with inline style attributes, and a strict style-src with hashes blocks them. CSP Level 3 can allow them with 'unsafe-hashes', and on this site that would take nine hashes. I turned highlighting off anyway: the nine colours sit outside my design tokens, the theme picks the hash list, and the markup would add 29,970 bytes across 12 pages.

Published: 2026-11-11
Canonical: https://umar.codes/shiki-strict-csp

## Revisions

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

---

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`](/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`:

```js
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.
