Companion repository for the ankurm.com article. Every transcript in docs/output/ was produced by running this project; scripts/run-all.sh regenerates all of them. Verified against Spring Boot 4.1.1 / Framework 7.0.9 / Security 7.1.1 / Micrometer 1.17.1 / kafka-clients 4.2.1 on Temurin JDK 25.0.4.1+1.
4.5 KiB
← 03 Endpoint catalogue · 04 · Securing Actuator · 05 Custom health indicators →
04 — Securing Actuator
Two mechanisms, and you want both. Network isolation decides who can reach the port; authorisation decides who can use it. Neither is a substitute for the other.
The separate management port
SecuredActuatorConfig
handles authorisation. application-mgmtport.yaml handles reachability:
management:
server:
port: 9001
address: 127.0.0.1
endpoints:
web:
base-path: /manage
From output/06-management-port.txt:
GET :8080/actuator/health 401 <- application chain, no Actuator here
GET :8080/orders/count 200
GET :9001/manage/health 503
GET :9001/actuator/health 404 <- base-path moved
GET :9001/orders/count 404 <- separate context, no app controllers
LISTEN *:8080
LISTEN [::ffff:127.0.0.1]:9001
The last two lines are the argument. The management port runs a separate application context
with its own DispatcherServlet, so it cannot serve your controllers, and binding it to
loopback (or simply not listing it as a Kubernetes Service port) makes Actuator unreachable
from the internet by routing rather than by a rule somebody has to keep correct.
This is defence in depth, not a replacement for authorisation. Anything running in the same pod or on the same host still reaches 9001.
Match on EndpointRequest, not on a path
http.securityMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests((requests) -> requests
.requestMatchers(EndpointRequest.to(HealthEndpoint.class, InfoEndpoint.class)).permitAll()
.anyRequest().hasRole("ACTUATOR"))
A rule written as requestMatchers("/actuator/**") stops matching the moment somebody sets
management.endpoints.web.base-path — as the mgmtport profile does. The endpoint moves, the
rule does not follow, and the endpoint is now unprotected with no error anywhere. EndpointRequest
asks the endpoint registry where things actually are.
Order the chains
@Order(1) on the Actuator chain, @Order(2) on the application chain. Without explicit
ordering, whichever chain Spring registers first wins for a given request, and an application
chain ending in permitAll() will happily swallow /actuator/**.
The resulting matrix
From output/05-secured-matrix.txt, with exposure set to "*":
| Request | Anonymous | ops / ROLE_ACTUATOR |
Wrong password |
|---|---|---|---|
/actuator/health |
503 | 503 | — |
/actuator/info |
200 | — | — |
/actuator/env |
401 | 200 | 401 |
/actuator/beans |
401 | 200 | — |
/actuator/threaddump |
401 | 200 | — |
/actuator (links) |
401 | — | — |
/orders/count |
200 | — | — |
Exposure is "*" and nothing leaks. That is the point: exposure is not access control.
Use exposure to decide what exists, and the security chain to decide who may use it.
show-details: when-authorized
The third option, and the one to reach for. Anonymous:
{"groups":["liveness","readiness"],"status":"DOWN"}
Authenticated with ROLE_ACTUATOR: the full nine-component breakdown, naming kafka as the
failing one. Same URL, same status code, different body. Your load balancer gets the signal it
needs; an anonymous prober learns that something is wrong but not which of your dependencies to
attack next.
management:
endpoint:
health:
show-details: when-authorized
roles: ACTUATOR
Make the Actuator chain stateless and CSRF-free
Spring Boot's auto-configured security is browser-shaped: it enables CSRF protection. Actuator
is a machine-to-machine API, and POST /actuator/loggers/{name} from a script will be rejected
before it reaches the endpoint.
While building this repository that showed up as a 401 on POST /stub/upstream/mode while
GET /orders/count with the same credentials returned 200 — a genuinely confusing pair of
results that has nothing to do with the credentials. See
DemoSecurityConfig.
.csrf((csrf) -> csrf.disable())
.sessionManagement((s) -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))