Gestión de Secretos para Node.js y Next.js: Protege tus Claves de API en 2026
Introducción
La mayoría de las brechas en startups no empiezan con un zero-day. Empiezan con un secreto que se filtró: una clave de AWS commiteada en un repositorio público, un archivo .env subido con prisas, una clave de Stripe pegada en un bundle de frontend. Una vez que un atacante tiene una credencial válida, todos los demás controles de tu stack — reglas de WAF, limitación de velocidad (rate limiting), validación de entrada — se vuelven irrelevantes. Ya están dentro.
El ecosistema de Node.js agrava el problema. La cultura de dotenv normalizó cargar la configuración desde un único archivo .env en la raíz del proyecto, y ese archivo acaba en git más a menudo de lo que los equipos admiten. Una búsqueda rápida en GitHub de AWS_SECRET_ACCESS_KEY devuelve miles de repositorios activos. Los investigadores de seguridad en la nube han demostrado repetidamente que las credenciales expuestas se convierten en armas en cuestión de minutos tras ser subidas — los atacantes ejecutan escáneres automatizados sobre código público y el historial de commits de forma continua.
Esta guía te ofrece una estrategia completa de gestión de secretos para aplicaciones Node.js y Next.js: dónde se filtran los secretos, cómo mantenerlos fuera de tu código, cómo cargarlos y validarlos de forma segura, cómo usar un gestor de secretos en producción y qué hacer cuando uno se filtra igualmente. Cada sección termina con código que puedes desplegar hoy.
El Modelo de Amenazas: Seis Lugares Donde se Filtran los Secretos
Antes de arreglar nada, entiende dónde ocurren las filtraciones. En una startup típica de Node.js/Next.js, los secretos escapan por seis canales:
| Vector de fuga | Cómo ocurre | Riesgo |
|-------------|----------------|------|
| Historial de git | .env commiteado una vez y luego eliminado del árbol de trabajo — pero vive para siempre en el historial | Crítico |
| Paquetes npm | El bundler incluye archivos de configuración en el paquete publicado | Crítico |
| Bundles de cliente | Claves NEXT_PUBLIC_* o hardcodeadas compiladas en el bundle del navegador | Crítico |
| Logs y rastreadores de errores | Secretos registrados en cuerpos de peticiones, stack traces o salidas de depuración | Alto |
| Imágenes Docker | COPY . . incrusta .env en las capas de la imagen; las variables ENV son inspeccionables | Alto |
| Logs de CI/CD | Archivos de workflow con secretos en texto plano, echo $SECRET en logs de build | Alto |
El hilo común: los secretos se tratan como código en lugar de como credenciales. La solución es un conjunto de reglas que hagan la filtración estructuralmente difícil, no solo improbable.
Regla 1: Nunca Hardcodear. Nunca Committear. Nunca Usar Valores por Defecto.
La primera regla es también la más barata: un secreto no puede existir en el código fuente. Ni en una constante, ni en un objeto de configuración, ni como valor por defecto en un parámetro de función.
// [X] PELIGROSO — credencial hardcodeada
const stripe = new Stripe("sk_live_51Hx...");
// [X] PELIGROSO — el fallback oculta el problema y envía claves de producción a desarrollo
const dbUrl = process.env.DATABASE_URL ?? "postgres://admin:***@localhost:5432/prod";
// [X] PELIGROSO — valores por defecto "temporales" que acaban en producción
const apiKey = process.env.OPENAI_API_KEY || "sk-test-1234";
Los valores por defecto son los más traicioneros de los tres. Hacen que la aplicación "funcione" en tu portátil, así que la variable de entorno ausente nunca sale a la superficie — hasta que la misma ruta de código se ejecuta en producción con un valor por defecto que resulta ser real, o con una credencial que nunca se rota porque nadie sabe dónde está definida.
La regla: cada secreto se lee de process.env, y cada secreto ausente provoca un fallo rápido (fail fast) al arrancar. Sin valores por defecto silenciosos, sin cadenas vacías que se convierten en fallos de autenticación horas después.
// src/config/env.ts
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
DATABASE_URL: z.string().min(1),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
OPENAI_API_KEY: z.string().min(1),
JWT_SECRET: z.string().min(32), // nunca secretos cortos
});
// Fail fast: el proceso se niega a arrancar con un secreto ausente/inválido
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid environment configuration:");
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
Con esto en marcha, un desarrollador que clona el repositorio y olvida crear .env.local recibe un error inmediato y explícito — no un misterioso error 500 tres horas después. Los equipos que omiten la validación de esquemas son los que descubren secretos ausentes a las 2 AM durante un incidente.
Y la regla de .gitignore es innegociable:
# .gitignore
.env
.env.*
!.env.example
El archivo de excepción, .env.example, se commitea deliberadamente: documenta cada variable que la aplicación necesita, con valores de ejemplo y un comentario que describe cada una. Es el contrato entre tu aplicación y tu pipeline de despliegue.
Regla 2: Mantén los Secretos Fuera del Historial de Git
Un secreto eliminado del árbol de trabajo pero presente en git log es un secreto público. Los atacantes no leen tu último commit — clonan el repositorio y recorren todo el historial, o consultan la API de búsqueda de código de GitHub buscando patrones de secretos conocidos.
Prevención: gitleaks en pre-commit y en CI
Gitleaks es el escáner estándar. Instálalo como hook de pre-commit y — más importante — como job de CI que se ejecuta en cada push y pull request:
# .github/workflows/secret-scan.yml
name: Secret Scan
on: [push, pull_request]
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Scan full history for secrets
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
El escaneo en CI con fetch-depth: 0 importa: comprueba todo el historial, no solo el diff, así que un secreto añadido en un commit anterior sigue detectándose en el momento del PR.
Activa el secret scanning de GitHub + push protection
En GitHub, activa Secret Scanning y Push Protection en todos los repositorios (gratis para repositorios públicos, disponible en repositorios privados para clientes de GitHub Advanced Security). Push Protection bloquea los commits que contienen patrones de secretos conocidos antes de que lleguen, y Secret Scanning te alerta cuando se detecta un secreto — incluso en forks y commits pasados. Si usas Forgejo, Gitea o GitLab, existen equivalentes: GitLab tiene su propia detección de secretos, y Forgejo soporta escaneo de secretos vía CI. Sea cual sea tu plataforma, el escaneo debe ejecutarse a nivel de plataforma, no solo en tu cabeza.
Remediación: depura el historial con git-filter-repo
Si un secreto ya está en el historial, borrar el commit no basta — debes reescribir el historial y rotar la credencial. Primero rotación, después limpieza, en ese orden:
# 1. ROTA el secreto AHORA — asume que está comprometido (es público)
# Regenera la clave, actualiza el gestor de secretos, redespliega.
# 2. Elimina el archivo de TODO el historial
git filter-repo --invert-paths --path .env
# 3. Fuerza el push del historial limpio
git remote add origin <new-origin> # filter-repo elimina los remotes por diseño
git push origin --force --all
git push origin --force --tags
# 4. Dile a cada colaborador que vuelva a clonar — los clones antiguos aún contienen el secreto
git-filter-repo se prefiere sobre filter-branch — es más rápido, más seguro y elimina el historial antiguo correctamente. Pero recuerda: cualquier clon, fork o caché de CI creado antes de la limpieza sigue conteniendo el secreto. La rotación es la única solución real. Limpiar el historial es higiene; rotar es seguridad.
Regla 3: Usa un Gestor de Secretos en Tiempo de Ejecución
Leer secretos de process.env es correcto para la configuración, pero el propio entorno se convierte en el problema de almacenamiento. En un solo VPS, un archivo .env en disco está a un cat de distancia de cualquiera con acceso al shell — y a una copia de seguridad o shipper de logs mal configurado de filtrarse.
Para cualquier equipo que haya superado "un servidor, un proyecto de hobby", mueve los propios secretos a un almacén dedicado:
| Herramienta | Ideal para | Notas | |------|----------|-------| | HashiCorp Vault | Self-hosted, multi-nube, secretos dinámicos | El más flexible; tú gestionas la infraestructura | | AWS Secrets Manager | Equipos que ya están en AWS | Rotación automática para RDS, IAM | | Google Secret Manager | Equipos en GCP | Versionado + IAM integrados | | Doppler | Startups que quieren zero-ops | Sincroniza con CI y entornos | | 1Password/Infisical | Equipos pequeños, amigable para humanos | Buena UX para desarrolladores |
El patrón de tiempo de ejecución es el mismo en todas partes: obtener una vez al arrancar, cachear en memoria, nunca escribir en disco. Este es el patrón con AWS Secrets Manager:
// src/lib/secrets.ts
import {
SecretsManagerClient,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
const client = new SecretsManagerClient({ region: process.env.AWS_REGION });
// Caché en memoria — nunca persistir en disco, nunca registrar
const cache = new Map<string, string>();
export async function getSecret(name: string): Promise<string> {
if (cache.has(name)) return cache.get(name)!;
const { SecretString } = await client.send(
new GetSecretValueCommand({ SecretId: name })
);
if (!SecretString) throw new Error(`Secret ${name} is empty`);
cache.set(name, SecretString);
return SecretString;
}
// Uso — los secretos se obtienen, nunca se incrustan en el código fuente
import { getSecret } from "@/lib/secrets";
export async function createStripeClient() {
return new Stripe(await getSecret("stripe/live/secret_key"));
}
La caché es deliberada: evita un viaje de red por petición y reduce tu radio de explosión en la cuota de API, manteniendo el secreto fuera de la memoria del proceso solo durante el tiempo necesario.
¿Necesitas un vault hoy? Respuesta honesta: si tienes menos de ~10 secretos y un único destino de despliegue, process.env validado + un contrato estricto de .env.example es aceptable — siempre que la plataforma inyecte las variables (Vercel, Railway, Render, un archivo de entorno de systemd) en lugar de un archivo commiteado. En cuanto tengas múltiples entornos (dev/staging/prod), múltiples servicios o cualquier requisito de cumplimiento (SOC 2, ISO 27001), muévete a un gestor. El coste de la migración es un día; el coste de una fuga es tu empresa.
Regla 4: Higiene de CI/CD
Los pipelines de CI son donde mueren los secretos: workflows commiteados con credenciales en texto plano, secretos volcados a logs, o actions de terceros husmeando variables de entorno. Tres reglas:
1. Usa el almacén de secretos de la plataforma, nunca los archivos de workflow. En GitHub Actions, eso es ${{ secrets.* }}; en GitLab, variables de CI protegidas; en Forgejo, secretos de repositorio. El valor vive en el almacén cifrado de la plataforma y se enmascara en los logs automáticamente.
# [X] PELIGROSO — secreto en el archivo de workflow
env:
STRIPE_KEY: "sk_live_51Hx..."
# [X] PELIGROSO — depurar secretos en logs
- run: echo "Deploying with key ${{ secrets.STRIPE_KEY }}"
# [OK] Referencia al almacén; nunca imprimas
- name: Deploy
env:
STRIPE_KEY: ${{ secrets.STRIPE_KEY }}
run: ./deploy.sh
2. Examina las actions de terceros. Una action comprometida puede exfiltrar cada secreto del job. Fija las actions a SHAs completos de commits, revisa el código fuente de todo lo que añadas y mantén la lista de actions corta.
3. Prefiere OIDC sobre credenciales de larga duración. El mayor secreto de CI de todos es a menudo una clave de nube de larga duración con permisos IAM amplios, sentada en el almacén de secretos durante años. Los proveedores de nube soportan federación OIDC: la plataforma de CI intercambia un token de corta duración y limitado a la carga de trabajo por credenciales de nube, sin almacenar ningún secreto estático.
# GitHub Actions → AWS vía OIDC: sin claves de AWS en el pipeline
permissions:
id-token: write # requerido para OIDC
contents: read
steps:
- uses: actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: eu-west-1
El rol de IAM confía en el emisor OIDC de GitHub y delimita los permisos por repositorio y por entorno. Sin AWS_ACCESS_KEY_ID en el almacén de secretos, nada que rotar, nada que filtrar.
Regla 5: Imágenes Docker — No Incrustes Secretos
Docker es una fuga de secretos silenciosa. COPY . . en una imagen copia tu .env si existe en el contexto de build. ENV STRIPE_KEY=... almacena el valor en la configuración de la imagen, visible para cualquiera con docker inspect. Y los secretos definidos en tiempo de build acaban en las capas de la imagen de forma permanente, incluso si se eliminan después.
# [X] PELIGROSO — .env incrustado en la capa
COPY . .
# [X] PELIGROSO — secreto visible vía docker inspect
ENV STRIPE_KEY=sk_live_51Hx...
# [OK] Secretos de BuildKit — disponibles en tiempo de build, nunca almacenados en la capa
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=stripe_key \
export STRIPE_KEY=$(cat /run/secrets/stripe_key) && \
./build.sh
# Build con el secreto inyectado desde el entorno de la máquina local
docker build --secret id=stripe_key,env=STRIPE_KEY -t myapp .
Dos reglas complementarias: añade .env al .dockerignore para que nunca entre en el contexto de build, y usa builds multi-etapa para que la imagen de runtime contenga solo el output compilado — sin código fuente, sin toolchain, sin archivos de configuración sueltos. Los secretos en tiempo de ejecución los inyecta el orquestador (Docker --env-file, Kubernetes secret, definición de tarea ECS), nunca la imagen.
Regla 6: Lado del Cliente — NEXT_PUBLIC_ Es Público
En Next.js (y Vite, y cualquier bundler), cualquier cosa con prefijo NEXT_PUBLIC_ se inlinea en el bundle del cliente en tiempo de build. Lo descarga cada visitante, es visible en la pestaña Network y lo indexan los scrapers. No es un secreto.
// [X] PELIGROSO — esto viaja al navegador
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! // está en el bundle — público por diseño
);
// [X] PELIGROSO — una clave real en un componente de cliente
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!);
NEXT_PUBLIC_SUPABASE_ANON_KEY está diseñada para ser pública (está limitada por Row Level Security). El peligro aparece cuando los equipos aplican el patrón a credenciales reales. La mitigación es estructural:
- Convención de prefijo: solo los valores seguros de publicar y limitados por capacidad reciben
NEXT_PUBLIC_. Hazlo cumplir con una regla de lint o un elemento de checklist de revisión de código. - Solo servidor por defecto: en Next.js, los secretos de solo servidor se leen en server components, route handlers y server actions — nunca en componentes de cliente. Marca los módulos con el paquete
server-onlypara que el bundler falle si un componente de cliente los importa. - Prueba el bundle:
grep -r "sk_live\|AKIA" .next/static/después de un build. Cero coincidencias, o tienes una fuga viajando a producción.
Rotación y Respuesta a Incidentes
Asume que cada secreto tiene una vida media. La rotación no es una tarea doméstica; es el control que hace que cualquier otra fuga sea sobrevivible. La disciplina:
- Credenciales de corta duración por defecto. Las credenciales temporales (STS de IAM, OIDC, secretos dinámicos de Vault) expiran en minutos u horas. Un token de corta duración robado es un no-evento; una clave estática robada es una brecha.
- Rotación automatizada para cualquier cosa de larga duración. AWS Secrets Manager y Vault soportan rotación programada para servicios compatibles (contraseñas de RDS, etc.). Si un secreto tiene más de 90 días y la rotación no está automatizada, programa el trabajo.
- El playbook de incidentes. Cuando un secreto se filtra, en este orden: (1) rótalo inmediatamente — trátalo como comprometido, porque lo está; (2) revoca y reemite, no hagas parches alrededor; (3) audita los logs de acceso del recurso afectado; (4) comprueba el movimiento lateral (¿se reutilizó la clave? ¿leyó el atacante tu almacén de secretos?); (5) actualiza las herramientas de escaneo para que la misma fuga se detecte antes la próxima vez. Comunica internamente con la misma franqueza: la fuga ocurrió porque falló el proceso, no la persona.
Checklist de Gestión de Secretos
- [ ] Cero secretos hardcodeados en el código fuente (
grep -rn "sk_\|AKIA\|password" src/no devuelve nada accionable) - [ ] Cero valores por defecto para secretos —
process.envvalidado por esquema, fail-fast al arrancar - [ ]
.envy.env.*en.gitignore;.env.examplecommiteado y actualizado - [ ] gitleaks en pre-commit y en CI, escaneando todo el historial en cada push
- [ ] Secret scanning de la plataforma + push protection activados
- [ ] Secretos almacenados en un gestor (Vault, AWS SM, Doppler) para multi-entorno/producción
- [ ] El CI usa el almacén de secretos de la plataforma; los workflows no contienen secretos en texto plano
- [ ] Federación OIDC sustituye a las claves de nube de larga duración en CI
- [ ] Docker:
.enven.dockerignore, BuildKit--mount=type=secret, builds multi-etapa - [ ] Sin
NEXT_PUBLIC_ni credenciales reales visibles en el bundle; imports de solo servidor forzados - [ ] Rotación automatizada para secretos de larga duración; todas las claves estáticas con menos de 90 días
- [ ] Playbook de incidentes escrito y probado (rotar → revocar → auditar → escanear)
Resumen
-
Los secretos son credenciales, no código. No pertenecen al código fuente, ni a valores por defecto, ni a bundles, ni a imágenes, ni a logs — solo a la inyección de entorno y a almacenes dedicados.
-
Fail fast, siempre. Un
process.envvalidado y comprobado por esquema convierte un secreto ausente en un error de arranque claro en lugar de un incidente de producción. -
El historial es para siempre. Asume que cualquier cosa en el historial de git es pública. Escanea en cada push y, cuando algo se filtre, rota primero y depura después.
-
Reduce el radio de explosión. Credenciales de corta duración, OIDC en CI, gestores de secretos en tiempo de ejecución y
NEXT_PUBLIC_solo para valores seguros de publicar — cada control hace que la próxima fuga sea más barata. -
La rotación es el control que te salva. Un secreto que rota en horas convierte una fuga en una molestia. Un secreto que nunca rota convierte una fuga en una brecha.
La gestión de secretos se sitúa en la intersección entre el flujo de trabajo del desarrollador y la ingeniería de seguridad — que es exactamente por qué es la corrección de mayor apalancamiento que la mayoría de las startups aún no han hecho. Para una mirada más profunda al lado de la cadena de suministro de este problema — lo que npm audit no detecta y cómo auditar tu árbol de dependencias correctamente — consulta nuestra guía npm audit no es suficiente, y el OWASP Top 10 para Backends Node.js para ver dónde aparecen los secretos mal configurados en la taxonomía de vulnerabilidades.
La próxima semana: Condiciones de carrera y fallos de lógica de negocio — por qué tu autenticación solo es tan fuerte como tus transiciones de estado, con patrones de explotación y soluciones para doble gasto, TOCTOU y workflows rotos.
JS Security Audit
Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.