Spring Boot Actuator en producción: los endpoints que dejé abiertos sin darme cuenta y cómo los cerré
Estaba revisando la configuración de un backend Spring Boot 3.x que vengo construyendo — el mismo que discutí en el post de Jakarta EE vs Spring Boot — cuando hice algo que debería haber hecho desde el día uno: un curl simple contra /actuator.
La respuesta me cayó como un balde de agua fría.
{
"_links": {
"self": { "href": "http://localhost:8080/actuator" },
"beans": { "href": "http://localhost:8080/actuator/beans" },
"health": { "href": "http://localhost:8080/actuator/health" },
"info": { "href": "http://localhost:8080/actuator/info" },
"env": { "href": "http://localhost:8080/actuator/env" },
"loggers": { "href": "http://localhost:8080/actuator/loggers" },
"metrics": { "href": "http://localhost:8080/actuator/metrics" },
"mappings": { "href": "http://localhost:8080/actuator/mappings" },
"threaddump": { "href": "http://localhost:8080/actuator/threaddump" },
"heapdump": { "href": "http://localhost:8080/actuator/heapdump" }
}
}
Diez endpoints. Sin autenticación. /actuator/env exponiendo variables de entorno. /actuator/heapdump sirviendo un dump completo del heap de la JVM a cualquiera que lo pidiera.
Momento de "espera, esto está abierto" en estado puro.
Mi tesis, después de investigar y cerrar todo esto, es directa: los defaults de Spring Boot Actuator son razonables para desarrollo local, pero son una trampa en producción, y la documentación oficial los presenta con un tono que suaviza el riesgo real. Si no configurás Actuator con intención, estás apostando a que nadie lo encuentre.
Spring Boot Actuator endpoints seguridad producción: qué queda expuesto por defecto
Spring Boot 3.x expone por defecto via HTTP solo dos endpoints: health e info. Pero eso es solo la mitad de la historia.
El problema está en que:
-
/actuator(el índice) está habilitado y es público — enumera todo lo que existe. -
Si agregás
spring-boot-starter-actuatorsin configuración adicional, el índice revela los endpoints disponibles aunque no todos estén expuestos via HTTP. -
En entornos con
management.endpoints.web.exposure.include=*— que es exactamente lo que aparece en mil tutoriales de "cómo monitorear tu Spring Boot app" — abrís todo el tablero de un saque.
Corrí un script de enumeración simple contra el backend en un entorno de staging configurado como producción-réplica:
#!/bin/bash
# Script de auditoría de Actuator — reproducible en cualquier Spring Boot 3.x
BASE_URL="http://localhost:8080"
echo "=== Enumerando endpoints Actuator ==="
curl -s "$BASE_URL/actuator" | python3 -m json.tool
echo ""
echo "=== Probando /actuator/env (puede exponer secrets) ==="
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/actuator/env")
echo "HTTP Status: $STATUS"
echo ""
echo "=== Probando /actuator/heapdump (dump completo del heap) ==="
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/actuator/heapdump")
echo "HTTP Status: $STATUS — Si es 200, hay un problema serio."
Lo que encontré en el entorno con configuración descuidada (el famoso exposure.include=* copiado de un tutorial de Prometheus):
| Endpoint | HTTP Status | Riesgo |
|---|---|---|
/actuator/env |
200 | Crítico — expone variables de entorno, incluyendo keys parcialmente enmascaradas |
/actuator/beans |
200 | Alto — revela toda la estructura interna de beans Spring |
/actuator/heapdump |
200 | Crítico — dump del heap JVM descargable, puede contener secrets en memoria |
/actuator/mappings |
200 | Medio — mapeo completo de todos los endpoints HTTP de la app |
/actuator/threaddump |
200 | Medio — estado de todos los threads, útil para fingerprinting |
/actuator/loggers |
200 | Medio — permite cambiar niveles de log en runtime via POST |
/actuator/health |
200 | Bajo (con detalle deshabilitado) |
/actuator/info |
200 | Bajo (con info mínima) |
El /actuator/env es el que más me preocupó. Devolvía algo así:
{
"propertySources": [
{
"name": "systemEnvironment",
"properties": {
"DATABASE_URL": {
"value": "jdbc:postgresql://****:5432/mydb",
"origin": "System Environment Property"
},
"JWT_SECRET": {
"value": "******",
"origin": "System Environment Property"
}
}
}
]
}
Spring enmascara los valores con ****** para propiedades que detecta como sensibles — pero la detección es por nombre. Si la variable se llama MY_SIGNING_KEY en vez de JWT_SECRET, el valor aparece en texto plano. No es una garantía, es una heurística.
El proceso de cierre: application.properties + Spring Security
Después de mapear el surface de ataque, armé el hardening en dos capas. La primera capa es configuración pura; la segunda, Spring Security.
Capa 1 — application.properties
# ============================================================
# Actuator — Hardening para producción
# ============================================================
# Solo exponemos los endpoints que necesitamos operacionalmente
management.endpoints.web.exposure.include=health,info,metrics
# El índice /actuator enumera los endpoints disponibles — lo cerramos
management.endpoints.web.exposure.exclude=beans,env,heapdump,threaddump,loggers,mappings,sessions
# Health: detalle solo para requests autenticados
management.endpoint.health.show-details=when-authorized
management.endpoint.health.show-components=when-authorized
# Info: solo exponemos lo que configuramos explícitamente
management.info.env.enabled=false
management.info.java.enabled=false
management.info.os.enabled=false
# Movemos Actuator a un puerto interno (no expuesto en el load balancer)
# Opcional pero recomendado si tu infraestructura lo permite
management.server.port=8081
# Deshabilitamos endpoints que no usamos aunque no estén expuestos via HTTP
management.endpoint.heapdump.enabled=false
management.endpoint.threaddump.enabled=false
management.endpoint.env.enabled=false
management.endpoint.beans.enabled=false
La opción del puerto separado (management.server.port=8081) es la más limpia si la infraestructura lo permite. En Railway, por ejemplo, el puerto expuesto públicamente es el PORT env var — si Actuator corre en 8081 y solo exponés PORT al exterior, los endpoints de management quedan inaccesibles desde internet directamente.
Cubrí más sobre configuración JVM y Railway en Spring Boot en producción: lo que la documentación omite.
Capa 2 — Spring Security
La configuración de properties es necesaria pero no suficiente. Si Security está mal configurado, o si alguien toca esa config en el futuro sin contexto, todo puede reabrirse. La segunda capa es el cinturón de seguridad:
@Configuration
@EnableWebSecurity
public class ActuatorSecurityConfig {
@Bean
public SecurityFilterChain actuatorFilterChain(HttpSecurity http) throws Exception {
http
// Aplicamos esta config solo a rutas de Actuator
.securityMatcher("/actuator/**")
.authorizeHttpRequests(auth -> auth
// Health e info son públicos — para health checks del load balancer
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers("/actuator/info").permitAll()
// Métricas solo para usuarios con rol MONITORING
.requestMatchers("/actuator/metrics/**").hasRole("MONITORING")
// Cualquier otro endpoint Actuator requiere ADMIN
.anyRequest().hasRole("ADMIN")
)
// Actuator no necesita CSRF — es API interna
.csrf(csrf -> csrf
.ignoringRequestMatchers("/actuator/**")
)
// Sin sesiones para Actuator — stateless
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
Este enfoque usa el patrón de múltiples SecurityFilterChain que Spring Boot 3.x recomienda. La chain de Actuator tiene su propia lógica y no interfiere con la seguridad del resto de la app.
Los gotchas que nadie menciona en los tutoriales
Después de cerrar todo y correr la auditoría de nuevo, encontré tres situaciones que me atraparon y que vale la pena documentar.
1. /actuator/health detallado rompe health checks del load balancer
Cuando configurás show-details=when-authorized, el endpoint /actuator/health devuelve 200 OK con body mínimo para requests no autenticados — lo cual es correcto. Pero algunos health checkers corporativos esperan ver "status": "UP" en el body y parsean el JSON. Verificá que el health checker que usás funcione con el body reducido:
{ "status": "UP" }
Railway usa el status HTTP (200 = healthy), no el body — así que ahí no hay drama. Pero si venís de un setup con AWS ALB o un probe de Kubernetes que valida el body, probalo antes de deployar.
2. management.endpoint.X.enabled=false vs exposure.exclude — no son lo mismo
-
exposure.excludesaca el endpoint de la lista HTTP pero lo deja habilitado internamente (JMX, etc.) -
enabled=falselo deshabilita completamente en todos los transports
Para endpoints como heapdump y env, usá ambos. La razón: si alguien en el futuro agrega una dependencia de monitoring que habilita JMX, un endpoint solo excluido de HTTP puede reaparecer.
3. El /actuator/loggers POST es una puerta de escritura
/actuator/loggers/{name} acepta POST para cambiar niveles de log en runtime. Si ese endpoint queda abierto, cualquier atacante puede subir el nivel de logging a TRACE y potencialmente generar logs enormes (disk exhaustion) o bajar niveles de seguridad a OFF. Cerrarlo no es opcional.
Comparativa del surface de ataque antes y después
Corrí el mismo script de auditoría antes y después del hardening. El resultado:
# Antes del hardening (con exposure.include=*)
Endpoints accesibles sin auth: 10
Endpoints con información sensible: 3 (env, beans, heapdump)
Endpoints con capacidad de escritura: 2 (loggers, shutdown*)
# Después del hardening
Endpoints accesibles sin auth: 2 (health básico, info)
Endpoints con información sensible accesibles sin auth: 0
Endpoints con capacidad de escritura accesibles sin auth: 0
El shutdown endpoint merece mención especial: está deshabilitado por defecto en Spring Boot 3.x, pero si alguna vez lo habilitaste para testing y olvidaste revertirlo, es un POST /actuator/shutdown que mata la JVM. Lo verifico explícitamente en el script de auditoría.
Este tipo de superficie de ataque es relevante si estás pensando en seguridad end-to-end, incluyendo el cifrado de datos en tránsito — algo que exploré en más profundidad en el post de Themis vs Web Crypto API.
FAQ — Spring Boot Actuator seguridad producción
¿Cuáles son los endpoints de Actuator que Spring Boot expone por defecto via HTTP?
En Spring Boot 3.x, solo health e info están expuestos via HTTP por defecto. Sin embargo, si usás management.endpoints.web.exposure.include=* (común en setups de Prometheus o Grafana copiados de tutoriales), todos los endpoints disponibles se exponen de golpe. El índice /actuator siempre está visible y enumera lo que hay.
¿Qué información sensible puede exponer /actuator/env?
/actuator/env expone todas las fuentes de configuración de Spring: variables de entorno del sistema, propiedades de application.properties, propiedades de sistema JVM, y más. Spring enmascara valores cuyo nombre contiene palabras como password, secret o key, pero la detección es por convención de nombre — no es infalible. Variables con nombres no estándar pueden aparecer en texto plano.
¿Es suficiente con management.endpoints.web.exposure.exclude para proteger los endpoints?
No. exclude solo controla la exposición HTTP. Los endpoints siguen habilitados para otros transports (JMX) y siguen siendo descubribles si conocés el path directo. La protección completa requiere combinar exclude, enabled=false para los endpoints más críticos, y Spring Security para los que dejás abiertos.
¿Cómo protejo /actuator/health sin romper los health checks del load balancer?
Configurá management.endpoint.health.show-details=when-authorized. Requests sin auth reciben {"status":"UP"} o {"status":"DOWN"} con HTTP 200/503 — suficiente para la mayoría de los health checkers basados en status HTTP. Verificá que el tuyo no parsee el body antes de deployar.
¿Cuál es la mejor práctica para Actuator en una arquitectura de microservicios?
Mover Actuator a un puerto interno (management.server.port=8081) y no exponer ese puerto en el load balancer o ingress público. Las herramientas de monitoring (Prometheus, Grafana) acceden desde la red interna; los usuarios externos nunca tienen acceso directo. Combinado con Spring Security en ese puerto, el surface de ataque queda muy reducido.
¿/actuator/heapdump es tan peligroso como suena?
Sí. Un heap dump contiene el estado completo de la memoria de la JVM en el momento de la captura: objetos en memoria, strings, estructuras de datos internas. En una app que maneja tokens JWT, conexiones a base de datos o cualquier dato de sesión de usuario, un heap dump capturado por un atacante es esencialmente una filtración de datos. Deshabilitalo en producción salvo que lo necesités para debugging activo y bajo acceso controlado.
Conclusión: la documentación oficial suaviza el riesgo real
La documentación oficial de Spring Boot Actuator es clara en decir que "para producción, te recomendamos asegurarte de que solo los endpoints de health e info estén expuestos". Pero el tono es de recomendación, no de advertencia fuerte. Y los tutoriales virales de "integrar Prometheus con Spring Boot" pasan directo a exposure.include=* sin mencionar que eso abre el heapdump al mundo.
Mi punto después de armar este checklist: Actuator es una herramienta poderosa de observabilidad, pero viene configurada para conveniencia de desarrollo, no para resiliencia de producción. El costo de no auditarlo es alto; el costo de cerrarlo correctamente es bajo — una tarde de trabajo, dos archivos de configuración.
Si venés de leer el post de pnpm vs npm en mi monorepo o el de functional programming en TypeScript, ya sabés que mi approach es validar en escenarios concretos antes de dar recomendaciones. Acá aplica lo mismo: no confíes en el default. Corrés el script de auditoría, ves qué devuelve, y después decidís qué cerrar.
El checklist final que uso ahora para cualquier backend Spring Boot antes de ir a producción:
# Checklist Actuator pre-producción — Spring Boot 3.x
# 1. Verificar qué endpoints están expuestos
curl -s http://localhost:8080/actuator | python3 -m json.tool
# 2. Confirmar que /actuator/env devuelve 401 o 404
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/actuator/env
# 3. Confirmar que /actuator/heapdump devuelve 401 o 404
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/actuator/heapdump
# 4. Confirmar que /actuator/beans devuelve 401 o 404
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/actuator/beans
# 5. Verificar que health básico sigue respondiendo para el load balancer
curl -s http://localhost:8080/actuator/health
# Resultado esperado en producción:
# env → 401 o 404 ✓
# heapdump → 401 o 404 ✓
# beans → 401 o 404 ✓
# health → 200 con {"status":"UP"} ✓
Si alguno de los primeros tres devuelve 200 sin autenticación, parás el deploy y lo corregís. No hay excusa.
Fuentes originales:
- Spring Boot Actuator documentation: https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html
Este artículo fue publicado originalmente en juanchi.dev
Top comments (0)