# Pagefind search hung under a strict CSP — the worker reads its own header, and a 304 drops it

> Pagefind search on Vercel hung because the CSP lacked wasm-unsafe-eval. After the fix, cached workers kept the old policy, because Vercel's 304 carries no CSP.

- Source: https://oharu121.com/blog/pagefind-search-csp-worker-wasm-unsafe-eval-vercel-304/
- Published: 2026-10-31T23:56:08+09:00
- Tags: Pagefind, CSP, Web Worker, WebAssembly, HTTP, Vercel

---
## Introduction

I searched my own blog for "claude", and the search box sat on **"Searching for claude…"** with no results and no error. It had been that way since I [added a Content-Security-Policy (CSP) to the site](/blog/astro-vercel-security-csp-service-worker-hash/) two months earlier, so every reader who tried search in that time got the same empty box.

The policy's `script-src` had no `'wasm-unsafe-eval'`, so the browser refused to compile the WebAssembly that Pagefind runs in a worker. The error appeared only in the DevTools console, and the search waited for a result that never came.

*Image: The search box shows "Searching for claude…" while the DevTools console lists four errors, each reading "Failed to load the Pagefind WASM" with a CompileError about the Content Security Policy*

*The page shows a search in progress. Only the console, in red, says the WASM was refused.*

Adding `'wasm-unsafe-eval'` let new visitors search again. Returning visitors whose browsers had cached the worker still hung. Their browser asked Vercel whether the cached worker was still current, and **Vercel's "not modified" answer, a 304, carries no CSP header**, so the browser kept the old policy from its cache.

A hash of the policy is now stamped into the worker file, so any policy change reaches every browser on its next load.

## How Pagefind runs a search

Pagefind is a static search library. It indexes the built HTML at build time and ships that index with a small JavaScript and WebAssembly (WASM) runtime.

