Para proteger el código fuente JavaScript, deja de servir source maps, mantén los secretos y las comprobaciones de acceso en el servidor y ofusca el código que tiene que llegar al navegador. Cada visitante descarga tu bundle, y la minificación solo acorta los nombres locales: los strings, las rutas de la API, los feature flags y las reglas de precios siguen legibles. AfterPack (afterpack.dev), el ofuscador de JavaScript, convierte tu bundle en un programa estructuralmente distinto en cada build. Su CLI de código abierto funciona gratis y en local con npx afterpack; Pro añade directivas por región y anti-tamper.
Para mostrar cómo se ve esto, monté una pequeña app de demo: cuatro archivos TypeScript con precios por asiento, códigos promocionales, feature flags, un límite de exportaciones en el plan gratuito y dos llamadas a la API. La empaqueté con esbuild 0.28.1 (--bundle --minify --sourcemap) el 27 de septiembre de 2026. Todo lo que sigue es salida real de ese build, y puedes hacer las mismas comprobaciones en tu propio sitio en producción en unos cinco minutos con DevTools, un curl y el escáner de seguridad gratuito.
¿Pueden los usuarios ver tu código JavaScript?
Sí. Cada script que ejecuta tu página se descarga en el navegador del visitante, y DevTools los muestra todos.
«Ver código fuente de la página» (Ctrl+U, o Cmd+Option+U en macOS) solo muestra el HTML y sus etiquetas de script; DevTools muestra los scripts en sí. Las apps de React, Next.js y Vue se envían igual: tus componentes llegan como archivos JavaScript que cualquiera puede abrir.
Abre tu sitio en producción, no localhost. En Chrome, abre DevTools (Cmd+Option+I en macOS, F12 en el resto) y ve a la pestaña Sources. Tus bundles aparecen bajo tu dominio. Haz clic en uno. Si no está formateado todavía, pulsa el botón {} de abajo.
Ahora busca en todos los archivos cargados con Cmd+Option+F (Ctrl+Shift+F en Windows y Linux). Prueba /api, /v1, internal, admin, free, pro, trial, promo y el nombre de cualquier funcionalidad que aún no hayas anunciado.
¿Qué expone el JavaScript minificado?
Todo menos los nombres locales. El bundle minificado completo de mi demo ocupa 995 bytes:
| (()=>{var f={LAUNCH40:.4,PARTNER_FRIENDS:.5};function i(e,t,o){let r=e*1200;e>=25&&(r*=.9),t&&(r*=1-.2);let p=o?f[o.toUpperCase()]??0:0;return r*=1-p,Math.round(r)}function a(e,t){return e==="free"?t<3:!0}var l={newCheckout:!0,aiSummaries:!1,teamSeatsBeta:!0};function s(e,t){return e==="aiSummaries"?t==="pro"||t==="team":l[e]}var u="https://api.example.com";async function c(e){return(await fetch(`${u}/v2/billing/invoices`,{headers:{Authorization:`Bearer ${e}`}})).json()}async function m(e){return fetch(`${u}/internal/admin/trials/${e}/extend`,{method:"POST"})}var n={id:"u_1",plan:"free",exportsThisMonth:2};document.querySelector("#price").textContent=String(i(10,!0,"launch40")/100);document.querySelector("#export").addEventListener("click",()=>{if(!a(n.plan,n.exportsThisMonth)){alert("Upgrade to Pro to export more than 3 files a month");return}s("aiSummaries",n.plan)&&console.log("summaries on")});window.__debug={fetchInvoices:c,grantTrial:m};})(); |
| //# sourceMappingURL=app.js.map |
Los nombres locales desaparecieron; todo lo demás sigue ahí, incluidos los nombres que puse en window a propósito, como grantTrial:
| Qué tenía el código fuente | ¿Legible tras minificar? | Dónde está en el bundle |
|---|---|---|
| Nombres locales de funciones y variables | No, acortados | quote() pasó a ser i() |
| Códigos promocionales y sus descuentos | Sí | LAUNCH40:.4,PARTNER_FRIENDS:.5 |
| Precio por asiento en céntimos y el descuento a partir de 25 asientos | Sí | e*1200, e>=25&&(r*=.9) |
| El límite de tres exportaciones del plan gratuito | Sí | e==="free"?t<3:!0 |
| Los tres feature flags | Sí | newCheckout:!0,aiSummaries:!1,teamSeatsBeta:!0 |
| Host y rutas de la API, incluida una de administración | Sí | /v2/billing/invoices, /internal/admin/trials/${e}/extend |
Nombres asignados a window | Sí | window.__debug={fetchInvoices:c,grantTrial:m} |
| Archivos originales, tipos y nombres de constantes | Solo si el source map es público | la línea sourceMappingURL |
La minificación hace los archivos más pequeños; nunca se pensó para esconder nada.
¿Son los source maps un riesgo de seguridad en producción?
Sí, cuando el map es público. Un source map suele incluir tus archivos originales, así que servirlo es publicar tu código fuente.
Mira la última línea de cada bundle. Un comentario como //# sourceMappingURL=app.js.map le dice al navegador dónde está el map. Algunas configuraciones lo envían como cabecera de respuesta SourceMap, así que revisa también la pestaña Network.
Después pide el map tú mismo y mira el cuerpo, no el código de estado. Muchos hostings responden a cualquier ruta desconocida con 200 y tu index.html. Si esto imprime 3, estás publicando un source map:
| curl -s https://example.com/assets/app.js.map | jq .version |
| # 3 (my demo, served locally; an HTML page gives a jq parse error instead) |
Un source map suele llevar un array sourcesContent, un campo opcional del formato source map: tus archivos originales, tal como los escribiste. Dos llamadas a jq los listan e imprimen uno:
| 1 | curl -s https://example.com/assets/app.js.map | jq -r '.sources[]' |
| 2 | # ../src/pricing.ts |
| 3 | # ../src/flags.ts |
| 4 | # ../src/api.ts |
| 5 | # ../src/app.ts |
| 6 | |
| 7 | curl -s https://example.com/assets/app.js.map | jq -r '.sourcesContent[0]' | head -8 |
| 8 | # const SEAT_PRICE_CENTS = 1200; |
| 9 | # const ANNUAL_DISCOUNT = 0.2; |
| 10 | # const PROMO_CODES: Record<string, number> = { |
| 11 | # LAUNCH40: 0.4, |
| 12 | # PARTNER_FRIENDS: 0.5, |
| 13 | # }; |
| 14 | # |
| 15 | # export function quote(seats: number, annual: boolean, promo?: string) { |
Ese es mi TypeScript, con sus tipos y los nombres de sus constantes, sacado directamente del archivo en mi disco. DevTools hace lo mismo en silencio: cuando carga el map, la pestaña Sources muestra tu árbol de carpetas original junto al bundle. Un map publicado es también lo que convirtió el paquete npm de Claude Code en noticia, aunque su bundle llevaba todo ese tiempo siendo legible.
¿Puede la IA leer JavaScript minificado u ofuscado?
Sí. Un modelo actual explica un bundle minificado con una sola instrucción, hasta la fórmula de precios y un hook de administración expuesto, y los agentes de IA también leen código ofuscado: en una prueba anterior, uno devolvió las demos publicadas de dos ofuscadores a código fuente limpio en 10 y 20 minutos.
El 27 de septiembre de 2026 le di el bundle minificado de la demo de arriba (sin el map) a Claude Sonnet 5 a través de Claude Code 2.1.283, sin herramientas y con esta instrucción, seguida del bundle en un bloque de código js:
| This is a minified JavaScript file I downloaded from a website. Explain what it does in plain English: the pricing rules, the feature flags, the API endpoints it calls, and anything a competitor could copy or abuse. Be concise. |
Recuperó la fórmula de precios completa, hasta el precio que paga un plan anual de 10 asientos con LAUNCH40: 57,60 $. También encontró lo que yo había plantado sin señalarlo:
window.__debug.grantTrial/fetchInvoicesexponen las funciones de extensión de pruebas de administración y de obtención de facturas directamente en el ámbito global del JS de producción. Cualquiera puede abrir DevTools y llamar a__debug.grantTrial('<any user id>')desde la consola del navegador.
La respuesta completa, sin editar
| 1 | ## Pricing logic (`i()`) |
| 2 | - Base rate: **$12/seat/month** (`e*1200` in cents). |
| 3 | - **25+ seats** → extra 10% off (stacks). |
| 4 | - Annual billing (`t` truthy) → 20% off. |
| 5 | - Promo codes (case-insensitive): `LAUNCH40` = 40% off, `PARTNER_FRIENDS` = 50% off. Discounts stack multiplicatively (seat discount × annual × promo). |
| 6 | - Example in the code: 10 seats, annual, `launch40` → $120 → $96 (annual) → $57.60 (promo), shown as `57.6` on `#price`. |
| 7 | |
| 8 | ## Feature gating |
| 9 | - Export limit: free plan capped at **<3 exports/month**; every paid plan is unlimited (`a()`). |
| 10 | - Flags: `newCheckout: true`, `aiSummaries: false`, `teamSeatsBeta: true` (`l`). |
| 11 | - `aiSummaries` is force-enabled for `pro`/`team` plans regardless of the global flag — so it's effectively live for paying users even though the flag reads "off" (likely a soft-launch/A-B mechanism). |
| 12 | |
| 13 | ## API endpoints |
| 14 | - `GET /v2/billing/invoices` — bearer-token authenticated, returns invoice list. |
| 15 | - `POST /internal/admin/trials/{userId}/extend` — an **internal/admin** endpoint shipped in a public client bundle, with no auth header attached in this call at all. |
| 16 | |
| 17 | ## What a competitor/attacker could copy or abuse |
| 18 | - **Hardcoded discount codes** (`LAUNCH40`, `PARTNER_FRIENDS`) are fully visible client-side, including their exact percentages — trivial to discover and use even if not publicly advertised. |
| 19 | - **Exact free-tier limit** (3 exports/month) and pricing formula are exposed, handy for competitive benchmarking/undercutting. |
| 20 | - **`window.__debug.grantTrial` / `fetchInvoices`** expose the admin trial-extension and invoice-fetch functions directly in the global scope of production JS. Anyone can open devtools and call `__debug.grantTrial('<any user id>')` from the browser console. If the server doesn't independently re-verify admin privileges (and the client sends no auth token here), this is a **self-service trial/privilege-extension exploit** — the single biggest risk in this file. |
| 21 | - Naming leaks internal API structure (`/internal/admin/...`), which helps an attacker guess/probe other admin routes. |
| 22 | - `teamSeatsBeta` flag reveals an unreleased feature (competitive roadmap intel). |
| 23 | |
| 24 | **Bottom line:** the real vulnerability isn't the pricing math, it's that debug/admin hooks (`grantTrial`) were left reachable from `window` in production — that should be stripped from the shipped bundle and the endpoint should require server-side admin auth regardless. |
La contrasté con el código fuente: cada dato es correcto. La única expresión imprecisa es <3 exports/month, que más adelante formula bien como 3. Hizo falta una sola instrucción, y cualquiera con curiosidad por tu producto puede enviarla.
Cómo escanear tu sitio en busca de código fuente y source maps expuestos
Pega la URL de tu sitio en producción en el escáner de seguridad gratuito de AfterPack; no necesita cuenta. Renderiza la página en un navegador real, recoge cada script que carga e informa de secretos expuestos, source maps públicos y el nivel de protección de cada bundle. El mismo escáner funciona desde la terminal:
| npx afterpack@latest audit https://example.com |
Auditar un sitio en vivo detalla qué revisa y los códigos de salida para CI.
Cómo proteger el código fuente JavaScript
Quita primero lo que la ofuscación no puede proteger y luego ofusca el resto: un source map público o una clave en el bundle entregan el código, esté como esté escrito el bundle.
| Paso | Contra qué protege | Cómo |
|---|---|---|
| 1. No servir source maps | Que se publiquen tus archivos originales, tipos y comentarios | Maps ocultos, subidos en privado; archivos .map borrados del despliegue |
| 2. Llevar los secretos y la autoridad al servidor | Que se lean las claves y se salten los límites y las rutas de administración | Comprobaciones en el servidor; la del cliente es solo interfaz |
| 3. Ofuscar la lógica que tiene que enviarse | Que se busquen los strings y se lea o parchee la lógica | npx afterpack@latest dist/ --preset=hard, o un plugin de framework |
Cómo desactivar los source maps en producción
No los sirvas. Si los necesitas para el seguimiento de errores, genéralos sin el comentario sourceMappingURL:
| Bundler o framework | Ajuste | Qué obtienes |
|---|---|---|
| esbuild | --sourcemap=external | Un archivo .map, sin comentario en el bundle |
| Vite | build.sourcemap: "hidden" | Un archivo .map, sin comentario en el bundle |
| webpack | devtool: "hidden-source-map" | Un archivo .map, sin comentario en el bundle |
| Rollup | output.sourcemap: "hidden" | Un archivo .map, sin comentario en el bundle |
| Next.js | productionBrowserSourceMaps | Desactivado por defecto; comprueba que nadie lo activó |
| Create React App | GENERATE_SOURCEMAP=false | Ningún map (vienen activados por defecto) |
Sube los maps en privado a tu herramienta de seguimiento de errores y después borra los archivos .map del directorio de despliegue. Un map oculto que se queda en el servidor sigue a una URL adivinada de distancia. Depurar trazas de producción sin publicar el map incluye una regla en el edge para cuando se cuela uno.
Mantén los secretos y las comprobaciones en el servidor
Una clave de API en el bundle la puede leer quien ejecute la página, hagas lo que hagas con el archivo. Lo mismo vale para las decisiones: un límite del plan gratuito que solo se comprueba en el navegador es una sugerencia, los códigos promocionales van en una consulta del lado del servidor, y la ruta de administración de mi demo es tan segura como la comprobación que haga el propio servidor. Deja la comprobación en el cliente para la interfaz. Aplícala en el servidor.
Ofusca el JavaScript que tiene que enviarse
Hay código que solo funciona en el navegador, como una calculadora de precios en vivo o un editor en el navegador. Para ese código, ejecuta AfterPack sobre la salida del build:
| npx afterpack@latest dist/ --preset=hard |
Sin --preset obtienes light, que codifica los literales de string. Eso frena una búsqueda de texto de tus rutas y flags; no hace que la lógica de precios sea difícil de seguir. Para el código que un competidor querría, como los precios y el control de acceso por plan, usa hard, o pega antes una función en el playground para ver qué le hace cada nivel. Presets detalla lo que añade cada nivel. Mi demo sigue calculando los mismos 57,60 $ después de hard (AfterPack 0.2.0, 29 de septiembre de 2026), y hay plugins de framework si prefieres que lo haga tu build habitual.
La forma importa tanto como el preset. Un bundle fijo es un blanco: un modelo o un script le hace ingeniería inversa una vez, y el resultado vale hasta tu próxima release. AfterPack usa una semilla nueva en cada build, así que cada release es un programa estructuralmente distinto, y un script de desofuscación o un mapa anotado hecho contra el bundle de la semana pasada no sirve para el de esta semana. Con @afterpack/wasm dentro de tu propio Cloudflare Worker, el código puede cambiar de forma en cada petición; el tutorial de Workers tiene la configuración completa, y el post del lanzamiento muestra una función antes y después.
Empieza con una búsqueda: abre DevTools en tu sitio en producción y busca internal. Lo que aparezca ya lo tiene cada visitante.
Preguntas frecuentes
¿Se puede ocultar por completo el JavaScript a los usuarios?
No. El navegador tiene que descargar el código para ejecutarlo, así que cualquiera puede leer lo que recibe. Puedes mantener los secretos y la autoridad en el servidor, dejar de publicar source maps y hacer que el código que se envía sea caro de leer con ofuscación.
¿Cómo evito que copien o hagan ingeniería inversa de mi JavaScript?
No puedes impedir la descarga; sí puedes hacer que la copia sea cara de leer y quede obsoleta pronto. Ofusca con --preset=hard, envía una forma nueva en cada build (o en cada petición desde tu Worker) y mantén la autoridad en el servidor, para que un cliente copiado no pueda hacer más que el original. El anti-tamper de Pro hace que una copia modificada se rompa en lugar de funcionar.
¿La ofuscación oculta las claves de API?
No. Una clave en el bundle la puede leer quien ejecute la página, esté ofuscada o no. Mantén las claves en el servidor y llama a la API desde allí; el modelo de amenazas explica qué protege la ofuscación y qué no.
¿El JavaScript ofuscado funciona igual?
Debería, y cuando no puede, te enteras al compilar: un archivo que el motor no puede ofuscar hace fallar el build y lo nombra, así que un bundle roto nunca se publica sin que lo sepas. Ejecuta tus pruebas de integración contra el build ofuscado, no contra el normal: es el que se publica.
¿AfterPack es gratis?
El motor local es gratis con todos los presets, y el CLI y los plugins de framework son de código abierto. Pro añade directivas por región, anti-tamper y builds en la nube facturados por megabyte; los planes tienen las cifras.
— Nikita
