El Blog de AfterPack
guidesecurity

Ofuscar JavaScript en cada petición con Cloudflare Workers

por Nikita Savchenko10 min de lectura

Para ofuscar JavaScript en cada petición en un Cloudflare Worker, instala @afterpack/wasm y llama a obfuscate() en tu handler fetch con una seed aleatoria: cada respuesta es un script estructuralmente distinto que se comporta igual. El paquete es el motor de AfterPack (afterpack.dev), el ofuscador de JavaScript, compilado a WebAssembly; es gratuito y se ejecuta dentro de tu Worker. Los bundles reales superan el límite de 10 ms de CPU del plan gratuito, así que usa Workers Paid y cachea una versión ofuscada por ventana de tiempo.

Ejecuté todo lo de abajo con wrangler dev: el Worker exacto, la salida de curl con dos cuerpos distintos para la misma URL y una variante con caché que rota cada diez minutos en lugar de en cada petición. La ofuscación de código en cada petición cabe en un paquete de npm y menos de veinte líneas de código del Worker.

La IA abarató leer el JavaScript que publicas: a un agente de IA le bastaron minutos para convertir demos ofuscadas de nuevo en código fuente limpio. El CLI de AfterPack y los plugins de framework responden a eso en cada release, con una semilla nueva en cada build (post de lanzamiento). Dentro de tu propio Worker, el mismo motor lleva ese ritmo a cada petición, así que cualquier automatización escrita contra una copia de un script hay que rehacerla para la siguiente.

¿Qué frena la ofuscación de código en cada petición?

Hace que los hooks, parches y scripts de desofuscación construidos contra una respuesta dejen de funcionar en la siguiente, porque los identificadores, los offsets y las posiciones del decodificador se mueven cada vez. Eso importa en los scripts a los que la automatización vuelve una y otra vez: scripts anti-bot y de desafío, comprobaciones de integridad en el cliente y scripts que los scrapers parchean o interceptan para saltárselos.

Cada respuesta es además un build completo de AfterPack: los algoritmos se reescriben, el flujo de control se aplana y las identidades se fusionan, así que incluso una copia revertida difiere del código que escribiste. Aun así, todas las respuestas son el mismo programa, y ningún ofuscador, AfterPack incluido, hace imposible la reversión.

Cómo instalar un ofuscador de JavaScript en un Cloudflare Worker

Instala @afterpack/wasm junto a wrangler e importa obfuscate desde él. No hay llamada init, ni flag nodejs_compat, ni regla de wrangler para el archivo .wasm.

Versiones que usé, el 2026-09-27: @afterpack/wasm 0.1.0, wrangler 4.141.0, Node 24.21.0, en un Apple M2 Max.

npm i @afterpack/wasm wrangler

@afterpack/wasm es el mismo motor en Rust que ejecutan el CLI y los plugins, compilado a WebAssembly; el mapa exports del paquete le da a wrangler una entrada con forma de Worker (docs). El mismo import funciona en Node, donde otra entrada lee el binario del disco, aunque en un build normal de Node el nativo @afterpack/core es más rápido.

El script de la demo es una pequeña calculadora de precios del lado del cliente. Hace de sustituto de un script de desafío o de integridad, y es lo bastante corto para comprobar la salida a mano:

src/pricing.client.js — el código fuente que se sirve
1const TIERS = [
2 { min: 1, max: 9, unit: 12 },
3 { min: 10, max: 49, unit: 10 },
4 { min: 50, max: Infinity, unit: 8 },
5];
6
7function unitPrice(quantity) {
8 const tier = TIERS.find((t) => quantity >= t.min && quantity <= t.max);
9 return tier ? tier.unit : TIERS[0].unit;
10}
11
12export function quote(quantity, coupon) {
13 let total = unitPrice(quantity) * quantity;
14 if (coupon === "LAUNCH20" && quantity >= 10) total *= 0.8;
15 return Math.round(total * 100) / 100;
16}

El Worker importa ese archivo como string, lo que requiere una regla de wrangler para el archivo fuente (el motor no necesita ninguna). wrangler.jsonc:

