Six ways a nonce-based CSP breaks quietly
Websites have a security rule that only lets "approved" code run on the page, like a bouncer checking IDs at a door, so hackers can't sneak in fake code that steals your information. The tricky part is that it's easy to accidentally lock out your OWN real code too, and the page can look completely fine while secretly being broken for every visitor. This write-up covers six ways that happens by accident, and how to catch it before real people do. Useful for anything that handles logins, payments, or personal information, basically any real website or app a business actually depends on
A nonce-based Content Security Policy is one of the strongest defenses against injected scripts, and one of the easiest to get subtly wrong. Each failure below is silent: the build passes, the unit tests pass, and the page still ships broken. I hit all six on real projects, then reproduced the browser behavior in Chrome to confirm it
1. A static page gets no nonce. A nonce must be fresh on every request, so the server has to render the page per request. Next.js documents that static pages can't get one. In one app, a simple form page fetched no data, so it built as static and every framework script on it was blocked. The page rendered and no button worked. Read the route table from a real build and check every page a first-time visitor can reach
2. A framework's own startup script has no nonce. Adding an Astro island with client:load to a nonce-only page passed the build, the type check, and every unit test. The island's inline bootstrap scripts carried no nonce, so a real browser would have blocked them and the island would never have woken up. We only saw it by reading the raw HTML of a real response. Our fix was pinning the scripts' SHA-256 hashes in the policy, which breaks whenever a version bump changes those bytes. Astro 6 now has a built-in hash-based option (a meta tag, not a header), so check that before pinning hashes by hand
3. A CDN optimizer rewrites your scripts. Cloudflare's Rocket Loader rewrites the type of every script tag and re-runs them through its own script. Cloudflare's documented CSP fix is to allow ajax.cloudflare.com in script-src. But 'strict-dynamic' makes browsers ignore host allowlists, so that fix does not apply to a nonce plus strict-dynamic policy. A batch of "Speed" recommendations took one of our sites from working to a half-loaded page within minutes: the HTML arrived and nothing ran. We turned Rocket Loader off. Treat any "auto-optimize" feature that rewrites markup or scripts as suspect. Plain compression and caching toggles are fine
4. Inline style= attributes are dropped. A nonce covers <style> and <link> elements, never a style attribute. Under a nonce-only style-src, every inline style prop is discarded. The page renders with browser defaults and nothing fails in the build. Move those rules into a real stylesheet
5. img-src 'self' blocks data: images. An inline SVG or any image delivered as a data: URI simply disappears. Search the app for every image source before writing the policy, including data: and blob:
6. Your test reads the nonce wrong. Browsers hide the nonce from the nonce attribute once the page is parsed, so getAttribute("nonce") returns an empty string. Read the .nonce property instead. A test that uses the attribute can compare an empty string to an empty string and pass with no policy at all
One check catches most of this. Build tools and jsdom never enforce a CSP, so load the page in a real browser and fail on any violation:
await page.addInitScript(() => {
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(e.effectiveDirective + " blocked " + (e.blockedURI || "inline")));
});
await page.goto(url);
expect(await page.evaluate(() => window.__csp)).toEqual([]);Tested, not just remembered: on a small test page in Chrome with a nonce plus strict-dynamic policy, a script without a nonce was blocked even though 'self' was listed, a script added by a nonced script still ran, an inline style attribute was ignored while a nonced style element applied, a data: image was blocked while a same-origin image loaded, and getAttribute("nonce") returned an empty string while .nonce held the value. I saw Rocket Loader on a live Cloudflare zone, not on the test page
Row lock plus grace window: why both are needed
When you log into an app, it hands you a secret "key" so you don't have to type your password every single time. That key gets swapped for a new one regularly, kind of like a hotel giving you a fresh room key whenever you check back in. If a stolen key ever gets used, the app can tell something's wrong. The hard part is that sometimes it's just you, like two open tabs or a slow phone retrying, not a thief, and a badly built system can't always tell the two apart. This is a small trick that tells them apart correctly. Useful for any app that keeps you signed in for a while: phone apps, websites you don't log into every single visit, anything with accounts
A stolen refresh token looks identical to the real one, right up until someone presents it a second time. Rotation makes that second use detectable: every refresh revokes the old token and issues a new one, so a replayed token means two parties held it
The hard part is concurrency. Two legitimate requests (two open tabs, a retry racing a proactive refresh) look exactly like a replay. The library handles that in two layers. A row lock (with_for_update) serializes requests presenting the same token, so only one wins the rotation and the session family can't fork into two. A 20-second grace window then lets the loser recover onto the family's current token instead of being logged out. A token replayed after that window is treated as theft, and the whole family is revoked
result = await db.execute(
select(RefreshToken).where(RefreshToken.token_hash.in_(candidate_hashes)).with_for_update()
)
row = result.scalar_one_or_none()
...
if row.revoked_at is not None:
if (now - row.revoked_at).total_seconds() <= REUSE_GRACE_PERIOD_SECONDS:
... # benign concurrent race: recover onto the family's live token
else:
await _revoke_family(db, row.family_id)
await db.commit()
raise RefreshTokenReusedError(user_id=row.user_id, family_id=row.family_id)Tested, not just reasoned about: on real Postgres in CI, 8 concurrent rotation attempts on one token land as a single unforked chain, and removing the lock makes that test fail. The other 27 tests cover the rest of the state machine on SQLite, which silently ignores row locks
Making an LLM-written ops report harder to get wrong
Imagine running a website and wanting a short daily update on whether everything's working, without staring at raw logs yourself. You could have an AI write that update in plain English. The catch is that AI can get things wrong, like calling old data fresh, or calling an old problem "brand new." This write-up is about building the reporting system so the AI is only allowed to repeat facts that a separate, boring, always-honest program already checked, never make anything up on its own. Useful for a solo developer or small team running a website or app who wants automatic status updates without hiring someone to watch dashboards all day
I wanted a short digest of a small production app: errors, health checks, revenue, server headroom, and whether the maintenance jobs ran. A model writes a readable digest, and it will also state a stale number as if it were fresh or call an old problem new. The design below keeps the model to narration and makes the rest checkable
Two layers. Layer 1 is plain scripts. They run the SQL aggregation and the health checks, use no LLM tokens, and write small JSON summary files to the repo through the GitHub API. Layer 2 is a scheduled LLM routine that reads one summary file and writes the digest. The routine cannot query the database or reach the server, so it only ever sees what a script chose to write, and those files are kilobytes, not raw rows
1. Make the model state the data age. Every summary carries its own generated_at. The prompt tells the model to compare it to the current time, state the age in the digest, and call the data stale when it is older than about twice its collection interval. A stale report presented as fresh is worse than none. The prompt also says to trust the timestamp over any schedule written in the prompt, because schedules change: the health check was throttled from every 15 minutes to every 12 hours
2. "New" needs the authoritative record. One digest called three error-tracker issues brand new. Two were six weeks old (219 and 113 occurrences) and one was ten days old (4 occurrences). All three were long-standing and recurring. In our runs, the search results' first-seen field looked like it reflected the queried time window, not the issue's lifetime. I did not confirm that against the tracker's documentation, so treat it as our observation. The fix was a rule in the prompt: fetch each issue's own record, read its real first-seen date and total count, and use "recurring" unless that date falls inside the lookback window. If unsure, say "recurring, not confirmed new". I have not measured how reliably the model follows it
3. Retry once before reporting an outage. The health collector checks a few endpoints on each environment. An instance that is slow to wake reads as an outage, and a false alarm teaches people to ignore the report. Each check uses a 10 second timeout. On failure it waits 5 seconds and tries once more, and a check that needed the retry is marked retried_after_failure so the recovery stays visible
4. A guard so no job goes unmonitored. The job-status report watches a list of scheduled maintenance workflows. One workflow was missed when that list was first written. Nothing failed; it just never appeared in the report. The fix is a test that scans the workflow files for a schedule trigger and fails unless each one is either monitored or on an explicit exclusion list with a reason. A new scheduled job can no longer be silently absent from both
scheduled = {
p.name for p in workflows_dir.glob("*.yml")
if "\n schedule:" in p.read_text(encoding="utf-8")
}
accounted_for = set(MONITORED_WORKFLOWS) | DELIBERATELY_UNMONITORED
unaccounted = scheduled - accounted_for
assert not unaccounted, f"{sorted(unaccounted)}: monitor it or exclude it with a reason"Tested, not just reasoned about: on a local test server, a first response slower than the timeout recovers on the retry, while a real 500 still fails. Removing one workflow from the watch list makes the guard test fail. The two prompt rules are instructions to a model, not tested code, so I cannot prove it always follows them. Reporting is manual now: the scheduled routines are paused, not deleted, and the summary files are generated on demand






