---
title: "A nonce per request, and a cache that lived twice"
description: "In release 12 every page moved from a host allow-list to a nonce with strict-dynamic, and it shipped enforced because a test proves every page gets the nonce. In release 13 the widget's pages began naming the only sites allowed to frame them, for a business that sets a list. Review found that saving the list took up to a minute to apply, because the proxy had its own copy of the cache."
url: "https://bookatu.com/engineering/a-nonce-per-request-and-a-cache-that-lived-twice"
language: "en"
section: "Engineering"
category: "Engineering"
author: "The Bookatu engineering team"
published: "2026-10-30"
updated: "2026-10-30"
tags: ["security", "csp", "nextjs", "caching", "engineering"]
---

# A nonce per request, and a cache that lived twice

In release 12 every page moved from a host allow-list to a nonce with strict-dynamic, and it shipped enforced because a test proves every page gets the nonce. In release 13 the widget's pages began naming the only sites allowed to frame them, for a business that sets a list. Review found that saving the list took up to a minute to apply, because the proxy had its own copy of the cache.

> **The short version**
>
> - Release 12 replaced our script host allow-list with a fresh nonce per request and 'strict-dynamic'. A script runs only if it carries the nonce, or was added by one that did.
> - It shipped enforced, because a test proves every page gets the nonce.
> - In release 13 a business that lists its own sites gets frame-ancestors naming those sites, and their subdomains, on every widget page, inside the one policy.
> - Review found that a saved list took up to a minute to apply, because the separately bundled proxy had its own copy of the cache. The cache now lives on globalThis.

In our post A private channel nobody else can join, an enforced allow-list quietly broke a websocket. Here the script allow-list goes, and the widget learns to tell the browser who may frame it.

## Why a host allow-list is weak

The policy we enforced before release 12 allowed 'unsafe-inline' and 'unsafe-eval', so injected script could run. Nor did it stop data leaving. img-src allowed any https: image, connect-src allowed our blob provider's whole domain, which takes an upload token for any blob store, and the analytics hosts accept hits for any property. A stricter report-only draft dropped 'unsafe-inline' but kept a host list, which Google's CSP Evaluator flags: Google hosts serve JSONP and script gadgets that get past host lists.

## A fresh nonce on every request

The new script-src is one line:

```ts
`script-src 'nonce-${o.nonce}' 'strict-dynamic' https: 'unsafe-inline'${o.dev ? " 'unsafe-eval'" : ""}`,
```

A browser that knows nonces ignores 'unsafe-inline' here, and one that knows 'strict-dynamic' ignores https:. They only keep older browsers working. 'unsafe-eval' appears in development only, for React's tooling.

proxy.ts makes 16 random bytes per request and builds the policy once. It goes on the response, and on the request the page renders from, where Next reads the nonce and stamps every script it writes. Whatever the caller sent under those header names is deleted first, so nobody picks their own nonce:

```ts
const frameAncestors = await embedFrameAncestors(req.nextUrl.pathname);
const csp = pageCsp({ pathname: req.nextUrl.pathname, frameAncestors });
const withCsp = (h: Headers): Headers => {
  h.delete("content-security-policy");
  h.delete("content-security-policy-report-only");
  h.delete(NONCE_HEADER);
  for (const [k, v] of Object.entries(csp.request)) h.set(k, v);
  return h;
};
```

Our own two inline tags, the platform's GA tag and a business's own GA tag, read the nonce from x-nonce. next/script adds an afterInteractive script from the browser, where it no longer knows the nonce, so we pass it in. Stripe.js, reCAPTCHA and gtag.js are loaded by our own code, so 'strict-dynamic' lets them through with no host list. The cost: every page renders per request, because the root layout reads headers().

## How we dared to enforce it

A nonce policy that misses one page breaks that page. tests/unit/csp-strict.test.ts proves, among other things:

- Every page route in app/ passes through the proxy, checked with Next's own matcher code.
- Every branch of the proxy, redirects and rewrites included, sets the policy and hands the render the same one, and Next's own parser reads the nonce back.
- Nothing opts a page into static rendering.
- Every inline script we write carries a nonce.
- Paths the proxy skips, /api and files with a dot, get exactly one static policy. Two policies on one response would intersect.