1{
2 "name": "per-request-obfuscation",
3 "main": "src/worker.js",
4 "compatibility_date": "2026-09-25",
5 "vars": { "WINDOW_SECONDS": "600" },
6 "rules": [{ "type": "Text", "globs": ["**/*.client.js"], "fallthrough": true }]
7}

Cómo ofuscar JavaScript en cada petición

Genera una semilla aleatoria en el handler fetch, pásala a obfuscate() y devuelve el resultado con cache-control: no-store. Así cada respuesta se construye a partir de su propia semilla.

src/worker.js:

1import { obfuscate } from "@afterpack/wasm";
2import source from "./pricing.client.js";
3
4export default {
5 async fetch(request) {
6 if (new URL(request.url).pathname !== "/pricing.js") {
7 return new Response("Not found", { status: 404 });
8 }
9 const seed = crypto.getRandomValues(new Uint32Array(1))[0];
10 const result = await obfuscate({ path: "pricing.js", source }, { preset: "hard", seed });
11 return new Response(result.bytes, {
12 headers: {
13 "content-type": "application/javascript",
14 "cache-control": "no-store",
15 },
16 });
17 },
18};

En un Worker, pasa siempre la seed tú mismo: la semilla aleatoria de crypto.getRandomValues es lo que hace distinta cada respuesta. cache-control: no-store lo mantiene así, porque un navegador, otro CDN delante de tu dominio o un proxy corporativo podrían quedarse con la primera versión, y el trabajo por petición no serviría de nada. Para reproducir una respuesta más tarde, registra su semilla o devuélvela en una cabecera de respuesta; la semilla no es un secreto.

Ejecuta npx wrangler dev y pide la misma URL dos veces:

1$ for i in 1 2; do curl -s localhost:8787/pricing.js | shasum -a 256; done
20265ae6f5cf5a0839ee52a9a08bab6326503849c66296ae581c2c21923d2d676 -
3a5684abbb2ba13be3c182840d30b1f2a634b241013e112be70c6de22ac1233b4 -
4
5$ for i in 1 2; do curl -s localhost:8787/pricing.js | head -c 100; echo; done
6var 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)
7var 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
8
9$ for i in 1 2; do curl -s localhost:8787/pricing.js | wc -c; done
10 3823
11 3545

Los hashes, los identificadores y las longitudes cambian, y ningún cuerpo contiene TIERS ni unitPrice. Guardé dos respuestas como módulos y llamé a quote() en cada una, junto al original, con seis pares de cantidad y cupón; los tres devolvieron los mismos totales. El string del cupón sigue ahí dentro, codificado y decodificado en tiempo de ejecución, y no pasa nada: el servidor que recibe el pedido recalcula el precio y valida el cupón, y esta copia del cliente solo muestra el presupuesto.

Cómo cachear JavaScript ofuscado y rotarlo cada 10 minutos

Deriva la semilla de la ventana de tiempo actual en lugar de generarla al azar, y cachea el resultado con una clave que incluya la ventana. Cada ubicación construye entonces una versión ofuscada por ventana y la sirve hasta que la ventana termina, así que los visitantes dejan de pagar CPU por un build nuevo cada uno:

src/rotating.js — una versión por ventana, cacheada
1import { obfuscate } from "@afterpack/wasm";
2import source from "./pricing.client.js";
3
4export default {
5 async fetch(request, env, ctx) {
6 const url = new URL(request.url);
7 if (url.pathname !== "/pricing.js") {
8 return new Response("Not found", { status: 404 });
9 }
10
11 const windowSeconds = Number(env.WINDOW_SECONDS);
12 const now = Math.floor(Date.now() / 1000);
13 const slot = Math.floor(now / windowSeconds);
14 const secondsLeft = (slot + 1) * windowSeconds - now;
15 const cacheKey = new Request(`${url.origin}/pricing.js?slot=${slot}`);
16
17 let response = await caches.default.match(cacheKey);
18 if (!response) {
19 const result = await obfuscate(
20 { path: "pricing.js", source },
21 { preset: "hard", seed: `pricing.js:${slot}` },
22 );
23 response = new Response(result.bytes, {
24 headers: {
25 "content-type": "application/javascript",
26 "cache-control": `public, max-age=${secondsLeft}`,
27 },
28 });
29 ctx.waitUntil(caches.default.put(cacheKey, response.clone()));
30 }
31
32 response = new Response(response.body, response);
33 response.headers.set("cache-control", `public, max-age=${secondsLeft}`);
34 return response;
35 },
36};

