I recently wrote about allowlisting actuator endpoints: what to expose and what not to. One question stayed open and kept nagging at me: if you decide to leave /actuator/env enabled — because you need it for debugging in staging, because infra is asking for it — what actually guarantees you won't show a secret in plain text the first time someone hits it with curl?
Short answer: nothing, if you blindly trust the default sanitizer.
The problem with actuator env show values
Spring Boot ships with a sanitizer that automatically masks certain values before showing them in /actuator/env. It works by property name: if the key contains password, secret, key, token or credentials, the value comes out as ******. It's a reasonable defense for the generic case.
The problem is exactly that: it's generic. It covers the words some Spring developer imagined you'd use. It doesn't cover the ones your team actually uses.
Think about how environment variables get named in a real project: DB_PASS instead of DB_PASSWORD, API_AUTH instead of API_TOKEN, WEBHOOK_SIGNING, PARTNER_SHARED_VALUE, INTERNAL_CIPHER. None of those contain the keywords the sanitizer looks for. All of them end up in the /actuator/env JSON response without a single asterisk.
My tesis here: the default sanitizer covers the obvious names, but that's not where the real leaks happen. The real leaks happen through the naming convention someone improvised in a sprint under deadline pressure, and nobody circled back to add it to the pattern list. I've seen this exact gap in code review — a variable named PARTNER_SHARED_VALUE sitting in a staging config, fully visible, because nobody thought a shared-secret-style value needed the word "secret" in it to deserve masking.
What the official source says — and what it doesn't
The official Spring Boot Actuator documentation confirms the behavior: the /env endpoint applies sanitization to values before exposing them, and that behavior is configurable through the SanitizingFunction interface, which replaced the keyword-based Sanitizer mechanism in more recent versions of the framework.
What the docs don't say — because it's not their job to say it — is what specific names you're going to use in your project. That's on you to audit. The docs give you the extension mechanism; the catalog of what to sanitize is the responsibility of whoever configures the project, not the framework.
That gap between "mechanism available" and "correct configuration for this case" is exactly where most exposure incidents on default, unreviewed configurations slip through.
Where people get it wrong
The common recipe I see repeated on forums and in inherited configs is: "turn on management.endpoint.env.show-values=when-authorized and you're covered." That solves who can see the values, not what values show up unmasked to whoever has authorization. They're two different problems, and they get treated as one all the time.
The hidden cost shows up when the endpoint stays accessible to an internal role — a monitoring service, an ops dashboard — and that role ends up seeing credentials nobody thought to mask because the variable name didn't match the default pattern.
Typical counterexample: a project that uses Vault or AWS Secrets Manager for production, but leaves variables named something like THIRD_PARTY_SHARED_SECRET_VALUE in application-staging.yml. The default sanitizer doesn't reliably catch SHARED_SECRET_VALUE as a unit if the matching logic is stricter than a plain substring check for "secret" — and in a lot of custom configs it ends up not covering variants with underscores or mixed case if someone overwrote the sanitizer without reviewing the inherited regex.
Here's how to extend the sanitizer with your own pattern, using SanitizingFunction:
// Custom sanitizer configuration for /actuator/env
@Bean
public SanitizingFunction customSanitizingFunction() {
// Pattern covering the team's own naming conventions
Pattern patronCustom = Pattern.compile(
"(?i).*(pass|auth|signing|shared|cipher).*"
);
return data -> {
String nombre = data.getSanitizableData().getKey();
if (patronCustom.matcher(nombre).matches()) {
return data.withValue("******");
}
// if it doesn't match, let the default sanitizer keep the chain going
return data;
};
}This bean gets added to the existing chain of sanitizers; it doesn't replace it. Spring Boot runs every registered SanitizingFunction in order and applies masking if any of them decides it's warranted.
That last path, the one on the right, is the one you have to actively close. It doesn't close itself.
Decision matrix for /actuator/env
| Situation | What to check first | What to do |
|---|---|---|
| Endpoint exposed only on localhost/debug | Confirm there's no tunneling or proxy exposing it outward | Default sanitizer may be enough, but audit variable names anyway |
| Endpoint accessible in staging with monitoring roles | What custom variables the team uses, not just Spring's | Add a SanitizingFunction with your own pattern before enabling access |
Variables with unconventional names (DB_PASS, API_AUTH) | List every key in application.yml and .env for each environment | Extend the regex pattern, don't trust the default list |
| Integrations with external providers (webhooks, partners) | Names the partner defines, not the ones you define | Custom sanitizer by provider prefix or suffix |
| Deciding whether to disable the whole endpoint | Whether anyone on the team regularly audits property names | Disabling it is safer than an unmaintained sanitizer |
That last row is the one I most want to point out: if nobody's periodically reviewing which property names show up in the code, a poorly maintained custom sanitizer gives a false sense of security — arguably worse than no sanitizer at all, because it looks like coverage. The allowlist criterion I laid out in the previous post is preferable to a sanitizer nobody updates.
Common mistakes and gotchas
- Confusing authorization with sanitization.
show-values=when-authorizedcontrols access, not content. They're independent settings that both need reviewing. - Copying the regex from a previous project without adapting it. Naming conventions change between teams and even between projects within the same team.
- Not testing the sanitizer against a negative case. Common gap: nobody writes a test that verifies a "weird"-named variable actually comes out masked.
- Thinking this is only a production problem. Staging and shared dev environments also expose
/actuator/env, and that's often exactly where real third-party credentials live for integration testing. - Assuming a Spring Boot version bump updates your pattern list for you. It doesn't. The framework maintains its own default keywords; your team's naming conventions are always your own responsibility, version after version.
FAQ
Does Spring Boot's sanitizer mask all sensitive values by default? No. It masks values whose property name contains specific words like password, secret, key, token or credentials. Any naming convention different from that stays uncovered unless explicitly configured.
What's the difference between Sanitizer and SanitizingFunction?
SanitizingFunction is the recommended interface in recent Spring Boot versions for extending sanitization behavior programmatically, replacing the earlier approach based solely on a fixed keyword list.
Can I have multiple SanitizingFunction instances registered at once?
Yes. Spring Boot runs them in a chain; if any of them decides to mask a value, that value stays masked in the final response.
Is disabling /actuator/env safer than sanitizing it?
Depends on the use case. If the team has no process to keep the custom sanitizer updated, disabling the endpoint or restricting it with a strict allowlist reduces risk with less ongoing maintenance.
Does show-values=when-authorized solve the exposed-secrets problem?
Not by itself. It controls who can see the values, not what values show up unmasked to those authorized users. They're two separate settings that need to be combined.
How do I test that my custom sanitizer works before deploying?
With a unit test that invokes the SanitizingFunction bean directly against real property names from the project, including cases with underscores, mixed case, and external provider prefixes.
Where I land
Spring Boot's default sanitizer isn't a placebo: it covers the generic case reasonably well, per what the official reference itself documents. What I can't claim without production evidence is exactly how much it reduces risk in any specific project — that depends entirely on how far that project's naming conventions drift from the default list.
What I can say with technical confidence: if nobody's audited the project's custom property names against the sanitizer's pattern list, there's an unclosed gap sitting there. That's not a remote possibility, it's a direct consequence of how the mechanism works — a name-matching filter only catches the names it was told to look for.
My practical recommendation follows the same logic I used to think through endpoint allowlisting in the previous post about actuator: treat sanitization as a living list that gets reviewed every time a new integration gets added, not as a configuration you set once and forget. Today's regex won't cover the variable name someone's going to invent next sprint. The uncomfortable question worth asking your team right now: when was the last time anyone actually opened application.yml and checked every key against the sanitizer, instead of assuming Spring already handled it?
If you're into how I think about architecture decisions with this same "what does the tool cover versus what do I have to cover" lens, I've got related posts on JWT vs stateful sessions and on what the path to Java Champion actually means that touch the same tension from other angles.
Original source:
- Spring Boot Actuator Docs: https://docs.spring.io/spring-boot/reference/actuator/endpoints.html
Looking for this approach on your team?
Explore my technical case studies or discuss a senior role, architecture and technical leadership.
Related Articles
Pinning in Virtual Threads: The Two Real Cases According to JEP 444
Yesterday I left a detail hanging: it's not that "something" blocks the carrier thread. There are exactly two scenarios, documented in JEP 444, and if you migrate legacy code without knowing them, pinning is going to explain why your virtual thread pool isn't scaling the way you expected.
Aug 28 2026 · 7′ · Tutorials · concurrencia · java
Actuator Endpoints in Spring Boot: Allowlist, Don't Just Disable the Obvious Ones
Spring Boot Actuator exposes more by default than most teams realize. The difference between an endpoint that's useful for monitoring and a map of environment variables handed to an attacker comes down to a decision almost nobody makes explicitly: allowlist versus disabling what looks obvious.
Aug 23 2026 · 8′ · Tutorials · spring-boot · java
What It Means to Be a Java Champion in 2026: The Real Criteria Behind the Recognition and Why I Care
Java Champion isn't an academic title or a corporate badge. It's recognition of community contribution — and in 2026, quality technical content in Spanish is an undervalued and completely legitimate contribution. My declared goal and the real criteria behind the program.
Aug 18 2026 · 10′ · Tutorials · open source · spring-boot
Comments (0)
What do you think of this?
Drop your comment in 10 seconds.
We only use your login to show your name and avatar. No spam.
No comments yet. Be the first — your take matters most when we're few.