# Obfuscate JavaScript on every request in Cloudflare Workers

To obfuscate JavaScript on every request in a Cloudflare Worker, call AfterPack's @afterpack/wasm with a random seed, or cache one version per time window.

Source: https://www.afterpack.dev/blog/obfuscate-javascript-cloudflare-workers

Published: 2026-09-28 · Author: Nikita Savchenko · Tags: guide, security

To obfuscate JavaScript on every request in a Cloudflare Worker, install `@afterpack/wasm` and call `obfuscate()` in your `fetch` handler with a random `seed`: each response is a structurally different script that behaves the same. The package is the engine of AfterPack (afterpack.dev), the JavaScript obfuscator, compiled to WebAssembly; it is free and runs inside your Worker. Real bundles exceed the free plan's 10 ms CPU cap, so use Workers Paid and cache one obfuscated version per time window.

I ran everything below with `wrangler dev`: the exact Worker, the curl output showing two different bodies for the same URL, and a cached variant that rotates every ten minutes instead of every request. Code obfuscation on every request takes one npm package and under twenty lines of Worker code.

AI made reading shipped JavaScript cheap: an AI agent [took minutes](https://www.afterpack.dev/blog/ai-deobfuscates-javascript) to turn obfuscated demos back into clean source. The [AfterPack CLI](https://www.afterpack.dev/docs/cli) and framework plugins answer that per release, with a new seed on every build ([launch post](https://www.afterpack.dev/blog/announcing-afterpack-javascript-obfuscator)). Inside your own Worker, the same engine moves that cadence to every request, so anything automated against one copy of a script has to be rebuilt for the next.

## What does code obfuscation on every request stop?

It stops hooks, patches and deobfuscation scripts built against one response from working on the next, because identifiers, offsets and decoder positions move every time. That matters for the scripts automation keeps coming back to: anti-bot and challenge scripts, client-side integrity checks, and scripts that scrapers patch or hook to get around them.

Each response is also a full AfterPack build: algorithms are rewritten, control flow is flattened and identities are fused, so even a reversed copy differs from the code you wrote. Every response is still the same program, though, and [no obfuscator, AfterPack included, makes reversal impossible](https://www.afterpack.dev/docs/threat-model#what-obfuscation-protects).

## How to install a JavaScript obfuscator in a Cloudflare Worker

Install `@afterpack/wasm` next to `wrangler` and import `obfuscate` from it. There is no `init` call, no `nodejs_compat` flag and no wrangler rule for the `.wasm` file.

Versions I used, on 2026-09-27: `@afterpack/wasm` 0.1.0, `wrangler` 4.141.0, Node 24.21.0, on an Apple M2 Max.

```bash
npm i @afterpack/wasm wrangler
```

`@afterpack/wasm` is the same Rust engine the CLI and plugins run, compiled to WebAssembly; the package's `exports` map hands wrangler a Worker-shaped entry ([docs](https://www.afterpack.dev/docs/workers)). The same import works in Node, where a different entry reads the binary off disk, though in a normal Node build the native [`@afterpack/core`](https://www.afterpack.dev/docs/frameworks) is faster.

The demo script is a small client-side price calculator. It stands in for a challenge or integrity script, and it is short enough to check the output against by hand:

**src/pricing.client.js — the source being served**

```js
const TIERS = [
  { min: 1, max: 9, unit: 12 },
  { min: 10, max: 49, unit: 10 },
  { min: 50, max: Infinity, unit: 8 },
];

function unitPrice(quantity) {
  const tier = TIERS.find((t) => quantity >= t.min && quantity <= t.max);
  return tier ? tier.unit : TIERS[0].unit;
}

export function quote(quantity, coupon) {
  let total = unitPrice(quantity) * quantity;
  if (coupon === "LAUNCH20" && quantity >= 10) total *= 0.8;
  return Math.round(total * 100) / 100;
}
```

The Worker imports that file as a string, which takes one wrangler rule for the source file (the engine needs none). `wrangler.jsonc`:

```jsonc
{
  "name": "per-request-obfuscation",
  "main": "src/worker.js",
  "compatibility_date": "2026-09-25",
  "vars": { "WINDOW_SECONDS": "600" },
  "rules": [{ "type": "Text", "globs": ["**/*.client.js"], "fallthrough": true }]
}
```

## How to obfuscate JavaScript on every request

Draw a random seed in the `fetch` handler, pass it to `obfuscate()`, and return the result with `cache-control: no-store`. Every response is then built from its own seed.

`src/worker.js`:

```js
import { obfuscate } from "@afterpack/wasm";
import source from "./pricing.client.js";

export default {
  async fetch(request) {
    if (new URL(request.url).pathname !== "/pricing.js") {
      return new Response("Not found", { status: 404 });
    }
    const seed = crypto.getRandomValues(new Uint32Array(1))[0];
    const result = await obfuscate({ path: "pricing.js", source }, { preset: "hard", seed });
    return new Response(result.bytes, {
      headers: {
        "content-type": "application/javascript",
        "cache-control": "no-store",
      },
    });
  },
};
```

In a Worker, always pass [`seed`](https://www.afterpack.dev/docs/config#seed) yourself: the random seed from `crypto.getRandomValues` is what makes each response different. `cache-control: no-store` keeps it that way, because a browser, another CDN in front of your domain or a corporate proxy could otherwise keep the first version, and the per-request work would buy nothing. To reproduce a response later, log its seed or return it in a response header; the seed is not a secret.

Run `npx wrangler dev`, then fetch the same URL twice:

```bash
$ for i in 1 2; do curl -s localhost:8787/pricing.js | shasum -a 256; done
0265ae6f5cf5a0839ee52a9a08bab6326503849c66296ae581c2c21923d2d676  -
a5684abbb2ba13be3c182840d30b1f2a634b241013e112be70c6de22ac1233b4  -

$ for i in 1 2; do curl -s localhost:8787/pricing.js | head -c 100; echo; done
var DA=($d,wS,Uv)=>((tD*(tD+1)&1)===0?(((tD*tD*tD-tD)%3|0)===0?$d:tD^32)[((tD*tD-tD&1)===0?e:tD&739)
var Sg=(H5,xS,nF)=>((Ye*(Ye+1)*(Ye+5)%6|0)===0?H5:Ye+124)[((Ye*(Ye+3)%2|0)!==0?Ye+100:B)](((Ye*(Ye+1

$ for i in 1 2; do curl -s localhost:8787/pricing.js | wc -c; done
    3823
    3545
```

The hashes, identifiers and lengths all differ, and neither body contains `TIERS` or `unitPrice`. I saved two responses as modules and called `quote()` on each next to the original for six quantity/coupon pairs; all three returned the same totals. The coupon string is still in there, encoded and decoded at runtime, which is fine: the server that takes the order recomputes the price and checks the coupon, and this client copy only renders the quote.

## How to cache obfuscated JavaScript and rotate it every 10 minutes

Derive the seed from the current time window instead of drawing a random one, and cache the result under a key that includes the window. Each location then builds one obfuscated version per window and serves it until the window ends, so visitors stop paying CPU for a fresh build each:

**src/rotating.js — one version per window, cached**

```js
import { obfuscate } from "@afterpack/wasm";
import source from "./pricing.client.js";

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname !== "/pricing.js") {
      return new Response("Not found", { status: 404 });
    }

    const windowSeconds = Number(env.WINDOW_SECONDS);
    const now = Math.floor(Date.now() / 1000);
    const slot = Math.floor(now / windowSeconds);
    const secondsLeft = (slot + 1) * windowSeconds - now;
    const cacheKey = new Request(`${url.origin}/pricing.js?slot=${slot}`);

    let response = await caches.default.match(cacheKey);
    if (!response) {
      const result = await obfuscate(
        { path: "pricing.js", source },
        { preset: "hard", seed: `pricing.js:${slot}` },
      );
      response = new Response(result.bytes, {
        headers: {
          "content-type": "application/javascript",
          "cache-control": `public, max-age=${secondsLeft}`,
        },
      });
      ctx.waitUntil(caches.default.put(cacheKey, response.clone()));
    }

    response = new Response(response.body, response);
    response.headers.set("cache-control", `public, max-age=${secondsLeft}`);
    return response;
  },
};
```

The config above sets a 600-second window. To watch it rotate without waiting ten minutes, I overrode it to 15 seconds:

```bash
npx wrangler dev src/rotating.js --port 8788 --var WINDOW_SECONDS:15
```

```bash
$ for i in 1 2 3 4 5; do
    date +%T
    curl -s -D headers.txt localhost:8788/pricing.js | shasum -a 256 | cut -c1-16
    grep -i cache-control headers.txt
    sleep 4
  done
15:03:46
951fdcf8a3cf73ef
Cache-Control: public, max-age=14
15:03:50
951fdcf8a3cf73ef
Cache-Control: public, max-age=10
15:03:54
951fdcf8a3cf73ef
Cache-Control: public, max-age=6
15:03:58
951fdcf8a3cf73ef
Cache-Control: public, max-age=2
15:04:03
4120ce5292b9cf92
Cache-Control: public, max-age=12
```

Same bytes inside the window, new bytes after it, and `max-age` counts down to the boundary.

Three details make this work:

- **The same seed gives the same bytes.** The same seed string, source and package version gave identical output on repeated calls in my test. Cloudflare's Cache API does not replicate outside the data center that stored the entry ([Cloudflare docs](https://developers.cloudflare.com/workers/runtime-apis/cache/)), so each location builds its own copy on its first miss; because the seed comes from the window, every location running the same deployed version should serve the same bytes for that window. The seed is [not a secret](https://www.afterpack.dev/docs/builds#random-seed-by-default-pin-for-reproducibility): it only lets someone reproduce the output from your source, which they don't have.
- **Browsers get the time left, not the time stored.** A cache hit comes back with the headers it was stored with, so the Worker rewrites `cache-control` on the way out. Without that, a browser that hit late in a window would keep the old version for up to one more window. The slot is in the cache key, so a new window never reads the old entry.
- **Test caching locally or on your own domain.** The Cache API has no effect in the dashboard editor or Playground previews, so use `wrangler dev` or a deployed route.

## How much CPU does obfuscation in a Worker cost?

For this 16-line file, 4–12 ms of request time once warm under `wrangler dev`. A real bundle takes hundreds of milliseconds per build at `light` and seconds at `hard`, well past the Workers free plan's 10 ms CPU cap.

Across five cold starts of `wrangler dev` on my M2 Max, the first request took 59–338 ms (engine start-up included) and later ones settled at 4–12 ms. Those are request times in a local workerd process, not CPU time in an edge isolate, so measure on Cloudflare before you size anything. For scale, the docs [measured](https://www.afterpack.dev/docs/performance#the-wasm-build) 271 ms at `light` and 1,558 ms at `medium` in Node for a 185 KB bundle with AfterPack 0.2.0; this demo runs `hard`, which is [complexity](https://www.afterpack.dev/docs/complexity) 12 ([presets](https://www.afterpack.dev/docs/presets)).

The engine fits the Worker size limit on either plan. The free plan's CPU cap is what rules it out for anything past a tiny script ([docs](https://www.afterpack.dev/docs/workers)).

## Per request, per window or per build?

Obfuscate per build for large bundles and anything on the page-load path, per window for scripts you want to rotate cheaply, and per request only where someone runs the same automation against your script over and over.

| Cadence | Where it runs | Builds | Caching | Fits |
|---|---|---|---|---|
| Per build | [CLI](https://www.afterpack.dev/docs/cli) or a plugin, in CI | One per deploy | Your CDN, like any file | Large bundles, the page-load hot path |
| Per window | Your Worker, seed from the time slot | One per window per location | Cache API, `max-age` = seconds left | Scripts you want to rotate without paying per visitor |
| Per request | Your Worker, random seed | One per request | `no-store` | Anti-bot and challenge scripts, integrity checks, scripts scrapers patch |

## How to run Pro obfuscation from a Worker

Pass a [`key`](https://www.afterpack.dev/docs/config#key), for example `{ key: env.AFTERPACK_KEY }` from a Worker secret, and the same `obfuscate()` call runs [Pro](https://www.afterpack.dev/docs/pro) in AfterPack's cloud. The package you install performs Free protection on its own.

That call belongs in the rotation Worker, not the per-request one, where every visitor would wait on a round trip to a cloud build. Watch the window length too: Pro is [metered per MB processed](https://www.afterpack.dev/docs/pro#what-pro-costs), and every location builds its own copy once per window. A 20 KB script at a 10-minute window in 30 locations is about 86 MB a day, 2.6 GB a month, well past Indie's 500 MB. At a 6-hour window the same script is about 72 MB a month.

Every cloud build is also a recorded build with its own [Protection Map](https://www.afterpack.dev/docs/protection-map), and a project keeps only its last 500, so [turn map storage off](https://www.afterpack.dev/docs/protection-map#turning-it-off) for the project your rotation Worker builds under. Your source leaves the Worker for that call and is processed in memory and discarded ([privacy](https://www.afterpack.dev/docs/privacy)). If the cloud can't be reached, the call [fails closed](https://www.afterpack.dev/docs/diagnostics#fail-closed-no-exceptions): it throws and nothing unprotected ships. Catch the throw and serve a version kept from an earlier window, or the visitor gets an error.

## Try it

Copy the three files above (`wrangler.jsonc`, `src/worker.js`, `src/pricing.client.js`), run `npm i @afterpack/wasm wrangler` and `npx wrangler dev`, then curl `/pricing.js` twice. The package reference is [Obfuscate JavaScript inside a Cloudflare Worker](https://www.afterpack.dev/docs/workers); keep the explicit `seed` from this post when you adapt it.

## FAQ

### Can I obfuscate JavaScript on the Cloudflare Workers free plan?

Only for tiny scripts, and even then the first request per isolate (59–338 ms of request time under `wrangler dev`, engine start-up included) can exceed the free plan's 10 ms CPU cap. Use Workers Paid, ideally with the per-window cache above.

### Does per-request obfuscation slow down my site?

For this 16-line script, warm requests took 4–12 ms under `wrangler dev`, and the first request per isolate took 59–338 ms. With the per-window cache, only the first request per window per location builds; most cache hits came back in 2–3 ms. Per-build obfuscation adds nothing at request time.

### Can per-request obfuscation stop web scraping and bots?

It breaks hooks, patches and scrapers written against one response, because the next response is a different script. It won't stop a person from reading a response, and it doesn't detect bots by itself; it raises the cost of automating against the scripts your bot checks rely on.

### Does obfuscating JavaScript per request hide API keys?

No. Anything the browser needs, the browser can read, whatever form it arrives in. Keep secrets and decisions like the final price or coupon validity on the server, as the demo does. [How to protect JavaScript source code](https://www.afterpack.dev/blog/protect-javascript-source-code) shows how to check what your site already ships.

### Can AfterPack serve per-request obfuscation without my own Worker?

No. The Worker above, in your own account, is the integration. For Pro, put your key in the same `obfuscate()` call: the build runs on AfterPack's cloud with the Pro engine, and a file too big for a Worker moves to a container automatically. The harder question is which of your scripts actually has someone running the same automation against it every day.