La configuración de arriba fija una ventana de 600 segundos. Para verla rotar sin esperar diez minutos, la bajé a 15 segundos:

npx wrangler dev src/rotating.js --port 8788 --var WINDOW_SECONDS:15
1$ for i in 1 2 3 4 5; do
2 date +%T
3 curl -s -D headers.txt localhost:8788/pricing.js | shasum -a 256 | cut -c1-16
4 grep -i cache-control headers.txt
5 sleep 4
6 done
715:03:46
8951fdcf8a3cf73ef
9Cache-Control: public, max-age=14
1015:03:50
11951fdcf8a3cf73ef
12Cache-Control: public, max-age=10
1315:03:54
14951fdcf8a3cf73ef
15Cache-Control: public, max-age=6
1615:03:58
17951fdcf8a3cf73ef
18Cache-Control: public, max-age=2
1915:04:03
204120ce5292b9cf92
21Cache-Control: public, max-age=12

Los mismos bytes dentro de la ventana, bytes nuevos después, y max-age cuenta hacia atrás hasta el límite.

Tres detalles hacen que esto funcione:

  • La misma semilla da los mismos bytes. La misma semilla, el mismo código fuente y la misma versión del paquete dieron una salida idéntica en llamadas repetidas en mi prueba. La Cache API de Cloudflare no replica fuera del centro de datos que guardó la entrada (documentación de Cloudflare), así que cada ubicación construye su propia copia en su primer fallo de caché; como la semilla sale de la ventana, todas las ubicaciones que ejecutan la misma versión desplegada deberían servir los mismos bytes durante esa ventana. La semilla no es un secreto: solo permite reproducir la salida a partir de tu código fuente, que nadie más tiene.
  • Los navegadores reciben el tiempo que queda, no el tiempo guardado. Un acierto de caché vuelve con las cabeceras con las que se guardó, así que el Worker reescribe cache-control a la salida. Sin eso, un navegador que llegara tarde en una ventana conservaría la versión vieja hasta una ventana más. El slot va en la clave de caché, así que una ventana nueva nunca lee la entrada anterior.
  • Prueba la caché en local o en tu propio dominio. La Cache API no tiene efecto en el editor del dashboard ni en las vistas previas del Playground, así que usa wrangler dev o una ruta desplegada.

¿Cuánta CPU cuesta ofuscar en un Worker?

Para este archivo de 16 líneas, 4–12 ms de tiempo de petición en caliente con wrangler dev. Un bundle real tarda cientos de milisegundos por build en light y segundos en hard, muy por encima del límite de 10 ms de CPU del plan gratuito de Workers.

En cinco arranques en frío de wrangler dev en mi M2 Max, la primera petición tardó entre 59 y 338 ms (incluido el arranque del motor) y las siguientes se estabilizaron en 4–12 ms. Son tiempos de petición en un proceso local de workerd, no tiempo de CPU en un isolate del edge, así que mide en Cloudflare antes de dimensionar nada. Como referencia, la documentación midió 271 ms en light y 1558 ms en medium en Node para un bundle de 185 KB; esta demo usa hard, que es complejidad 12 (presets).

El motor cabe en el límite de tamaño de Workers en cualquiera de los dos planes. Lo que lo descarta en el plan gratuito, salvo para un script diminuto, es el límite de CPU (docs).

¿Por petición, por ventana o por build?

Ofusca por build los bundles grandes y todo lo que esté en el camino de carga de la página, por ventana los scripts que quieres rotar sin gastar mucho, y por petición solo donde alguien ejecuta la misma automatización contra tu script una y otra vez.

