An app I maintain kept its JavaScript inline. The main page’s template was 3,979 lines, and 3,528 of them were one <script> block. Eight other pages were the same shape, around 6,500 lines in all.

That rules out a strict Content-Security-Policy. A policy that blocks injected scripts has to drop 'unsafe-inline' from script-src, and then every one of those blocks stops running. The usual answer is a nonce per request on every script tag. I didn’t build that.

A JSON script is data

The reason scripts end up inline is server data: the template injects values the JavaScript needs. But those values can travel in a tag the browser never executes:

<script type="application/json" id="boot">{{ boot | tojson }}</script>
<script src="/static/grid/grid.js" defer></script>
const boot = JSON.parse(document.getElementById("boot").textContent);

A <script type="application/json"> is data, not code. The browser never runs it, so it needs no exception in the policy. Everything else moves to a file, and script files from allowed origins are exactly what a strict policy permits.

The surprise was how little server data there was. The 3,528-line block contained eleven server-injected values. That is why this was a move and not a rewrite: the template went from 3,979 lines to 455, and the app’s ten behavioural golden files came out byte-identical.

Inline <style> is still allowed, deliberately. CSS can’t execute, so removing it buys nothing here.

Measure first, then enforce

The policy had been running in report-only mode since 14 August. In a month it caught exactly one thing, a Cloudflare beacon, since disabled at the zone. That month is what made flipping to enforcement a small decision.

Enforcement proved it was real within minutes. My own screenshot tool used an inline script to set the theme, and every capture came back as a blank 10 KB page.

Three holes the review found in my own work

All three were in code I had just written.

  • I shipped the same policy twice, enforced and report-only, reasoning that it kept the reporting channel open. Browsers evaluate the two headers separately and send two reports for every violation, which would have burned through my report log cap at double rate. An enforcing policy reports by itself.
  • My sweep for inline scripts matched a bare <script> only, so <script defer> walked straight past it. A page could have shipped a script the browser then refused, while the test suite called it clean.
  • My list of JavaScript MIME types held only the modern few, so application/x-javascript would have been classed as inert data while the browser happily ran it. Obsolete is not the same as ignored.

The same review raised nine older bugs in code this change had only relocated. All real, none introduced here, so they stayed out of it: folding them in would have destroyed the one property that made it safe to ship, which is that it was provably a move. One of them became the date that was yesterday until 3am.

Checking it on the wire

On the live login page there are five script tags, and every one is either an external file or the JSON boot block. Zero executable inline. The check that mattered most was pointing a real browser at the live sign-in page rather than a snapshot: the button rendered active, which means the config came off the boot block and the auth SDK loaded through the enforced policy. Sign-in is the one page that would have locked everybody out.

If your inline scripts exist mainly to receive server values, count the values before you reach for nonces. Mine came to eleven.