Astro client islands under a strict CSP — pinning the hydration inlines by sha256 hash
Why react islands with `client:load` silently fail under `script-src 'self'`, how I allowed the two deterministic Astro hydration inlines via hash, and how to refresh the hashes after Astro updates.
The photo section on this site has a lightbox carousel implemented as a React component with client:load. Locally everything worked. In production, clicking a thumbnail did nothing. A glance at the browser console:
Executing inline script violates the following Content Security Policy directive 'script-src 'self''.
Either the 'unsafe-inline' keyword, a hash ('sha256-QzWFZi+FLIx23tnm9SBU4aEgx4x8DsuASP07mfqol/c='),
or a nonce ('nonce-...') is required to enable inline execution.
Two of those errors, two distinct hashes. The strict CSP this site runs under was doing exactly what it was built to do — and blocking exactly what Astro needs for hydration.
What Astro inlines, and why
Astro’s client:* directives (client:load, client:visible, client:idle, …) need a tiny bootstrap that decides when a component hydrates in the browser and then runs it. Astro injects that bootstrap as two small inline <script> blocks on every page that contains an island:
<script>(()=>{var e=async t=>{await(await t())()};(self.Astro||(self.Astro={})).load=e;w...</script>
<script>(()=>{var A=Object.defineProperty;var g=(i,o,a)=>o in i?A(i,o,{enumerable:!0,con...</script>
Roughly 1.5 kB combined. With script-src 'self' and no 'unsafe-inline', the browser blocks both. The hydration system never starts, every island stays static HTML, and every event handler from React/motion/etc. is missing.
The key fact: those two blocks are deterministic per Astro version. The same npm run build produces the same bytes — and therefore the same sha256 hashes. Astro doesn’t put random IDs, timestamps, or build hashes in there.
Three options
- Allow
'unsafe-inline'for scripts — defeats the point of the whole CSP. - Nonces. Only useful if Caddy injects a fresh nonce per request into both the HTML and the header. That’s infrastructure I don’t want on a static-site stack.
- Hash pinning: put the exact sha256 hashes of those two inlines into the CSP.
Hash pinning fits a static-site stack: no server logic, no per-request processing, just two extra tokens in the script-src directive.
Computing the hashes
Straight off the build output — no external tool needed:
python3 -c "
import re, hashlib, base64
from pathlib import Path
html = Path('dist/fotos/unterwegs/asien/malaysia/kuala-lumpur/index.html').read_text()
for body in dict.fromkeys(re.findall(r'<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>', html, re.DOTALL)):
if body.strip():
print('sha256-' + base64.b64encode(hashlib.sha256(body.encode()).digest()).decode())
"
Any path that contains a client:* island will do — the two inlines are the same across pages. The script spits out the two hashes that belong in the CSP.
Patching the Caddy CSP
In my Caddy config the CSP sits as a single value inside a header block. Patch:
- script-src 'self';
+ script-src 'self' 'sha256-QzWFZi+FLIx23tnm9SBU4aEgx4x8DsuASP07mfqol/c=' 'sha256-QJZDUlo/qa5AJCrG6vHyWcatjwCeWidEHQfJc601lzw=';
Everything else stays — default-src 'none', img-src, style-src, untouched. Reload:
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
sudo systemctl reload caddy
curl -sI https://adrian-altner.de/ | grep -i content-security shows the new hashes in the header right after. On the next page load the island hydrates and the modal opens.
What happens when Astro updates
Hashes track the bytes of the inlines. Astro can rework the bootstrap between versions — major releases and occasionally minor releases. Patch and minor updates within a single major usually leave it alone, but “usually” isn’t “always”. Without a drift check, you only notice when something silently breaks in production.
Drift check in the deploy workflow
The right moment for the check is right after the deploy: the new build artifact is live, the old CSP is still in place. My Forgejo runner therefore has an extra step in the workflow:
- name: Verify CSP script-src hashes
run: |
SAMPLE_PATH='/fotos/unterwegs/asien/malaysia/kuala-lumpur/'
export LIVE_HTML="$(curl -fsSL "https://adrian-altner.de${SAMPLE_PATH}")"
export LIVE_CSP="$(curl -fsSI "https://adrian-altner.de${SAMPLE_PATH}" \
| tr -d '\r' | grep -i '^content-security-policy:')"
python3 - <<'PY'
import os, re, hashlib, base64, sys
html = os.environ['LIVE_HTML']
csp = os.environ['LIVE_CSP']
inlines = re.findall(r'<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>', html, re.DOTALL)
served = sorted({'sha256-' + base64.b64encode(hashlib.sha256(b.encode()).digest()).decode()
for b in inlines if b.strip()})
configured = sorted(set(re.findall(r"sha256-[A-Za-z0-9+/=]+", csp)))
if set(served) == set(configured):
print(f"OK: CSP hashes match ({len(served)}).")
sys.exit(0)
print("::error title=CSP hash drift::live HTML and CSP no longer agree")
# ... print missing/stale + ready-to-paste sed patch ...
sys.exit(1)
PY
Key point: no reading the build directory, no sudo against the Caddy file. The runner just curls the public endpoint, parses the two inlines from the HTML and compares them to the Content-Security-Policy response header. Mirrors exactly what the browser sees and needs no privileged access — the Forgejo runner’s sudoers rule can stay narrowly scoped to podman build and systemctl restart.
On drift the step fails with a Forgejo annotation and a ready-to-paste sudo sed patch in the log. Workflow:
- Forgejo run goes red, annotation “CSP hash drift detected” with both hash sets side by side.
ssh hetzner, paste the patch from the log —sedswap,caddy validate,systemctl reload caddy.- In the Forgejo UI hit “Re-run workflow” — the step turns green.
The deploy workflow runs on every push and once daily at 06:00 UTC anyway, which makes the drift check a free daily insurance.
What I deliberately didn’t do
- Rewrite the lightbox as a vanilla
<dialog>. That was the alternative — native<dialog>element plus a few vanilla-TS handlers, no React island, no Astro hydration inlines, no CSP maintenance. But it’s a bigger refactor and would lose motion for the slide/drag animations. Hash pinning is the smaller change with a tiny maintenance tail. - Open
script-srcto'unsafe-inline'. Once'unsafe-inline'is in, you might as well delete the script-CSP entirely — the whole point was a hard barrier against XSS and supply-chain compromise.
Current state: two hash tokens in the CSP, a Forgejo step that compares against the live response header on every deploy and goes red on drift, otherwise untouched.