> **HEADS UP**
>
> **Why Pagefind ships WebAssembly**
>
> To save bandwidth, Pagefind splits its index into small compressed binary chunks and downloads only the ones a query needs, [as CloudCannon's launch post explains](https://cloudcannon.com/blog/introducing-pagefind/). The engine that reads those chunks is written in Rust and compiled to WebAssembly (WASM), a binary format the browser runs alongside JavaScript.

In Pagefind 1.5, a search runs in a dedicated worker, off the page's main thread, so typing and scrolling stay responsive while it searches. `pagefind.js` starts that worker on the first query:

```js title="pagefind.js (Pagefind 1.5.2, excerpt, reformatted from the minified file)"
workerUrl = `${basePath}pagefind-worker.js`;
sharedWorker = new Worker(workerUrl);
```

The worker then fetches the WASM and compiles it. **A dedicated worker loaded from a URL runs under the CSP sent with its own script**, not under the policy of the page that started it. [MDN says so directly](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy). The one exception is a worker created from a `blob:` or `data:` URL, which inherits its creator's policy.

*Figure — WorkerCsp: Each response carries its own copy of the same policy. The compile is judged by the copy on `/pagefind/pagefind-worker.js`, which has no `'wasm-unsafe-eval'`.*

So the header that decides whether search works is the one on `/pagefind/pagefind-worker.js`. On this site, `vercel.json` has one header rule whose `source` is `/(.*)`, the catch-all rule. `/(.*)` matches every path, so every page and the worker get the same CSP header from it:

```json title="vercel.json (excerpt)"
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "default-src 'self'; … script-src 'self' …"
        }
      ]
    }
  ]
}
```

That policy has no `'wasm-unsafe-eval'`, so the worker gets the same refusal as every page, and the catch-all rule is where the fix belongs.

## Why does the CSP block compiling the WASM?

The problem was the CSP's `script-src`. It allowed only two kinds of script:

1. `'self'`: script files from the site's own domain. Third-party scripts from any other domain are blocked.
2. Hashes: inline scripts written in the HTML are blocked by default, except one whose content matches a listed hash.

Inline scripts are blocked by default because one injected through XSS (cross-site scripting) looks exactly like the site's own. So the policy lists six hashes, each matching one of the site's own inline scripts:

```text title="Content-Security-Policy (excerpt, before)"
script-src 'self' 'sha256-FSnQm9awVo7jad+j9vKPRI9jNpEpWw3TfPfEjTiRH00=' …
```

**`script-src` also governs code produced at runtime**, such as a string passed to `eval()` or the bytes WebAssembly compiles. That code cannot be checked in advance the way a script file or a hash can, so a CSP blocks it by default. **Without `'unsafe-eval'` or the narrower `'wasm-unsafe-eval'`, `WebAssembly.instantiate()` throws.**

When I opened DevTools on the stuck search box, it showed exactly that, logged from `pagefind-worker.js`:

```text
Failed to load the Pagefind WASM:
CompileError: WebAssembly.instantiate(): Compiling or instantiating WebAssembly module violates the following Content Security policy directive because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: "script-src 'self' 'sha256-FSnQm9awVo7jad+j9vKPRI9jNpEpWw3TfPfEjTiRH00=' …".
```

The error stays inside the worker. **The promise the search box waits on never settles**, so it keeps showing "Searching for claude…" and nothing on the page says that anything failed.

## Where `'wasm-unsafe-eval'` goes

**`'wasm-unsafe-eval'` lets a page or worker compile and instantiate WebAssembly, and does nothing else.** [Pagefind's hosting documentation](https://pagefind.app/docs/hosting/) names `'wasm-unsafe-eval'` as the keyword Pagefind needs.

> **DON'T**
>
> **Do not reach for 'unsafe-eval'**
>
> `'unsafe-eval'` also lets WebAssembly compile, but it allows JavaScript's `eval()` too. `eval()` runs any string as code, so a string an attacker gets into it runs as script on the page. Blocking `eval()` is exactly what a CSP is there to do.

I considered two places for `'wasm-unsafe-eval'`:

1. The catch-all rule, which applies to every path, pages and Pagefind's files alike.
2. A `/pagefind/*` rule, which applies only to paths starting with `/pagefind/`, that is, Pagefind's own files.

I chose the catch-all rule, because **adding a `/pagefind/*` rule alone still leaves the worker blocked by the catch-all policy**.

With a `/pagefind/*` rule added, the worker matches both rules and receives two `Content-Security-Policy` headers, [as another Pagefind site on Vercel found](https://github.com/useAvalon/Avalon/pull/125). **A browser enforces every policy it is given, all at once**, and a compile has to pass every one of them. The `/pagefind/*` policy allows it; the catch-all policy still refuses it.

*Figure — TwoCspHeaders: A new `/pagefind/*` rule only adds a policy. The catch-all rule's policy is still applied alongside it.*

Making a `/pagefind/*` rule work would mean writing a negative-lookahead regex into the catch-all rule's `source`, so that it skips `/pagefind/`.

**Putting `'wasm-unsafe-eval'` on every page is far less work and costs almost nothing**, because an attacker would first have to run JavaScript on the page to call `WebAssembly.instantiate()`. An attacker who can do that can already do anything with JavaScript and has no need for WASM.

> **HEADS UP**
>
> **Excluding /pagefind/ from the catch-all rule**
>
> The catch-all rule's `source` would become `/((?!pagefind/).*)`. That is a negative-lookahead regex meaning "every path that does not start with `/pagefind/`". The worker would then receive only the `/pagefind/*` rule's policy.

The policy here is generated rather than written by hand. `scripts/compute-csp-hashes.ts` hashes the inline scripts in the build and writes the header into `vercel.json`, and `pnpm check` fails when the two disagree. So I added `'wasm-unsafe-eval'` to the generator:

```ts title="scripts/compute-csp-hashes.ts"
const scriptSrc = `script-src 'self' 'wasm-unsafe-eval' ${scriptHashes.map((h) => `'${h}'`).join(" ")}`;
// was: script-src 'self' ${…}
```

> **HEADS UP**
>
> **Why no worker-src is needed**
>
> [Pagefind's documentation](https://pagefind.app/docs/hosting/) suggests `worker-src 'self' blob:` for sites that restrict workers. `blob:` is a URL pointing at data a page generated in memory, and only a worker started from one needs it. Pagefind here starts its worker from the file `pagefind-worker.js`, and without `worker-src` the browser uses `script-src`, whose `'self'` already allows that file.

## Why search still hung after the deploy and a reload

After the deploy, the worker's response carried the new policy. In my browser, a plain reload still left the box on "Searching for claude…". Only Chrome's "Empty Cache and Hard Reload" cleared it. Chrome offers it when you right-click the reload button with DevTools open, shown here in Chrome's Japanese UI as キャッシュの消去とハード再読み込み.

*Image: Chrome's reload menu with three entries: 通常の再読み込み (normal reload, ⌘R), ハード再読み込み (hard reload, ⇧⌘R) and キャッシュの消去とハード再読み込み (empty cache and hard reload)*

*Image: The search box with "clau" typed in, listing article results with "Claude" highlighted in their excerpts*

*After "Empty Cache and Hard Reload", the same browser returns results.*

The worker is served with `Cache-Control: public, max-age=0, must-revalidate`, which lets a browser cache the file but makes it check with the server before every use. It checks by sending the cached file's ETag in `If-None-Match`.

> **HEADS UP**
>
> **ETag and 304**
>
> An ETag is an identifier the server derives from a file's content, so it changes whenever the content does. The browser sends it back in `If-None-Match` when it checks. An unchanged file gets 304 Not Modified, with no body, and the browser keeps its cached copy; a changed file gets 200 and the new content.

The fix changed a header and not a byte of the file, so the ETag stayed the same and Vercel answered 304 Not Modified. I replayed that request with `curl`:

```bash
curl -sI -H 'If-None-Match: "8ff5a7a776be59b860655a35c70c008d"' https://oharu121.com/pagefind/pagefind-worker.js | grep -iE "^HTTP|content-security|cache-control|etag"
# HTTP/2 304
# cache-control: public, max-age=0, must-revalidate
# etag: "8ff5a7a776be59b860655a35c70c008d"
```

Under [RFC 9111](https://www.rfc-editor.org/rfc/rfc9111#section-4.3.4), the HTTP caching spec, a browser that receives a 304 updates the cached file's stored headers from it, so Chromium would replace the old policy with any CSP header the 304 carried. But **the 304 carries no `Content-Security-Policy`, so the old policy in the cache is never replaced**.

The browser keeps the policy it first stored **until the cached file is evicted or the reader hard-reloads**.

*Figure — RevalidationCsp: A header-only change leaves the ETag alone, and the 304 brings no policy to replace the stored one.*

## Stamping the policy into the worker

I considered two fixes:

1. `Cache-Control: no-store` on `/pagefind/*`, which forbids the browser from caching Pagefind's files. Every load then gets a full 200 with its headers, at the cost of re-downloading the bundle on every page where someone searches.
2. Change the worker file's bytes whenever the policy changes, so its ETag changes with them. As long as the policy stays the same, this costs nothing.

I chose changing the bytes.

**A build step now writes a hash of the policy into a comment at the top of the worker**, after Pagefind has emitted it. The hash is the first 16 hex characters of the policy's SHA-256:

```ts title="scripts/stamp-pagefind-csp.ts"
const STAMP = /^\/\* csp:[0-9a-f]+ \*\/\n/;
// …
const stamp = await cspStamp();
await writeFile(WORKER, `/* csp:${stamp} */\n` + source.replace(STAMP, ""));
```

```js title="pagefind-worker.js (first lines, as deployed)"
/* csp:826a419f579366b8 */
const pagefind_version="1.5.2";…
```

**An unchanged policy produces the same comment**, so the file stays byte-identical and no browser downloads it again.

The site has one more worker, the service worker at `/sw.js` that handles offline caching, and it would hit the same stale-policy problem. So I put the same hash into the version of `/sw.js` too, and **any CSP change now makes browsers download `/sw.js` again**.

After the deploy, the old ETag gets the new file:

```bash
curl -sI -H 'If-None-Match: "8ff5a7a776be59b860655a35c70c008d"' https://oharu121.com/pagefind/pagefind-worker.js | grep -iE "^HTTP|etag"
# HTTP/2 200
# etag: "475b8920f22a66ecfaf009a83edafc7e"
```

The file's content changed, so the old ETag no longer matches, and the server answers with a 200 carrying the new file and the new CSP header instead of a 304. **On its next ordinary load, a browser that cached the old worker replaces it with the new file and the policy that includes `'wasm-unsafe-eval'`**, and search works again.

> **CAREFUL**
>
> **HTML pages may have the same problem**
>
> This site's service worker stores whole responses, headers included, in its page cache. After a CSP-only change, a page a returning visitor opens from that cache may still run under the old policy. I have not measured this yet.

## Summary

Search on this site works again, for new and returning visitors alike. Looking back:

1. Pagefind compiles its WASM inside a worker, and the worker runs under the CSP on the response it was loaded with. So **`'wasm-unsafe-eval'` had to go on the catch-all rule**. A separate `/pagefind/*` rule would only have added a policy, and the catch-all policy that blocks the compile would still have applied.
2. A CSP check that only loads pages cannot catch this error, because the WASM is not compiled until the first search. The only way to confirm search works is to search.
3. On Vercel, a header-only change never reaches a browser that cached the worker, because the 304 it gets on revalidation carries no CSP. Stamping a hash of the policy into the file makes the file change with the policy, and that is what gets the new policy to every browser.

## References

- [Pagefind: Hosting, including its Content Security Policy and Web Worker settings](https://pagefind.app/docs/hosting/)
- [Introducing Pagefind, CloudCannon's launch post on its chunked index](https://cloudcannon.com/blog/introducing-pagefind/)
- [MDN: Content-Security-Policy, including how a worker gets its policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy)
- [Content Security Policy Level 3, defining 'wasm-unsafe-eval'](https://www.w3.org/TR/CSP3/)
- [RFC 9111 section 4.3.4: Freshening stored responses upon validation](https://www.rfc-editor.org/rfc/rfc9111#section-4.3.4)