RitmoDónde se ejecutaBuildsCachéEncaja con
Por buildCLI o un plugin, en CIUno por despliegueTu CDN, como cualquier archivoBundles grandes, el camino crítico de carga
Por ventanaTu Worker, semilla a partir del slot de tiempoUno por ventana y por ubicaciónCache API, max-age = segundos restantesScripts que quieres rotar sin pagar por visitante
Por peticiónTu Worker, semilla aleatoriaUno por peticiónno-storeScripts anti-bot y de desafío, comprobaciones de integridad, scripts que los scrapers parchean

Cómo ejecutar la ofuscación Pro desde un Worker

Pasa una key, por ejemplo { key: env.AFTERPACK_KEY } desde un secreto del Worker, y la misma llamada a obfuscate() ejecuta Pro en la nube de AfterPack. El paquete que instalas aplica la protección Free por sí solo.

Esa llamada va en el Worker de rotación, no en el de cada petición, donde cada visitante esperaría el viaje de ida y vuelta a un build en la nube. Vigila también la duración de la ventana: Pro se factura por MB procesado, y cada ubicación construye su propia copia una vez por ventana. Un script de 20 KB con una ventana de 10 minutos en 30 ubicaciones son unos 86 MB al día, 2,6 GB al mes, muy por encima de los 500 MB de Indie. Con una ventana de 6 horas, el mismo script son unos 72 MB al mes.

Cada build en la nube es además un build registrado con su propio Protection Map, y un proyecto conserva solo los últimos 500, así que desactiva el almacenamiento de mapas en el proyecto con el que construye tu Worker de rotación. Tu código fuente sale del Worker para esa llamada, se procesa en memoria y se descarta (privacidad). Si no se puede llegar a la nube, la llamada falla en cerrado: lanza una excepción y no se publica nada sin proteger. Captura la excepción y sirve una versión guardada de una ventana anterior, o el visitante recibirá un error.

Pruébalo

Copia los tres archivos de arriba (wrangler.jsonc, src/worker.js, src/pricing.client.js), ejecuta npm i @afterpack/wasm wrangler y npx wrangler dev, y luego haz curl a /pricing.js dos veces. La referencia del paquete está en Ofusca JavaScript dentro de un Cloudflare Worker; conserva la seed explícita de este post cuando lo adaptes.

Preguntas frecuentes

¿Puedo ofuscar JavaScript en el plan gratuito de Cloudflare Workers?

Solo para scripts diminutos, e incluso entonces la primera petición por isolate (59–338 ms de tiempo de petición con wrangler dev, arranque del motor incluido) puede superar el límite de 10 ms de CPU del plan gratuito. Usa Workers Paid, a ser posible con la caché por ventana de arriba.

¿La ofuscación por petición ralentiza mi sitio?

Para este script de 16 líneas, las peticiones en caliente tardaron 4–12 ms con wrangler dev, y la primera petición por isolate, 59–338 ms. Con la caché por ventana, solo la primera petición de cada ventana en cada ubicación construye; la mayoría de los aciertos de caché volvieron en 2–3 ms. La ofuscación por build no añade nada en tiempo de petición.

¿Puede la ofuscación por petición frenar el web scraping y los bots?

Rompe los hooks, parches y scrapers escritos contra una respuesta, porque la siguiente respuesta es un script distinto. No impide que una persona lea una respuesta, y por sí sola no detecta bots; lo que hace es encarecer la automatización contra los scripts en los que se apoyan tus comprobaciones anti-bot.

¿Ofuscar JavaScript por petición oculta las claves de API?

No. Todo lo que el navegador necesita, el navegador lo puede leer, llegue en la forma que llegue. Mantén los secretos y las decisiones como el precio final o la validez de un cupón en el servidor, como hace la demo. Cómo proteger el código fuente JavaScript explica cómo comprobar qué publica ya tu sitio.

¿Puede AfterPack servir ofuscación por petición sin mi propio Worker?

Todavía no. Está en el roadmap, no disponible aún. Hasta entonces el Worker de arriba es toda la integración, y la pregunta difícil es cuál de tus scripts tiene de verdad a alguien ejecutando la misma automatización contra él cada día.

Mantente al día

Sigue a AfterPack para notas de versión y artículos.