← Back to Blog

Serving a Browser LLM Is Easy. Securing It Is the Part the Demos Skip.

Serving a Browser LLM Is Easy. Securing It Is the Part the Demos Skip.

Everything in this on-device AI series has been about how little a browser LLM costs to run. Here is the bill nobody puts on the slide: to serve one, you loosen your site's security policy, pull executable code and multi-gigabyte model files from servers you do not control, and then render whatever the model says back into your page. Do that casually and you have traded a metered API for something worse than a bill, which is a hole.

None of this is a reason not to ship it. It is a reason to ship it on purpose. This post is the security checklist that should come stapled to every "run a model in the browser" demo, including the one I put on this site.

The Content-Security-Policy is the main event

A Content-Security-Policy is the allow-list that tells the browser which origins your page may load scripts from, connect to, and run. A good one is tight: script-src 'self', connect-src 'self', and little else. A browser LLM does not fit through a tight policy, so making it work means widening several directives at once, and each one is a real increase in what an attacker who finds an injection can reach.

Here is what a WebLLM-style setup actually forces you to add, and what each one costs.

  • 'wasm-unsafe-eval' in script-src. The model runs as WebAssembly, and compiling WASM is gated behind this keyword when a script-src is present. It is narrower than the old 'unsafe-eval', but it still relaxes a protection you had.
  • worker-src 'self' blob:. The runtime spawns a Web Worker, often from a blob: URL. Allowing blob: workers is a common step in real exploit chains, so add it knowingly.
  • A CDN in script-src and connect-src. If you import the library from esm.run or jsdelivr, you are trusting that origin to serve honest JavaScript into your users' browsers, forever.
  • A model host in connect-src. The weights download from a model host such as Hugging Face and its file CDNs, so those origins go on your connect list too.

The single most important move is to shrink this list, not just paste it. Two ways:

  1. Self-host everything you can. You can serve the library file and even the model weights and WASM from your own origin, which 'self' already covers, so you add nothing to connect-src and no third-party script-src. It costs you the bandwidth and a build step, and it removes an entire class of supply-chain risk. For a feature you actually depend on, this is the right default.
  2. If you must use third parties, add the fewest, pinned, and no wildcards you do not need. Name the exact origins, not a broad https:.

And if your CSP is genuinely tight today, treat the widening as the security review it is. Our own Security Headers Audit and Cross-Origin Isolation Audit tools exist to check exactly this surface.

Treat the library and the model as third-party code, because they are

When you import WebLLM from a CDN, that is someone else's code executing in your visitors' browsers. When you download a model, that is a multi-gigabyte binary from a hosting account you do not own. Both are supply chain.

  • Pin versions. Never @latest. A floating version means a CDN can change the code running on your site without you doing anything. Pin an exact version and update deliberately.
  • Know that SRI does not cleanly cover a dynamic ESM import. Subresource Integrity, the integrity hash that protects a normal <script>, does not apply to a runtime import() of an ES module the same way. That is another argument for self-hosting the library, where you control the bytes.
  • Pin the model, and prefer known-good sources. A model is data, but data that steers a model. Pin a specific model revision rather than a moving tag, prefer the official publisher's copy, and be aware that a swapped or poisoned model is a real, if less common, risk.

The output is untrusted. Render it like it.

This is the one that bites people who got the CSP right. A language model's output is just text your page received from an untrusted process, and small models are easy to steer. If you take that text and drop it into the page with innerHTML, you have built a cross-site scripting vector: a prompt can make the model emit <img src=x onerror=...> or a <script> tag, and your own page will run it.

  • Never inject model output as HTML. Use textContent, or escape rigorously, or render through a framework that escapes by default. If you must allow any markup, run it through a real sanitizer with a strict allow-list.
  • Assume prompt injection. If your prompt includes anything a user or a page supplied, the model can be talked into ignoring your instructions. Never let raw model output trigger a privileged action, a fetch to an arbitrary URL, or a navigation without validating it first.
  • Keep the model in its lane. On-device models are for drafting, summarizing, classifying, and answering, not for deciding what your code does next.

