Secretos en despliegue: sin filtraciones al cliente
El riesgo central de las variables de entorno en producción: que un secreto acabe en el navegador. Por qué las props de una isla hidratada se serializan y son una vía de fuga, la diferencia entre inyectar el secreto en el build y en el runtime, y dónde vive cada credencial en los adapters de Node, Vercel, Netlify y Cloudflare, con sus almacenes cifrados y su alcance por entorno.
Todo lo aprendido sobre prefijos, esquemas y módulos converge en una sola pregunta operativa: cuando el sitio está desplegado y atendiendo tráfico real, ¿cómo garantizas que la clave de tu base de datos jamás aparece en el código que descarga un visitante? La respuesta tiene dos capas. La primera es no filtrar: entender por qué caminos un secreto puede escaparse al cliente —algunos evidentes, otros traicioneros como las props de una isla— y cerrarlos todos. La segunda es saber dónde vive cada secreto en la plataforma real, porque una credencial no se escribe en tu código: se inyecta desde fuera, y cada adapter la recoge de un sitio distinto. Sin esas dos capas, el mejor esquema del mundo no te salva.
- Identificar las vías por las que un secreto puede filtrarse al navegador y cerrarlas.
- Entender por qué las props de una isla hidratada se serializan y nunca deben llevar secretos.
- Distinguir la inyección de variables en el build de la inyección en el runtime.
- Localizar dónde viven los secretos en los adapters de Node, Vercel, Netlify y Cloudflare.
El único pecado capital: filtrar un secreto al cliente
En despliegue solo hay un error verdaderamente caro con las variables de entorno, y es que un secreto termine en el bundle que viaja al navegador. Todo lo demás se arregla; una credencial publicada hay que rotarla y asumir que alguien la vio. Las vías de fuga evidentes ya las cierran los mecanismos que estudiaste: una variable sin PUBLIC_ no se incrusta, un campo access: 'secret' no se exporta desde astro:env/client. Pero hay una vía sutil que ninguno de esos muros detiene, porque no pasa por ellos: las props de una isla.
---
import Panel from '../components/Panel.jsx';
import { API_SECRET } from 'astro:env/server';
---
<!-- FUGA: el secreto se serializa en el HTML para hidratar la isla -->
<Panel client:load token={API_SECRET} />
Cuando marcas un componente de framework con client:load o cualquier directiva de hidratación, Astro tiene que enviar sus props al navegador para que el componente pueda revivir allí. Esas props se serializan dentro del HTML. Da igual que el valor viniera de astro:env/server y fuese un secreto legítimo del servidor: en el momento en que lo pasas como prop a una isla, lo estás copiando al cliente con tus propias manos. El secreto salió limpio del módulo correcto y se filtró en la línea siguiente.
Todo lo que cruza como prop hacia un componente con directiva client: acaba visible en el HTML. Por eso las props de una isla deben ser datos ya cocinados y aptos para ver: el nombre del usuario, no su token; el resultado de una consulta, no la credencial que la autorizó. Si una isla necesita hablar con un servicio protegido, no le entregues la llave: haz que llame a un endpoint tuyo, y que sea ese endpoint —código de servidor— quien lea el secreto y hable con el tercero. La frontera se mantiene si el secreto nunca es un dato que el componente de cliente reciba.
Build-time frente a runtime: dos entornos, un mismo secreto
Hay una distinción que decide en qué máquina debe existir cada variable. Las públicas de cliente se incrustan durante el build: la máquina que compila necesita tenerlas, porque su valor queda horneado en los artefactos. Los secretos leídos en ejecución viven en el otro extremo: la máquina que sirve las peticiones es la que debe conocerlos, y a menudo la que compila ni siquiera debería verlos.
# El build incrusta las publicas: el CI necesita PUBLIC_*
PUBLIC_API_URL=https://api.produccion.com astro build
# El servidor en produccion inyecta los secretos en su propio entorno
API_SECRET=... node ./dist/server/entry.mjs
Confundir los dos entornos causa fallos desconcertantes. Un secreto presente en tu máquina pero ausente en el servidor de despliegue produce un sitio que compila sin quejarse y falla en la primera petición real. Una variable pública que olvidaste en el entorno del CI genera un build con un valor vacío incrustado que ninguna configuración del runtime podrá ya corregir, porque su hueco se cerró al compilar. La pregunta ¿esta variable la necesita quien compila o quien sirve? debe tener respuesta antes de desplegar.
La consecuencia sana de esta separación es que tu build debería ser lo más agnóstico posible del entorno. Compila una vez un artefacto que no lleve secretos dentro y despliégalo en pruebas, en preproducción y en producción cambiando solo las variables que cada entorno inyecta. Si en cambio horneas secretos o URLs específicas de un entorno en el build, te condenas a recompilar para cada destino y a multiplicar artefactos que deberían ser uno. Los secretos son configuración del entorno, no contenido del artefacto.
Esta separación tiene una consecuencia práctica en tu integración continua. La máquina que compila necesita las variables públicas para hornearlas, pero no debería tener acceso a los secretos de producción si su único trabajo es construir el artefacto estático. Dar a un paso de build más credenciales de las que su tarea exige es ampliar la superficie de ataque sin ganar nada: cada sitio que toca un secreto es un sitio más que hay que proteger y auditar.
En un pipeline maduro, el paso que compila y el que despliega no comparten los mismos secretos. El build recibe lo justo para producir el artefacto; el despliegue, que corre en un contexto más protegido, es quien inyecta las credenciales de runtime en el entorno de ejecución. Separar esos dos roles evita que un fallo en la parte más expuesta del pipeline —la que procesa código y dependencias de terceros— dé acceso directo a las llaves de producción.
Dónde viven los secretos en cada plataforma
Un secreto nunca se escribe en tu repositorio: se guarda en el almacén que ofrece la plataforma de despliegue, y el adapter se encarga de que getSecret y las importaciones de servidor lo encuentren allí. El sitio concreto cambia según dónde despliegues.
Node autohospedado
Viven en el entorno del proceso, process.env, que fijas con Docker, systemd, PM2 o un gestor de secretos. Los .env no se cargan solos en produccion.
Vercel
En Environment Variables del panel, cifradas y con alcance por entorno: produccion, preview y desarrollo separados. Se inyectan en build y runtime.
Netlify
En las variables de entorno del sitio, por interfaz o por contexto de despliegue. Evita ponerlas en netlify.toml, que si va al repositorio.
Cloudflare
Los secretos se suben con wrangler secret put o desde el panel, cifrados, y llegan por el objeto de entorno que el edge inyecta en cada peticion.
Estas diferencias explican, retroactivamente, por qué getSecret recibe su valor a través del adapter en lugar de leer siempre process.env. En Node el secreto está en el proceso; en Cloudflare no hay proceso persistente, sino un objeto de entorno que la plataforma pasa a cada invocación del Worker. Escribes el mismo getSecret('API_SECRET') en ambos casos, y es el adapter quien traduce esa intención al mecanismo concreto de la plataforma. Tu código de lectura permanece portable justamente porque el conocimiento sobre dónde viven los secretos está encapsulado en el adapter, no esparcido por tus endpoints.
flowchart LR STORE[almacen de secretos de la plataforma] --> ADAPT[adapter inyecta en runtime] ADAPT --> SRV[codigo de servidor lee el secreto] SRV --> RES[respuesta con datos ya cocinados] RES --> CLI[navegador recibe solo el resultado] SRV -.fuga si pasa como prop de isla.-> CLI style STORE fill:#f38ba8,color:#11111b style SRV fill:#89b4fa,color:#11111b style CLI fill:#a6e3a1,color:#11111b
Esa misma indirección del adapter opera también en tu máquina, no solo en el servidor de despliegue. En desarrollo, los secretos viven en archivos .env que nunca se versionan, y cada adapter tiene su forma de leerlos en local para que astro dev se parezca al despliegue. El servidor Node lee el .env durante el desarrollo; el adapter de Cloudflare, que en producción recibe los secretos por el objeto de entorno del Worker, los toma en local de un archivo aparte que su herramienta reconoce. Conocer el mecanismo de tu adapter evita que lo que funciona en tu portátil se rompa al desplegar por leer de un sitio distinto.
El error clásico es probar en local con un secreto en .env, ver que todo va, y descubrir en producción que la plataforma lo esperaba en otro almacén. La cura es alinear tu desarrollo con el runtime real: usa el mecanismo que tu adapter ofrece para inyectar secretos en local del mismo modo que lo hará la plataforma. Cuanto más se parezca tu entorno de desarrollo al de despliegue en la forma de entregar la configuración, menos sorpresas te llevarás al pasar de uno a otro.
Vías de fuga menos obvias: registros y props
Hay una vía de fuga que no pasa por el navegador y que los muros anteriores no cubren: los registros. Un secreto que imprimes por consola para depurar, o que acaba dentro del objeto de un error que tu telemetría captura, se copia a un almacén de logs que suele tener menos protección y más lectores que tu base de datos. Un secreto en un log es un secreto filtrado a un lugar que casi nadie vigila.
// FUGA silenciosa: el secreto viaja al almacen de logs
console.log('conectando con', API_SECRET);
// Mejor: registra que ocurrio, nunca el valor sensible
console.log('conectando con el servicio de pagos');
La regla es fácil de enunciar y fácil de olvidar bajo presión: los valores sensibles no se imprimen, no se serializan en mensajes de error y no se incluyen en los datos que mandas a tu telemetría. Cuando depures un fallo de autenticación, registra el hecho —la firma no coincidió— y no el material —la clave era tal—. El primer dato ayuda a diagnosticar; el segundo solo añade un sitio más del que tendrás que rotar el secreto en cuanto alguien lo lea.
Vigila los mensajes de error que tu propio código construye a partir de una respuesta fallida de un tercero: a veces incluyen, sin querer, la cabecera de autorización o el cuerpo de la petición con la clave dentro. Antes de mandar un error a tu sistema de observabilidad, asegúrate de que no arrastra material sensible. Depurar con secretos a la vista es cómodo un minuto y peligroso para siempre, porque los logs se guardan, se copian y se comparten mucho más de lo que su contenido a veces merece.
La otra vía menos obvia ya asomó al principio —las props de una isla—, y su corrección merece un patrón propio, porque es la solución canónica a una parte del cliente necesita algo que solo el servidor puede tener. En vez de entregar el secreto a la isla, interpones un endpoint tuyo: la isla le pide el dato, el endpoint —código de servidor— lee el secreto, habla con el tercero y devuelve solo el resultado. El secreto nunca sale del servidor; lo que viaja al cliente es una respuesta ya cocinada.
// src/pages/api/precio.ts -> el endpoint guarda el secreto
import { getSecret } from 'astro:env/server';
export const GET = async () => {
const clave = getSecret('PRICING_KEY');
const datos = await fetch('https://tercero.example/precio', {
headers: { authorization: `Bearer ${clave}` },
}).then((r) => r.json());
return Response.json({ precio: datos.precio }); // solo el resultado, no la clave
};
La isla, en el navegador, hace un fetch a /api/precio y recibe el precio sin enterarse jamás de la credencial que lo autorizó. Este rodeo —cliente pide, servidor guarda y traduce— es el mismo que sostiene cualquier arquitectura sana: el material sensible vive de un solo lado de la frontera y solo la cruza el resultado, nunca la llave. Interiorizarlo te ahorra la tentación recurrente de pasarle el token a la isla para que sea más simple, que es simple justo hasta el día en que alguien abre las herramientas del navegador.
Detrás de toda esta mecánica hay un principio que la industria tardó años en formular con claridad y que hoy es cimiento de cualquier despliegue serio: separar estrictamente la configuración del código. La idea suena obvia hasta que ves cuántos sistemas la violan. Un secreto escrito en el repositorio es configuración disfrazada de código: convierte algo que debería variar entre entornos —y cambiar cuando se compromete— en algo fijo, versionado y copiado en cada clon del proyecto. La cura es tratar la configuración como lo que es, un habitante del entorno, no del artefacto. El artefacto —tu build— debería ser inmutable y agnóstico, la misma bolsa de bytes que corre idéntica en pruebas y en producción; lo único que cambia entre un despliegue y otro son las variables que el entorno inyecta desde fuera. Esta disciplina compra tres cosas de golpe. Compra seguridad, porque los secretos viven cifrados en un almacén con control de acceso y no en un archivo que cualquiera con acceso al repositorio puede leer. Compra reproducibilidad, porque un artefacto que no depende de su entorno se comporta igual dondequiera que aterrice, y un fallo se puede reproducir moviendo el mismo artefacto a otro sitio. Y compra la posibilidad misma de rotar un secreto sin recompilar, porque si la credencial vive fuera del artefacto, cambiarla es actualizar el entorno, no reconstruir el mundo. Cuando entiendes que un adapter no es un detalle de despliegue sino la costura por la que el entorno inyecta su configuración en un artefacto que la ignora, dejas de ver las variables de entorno como un incordio de operaciones y empiezas a verlas como la línea que mantiene tu código honesto: puro comportamiento, sin un solo secreto pegado con cinta.
- Pasa deliberadamente un valor de
astro:env/servercomo prop a una islaclient:load, construye y busca el valor en el HTML generado para verlo filtrado. - Corrige el diseño moviendo el uso del secreto a un endpoint y haciendo que la isla lo llame en vez de recibir la clave.
- Distingue en tu proyecto qué variables necesita el entorno del build y cuáles el entorno del runtime, y anótalo.
- Elige tu plataforma objetivo y localiza exactamente dónde se guardan sus secretos y con qué alcance de entorno.