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.
This commit is contained in:
2026-09-04 10:40:04 +05:30
commit 4b6cefa60a
64 changed files with 3195 additions and 0 deletions

View File

@@ -0,0 +1,122 @@
[← 03 Endpoint catalogue](03-endpoint-catalogue.md) · **04 · Securing Actuator** · [05 Custom health indicators →](05-custom-health-indicators.md)
# 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`](../src/main/java/com/ankurm/actuator/config/SecuredActuatorConfig.java)
handles authorisation. `application-mgmtport.yaml` handles reachability:
```yaml
management:
server:
port: 9001
address: 127.0.0.1
endpoints:
web:
base-path: /manage
```
From [`output/06-management-port.txt`](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
```java
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`](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:
```json
{"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.
```yaml
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`](../src/main/java/com/ankurm/actuator/config/DemoSecurityConfig.java).
```java
.csrf((csrf) -> csrf.disable())
.sessionManagement((s) -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
```
---
[← 03](03-endpoint-catalogue.md) · **04** · [05 Custom health indicators →](05-custom-health-indicators.md)