```ts
it("every page route in app/ passes through the proxy", () => {
  const pages = APP.filter((f) => /(^|\/)page\.tsx$/.test(f));
  expect(pages.length).toBeGreaterThan(100);
  const missed = pages.map(sampleUrl).filter((u) => !matcher.test(u));
  expect(missed).toEqual([]);
});
```

CSP_STRICT_MODE=report-only is the kill switch. With a redeploy and no code change, it enforces the old policy while the strict one keeps reporting. Reports go to /api/csp-report by report-uri and report-to. The route logs the directive, the blocked origin and the page path. It never logs a script sample or a query string, because a blocked URL is where injected script would put what it stole.

## A report-only next step, and its noise

The enforced policy still allows any https: image, for a business's own photos, though connect-src now allows only the blob upload path. Beside it rides a report-only policy with img-src narrowed to the hosts we know, so its reports say what narrowing would break. Our browser check before release found it left out two hosts our own pages use: the one serving our marketing photos, and Google's avatar host for an owner signed in with Google. One view of the home page sent 11 reports and one of the blog sent 26, each a function run and a rate-limit write. Both hosts are now on the list, and tests/unit/r12-csp-own-images.test.ts checks every policy a page carries allows them.

## frame-ancestors for the booking widget

The widget is made to be framed. Under Grow, Website widget, a business can list its own sites, and before release 13 that list was checked against the Referer. A Referer gate only sees the request in front of it. Every step inside the frame sends our own origin as its Referer, which the gate has to let through, so it said nothing about who was framing a later step.

frame-ancestors asks the browser instead, on every response. With a list, each /embed/<slug> page names the business's sites and their subdomains, plus 'self' for the admin's own preview. It goes inside the one policy, never a second header. A browser enforces every policy it is sent, so two policies intersect:

```ts
function frameAncestors(o: { embed?: boolean; frameAncestors?: readonly string[] | null }): string {
  if (!o.embed) return "frame-ancestors 'none'";
  const list = (o.frameAncestors ?? []).filter((a) => ANCESTOR.test(a));
  if (!o.frameAncestors || o.frameAncestors.length === 0) return "frame-ancestors *";
  // A list was given: never widen it to *, even if nothing in it survived.
  return `frame-ancestors ${list.length ? list.join(" ") : "'self'"}`;
}
```

Every widget step is a page, so the proxy keeps each slug's list for a minute, for up to 1000 slugs. The response stays per request, with its fresh nonce, and one that names a business's sites is sent private, no-store. A database error falls back to frame-ancestors * and is not cached, and the page's own Referer gate, which fails closed, still stands.

## The cache that lived twice

Saving the list calls invalidateEmbedAncestors for that slug, so the next widget page sends the new list. In the browser check, the old frame-ancestors came back for about 40 seconds after a save.

proxy.ts is bundled apart from the app's pages and actions. Each bundle had its own copy of lib/embed-ancestors.ts, and each copy its own Map. The save cleared the action's copy, and the proxy's copy kept its answer until the minute ran out. The fix moves the cache from the module to the process:

```ts
type AncestorCache = Map<string, { ancestors: string[] | null; expires: number }>;
const CACHE_KEY = Symbol.for("bookatu.embedFrameAncestors");
const cache: AncestorCache = ((globalThis as Record<symbol, AncestorCache | undefined>)[CACHE_KEY] ??= new Map());
```

Symbol.for returns the same symbol in every copy, so every copy in one server process shares one Map. Other server instances still catch up within the minute, and the admin page says so. The test imports two fresh copies of the module, as the proxy and the action have:

```ts
const proxySide = await freshCopy();
const actionSide = await freshCopy();
expect(actionSide).not.toBe(proxySide);

await setDomains(["one.example.com"]);
expect(await proxySide!.embedFrameAncestors(`/embed/${biz.slug}`)).toContain("https://one.example.com");

await setDomains(["two.example.com"]);
actionSide!.invalidateEmbedAncestors(biz.slug); // what saveEmbedDomainsAction does
const now = await proxySide!.embedFrameAncestors(`/embed/${biz.slug}/book`);
expect(now).toContain("https://two.example.com");
```

A second case pins the other side: without a save, a copy keeps its answer for the minute, which is the bound on database reads.

## What we take from it

- A nonce policy is only as good as the proof that every page gets the nonce. Write the proof as a test, then enforce.
- One policy per response. A second header never widens the first: the browser enforces both.
- A module-level cache is one cache per bundle. If two bundles must agree, keep the state where both can see it.
