Files
spring-boot-demo/docs/04-securing-actuator.md
Ankur Mhatre 4b6cefa60a Spring Boot 4 Actuator in production: endpoints, security, custom health indicators
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.
2026-09-04 10:40:04 +05:30

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))

← 03 · 04 · 05 Custom health indicators →