Cross-origin isolation is a decision, not a default

Some in-browser runtimes want multi-threaded WASM for speed, which needs SharedArrayBuffer, which the browser only grants when your page is cross-origin isolated with the COOP and COEP headers. Turning those on has consequences of its own: it can break embeds, third-party images, and analytics that are not themselves set up for it. Decide whether you need the threaded path at all, and if you do, audit what it breaks before you ship. Single-threaded and the GPU path often avoid the question entirely.

Do not quietly break the privacy promise

The whole selling point of on-device inference is that the data never leaves the device. That is true by architecture right up until you undermine it. If you log the prompts server-side, pipe the text into analytics, or send "just the anonymized query" somewhere for product metrics, you have turned a private feature into a data-collection feature and, worse, one your users were told was private. If you say nothing leaves the device, make sure nothing does, and check your own network tab to prove it.

The short checklist

Before a browser LLM goes live on a site you care about:

  • CSP widened deliberately, minimum origins, pinned, and reviewed, not pasted.
  • Library and model self-hosted where feasible; otherwise version-pinned and revision-pinned.
  • Model output rendered with textContent or a strict sanitizer, never raw innerHTML.
  • No model output driving privileged actions; prompt injection assumed.
  • Cross-origin isolation only if you truly need threads, with its fallout audited.
  • No prompt logging, no telemetry on the text, network tab clean.

That is the difference between a demo and a feature. The economics of on-device AI are as good as the rest of this series says. Just remember that you are hanging a new door on your site, and take the ten minutes to make sure it locks.

If you are building real tools and client sites and want the trust surface handled properly rather than bolted on, that discipline is a running theme in my book The $100 Network (search the title on Amazon Kindle). The short version lives on this blog, in the posts below.

Related reading

Fact-check notes and sources

  • 'wasm-unsafe-eval' gates WebAssembly compilation under CSP: modern Chromium requires the wasm-unsafe-eval source expression in script-src to compile and instantiate WebAssembly when a script policy is set (MDN: CSP script-src, W3C CSP).
  • worker-src controls Worker/blob worker sources: the worker-src directive governs the origins from which Workers, SharedWorkers, and ServiceWorkers may be loaded, including blob: (MDN: CSP worker-src).
  • SRI applies to elements, not dynamic module imports: Subresource Integrity is defined for fetched elements like <script> and <link>; a runtime import() is not covered the same way, which is why pinning and self-hosting matter (MDN: Subresource Integrity).
  • SharedArrayBuffer requires cross-origin isolation (COOP + COEP): threaded WebAssembly needs SharedArrayBuffer, which is only available to cross-origin-isolated documents set with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp (MDN: SharedArrayBuffer / cross-origin isolation).
  • Untrusted HTML injection is the XSS mechanism: assigning attacker-influenceable strings to innerHTML can execute script; use textContent or sanitize (MDN: Security / XSS).

This post is informational, not security-consulting advice. Mentions of third-party projects and vendors are nominative fair use. No affiliation is implied. Web platform behavior and security guidance change; verify against current documentation before you ship.

← Back to Blog

Accessibility Options

Text Size
High Contrast
Reduce Motion
Reading Guide
Link Highlighting
Accessibility Statement

J.A. Watte is committed to ensuring digital accessibility for people with disabilities. This site conforms to WCAG 2.1 and 2.2 Level AA guidelines.

Measures Taken

  • Semantic HTML with proper heading hierarchy
  • ARIA labels and roles for interactive components
  • Color contrast ratios meeting WCAG AA (4.5:1)
  • Full keyboard navigation support
  • Skip navigation link
  • Visible focus indicators (3:1 contrast)
  • 44px minimum touch/click targets
  • Dark/light theme with system preference detection
  • Responsive design for all devices
  • Reduced motion support (CSS + toggle)
  • Text size customization (14px–20px)
  • Print stylesheet

Feedback

Contact: jwatte.com/contact

Full Accessibility StatementPrivacy Policy

Last updated: April 2026