Every Spring Boot application eventually needs a password it must not leak: a database login, an API key, a signing key. The framework treats that password like any other setting. It is a property with a name, and some source supplies its value. So the real question is not how Spring reads a secret. It is where the value lives before the application starts, who can read it there, and what happens to the application when that place cannot answer.
This post walks through four common answers to that question: HashiCorp Vault, AWS Secrets Manager, Kubernetes Secrets and Jasypt-encrypted properties. Every behaviour described here was run in a companion module, spring-boot-demo/secrets, and each console block is quoted from a transcript committed there. Where something could not be established, the post says so.
Versions in this post. Spring Boot 4.1.2, Spring Cloud 2025.1.4, Spring Cloud Vault 5.0.3, Spring Cloud AWS 4.2.1, jasypt-spring-boot 4.0.4, Testcontainers 2.0.5, Vault server 2.2.1 (hashicorp/vault:2.2.1), JDK 25 (Temurin 25.0.5). The AWS tests ran againstmoto5.2.5, an AWS emulator, not real AWS. The Kubernetes tests reproduce the kubelet file layout on disk; no cluster was involved. Coming from Boot 3? See the Spring Boot 3 to 4 migration guide.
The test suite is the evidence: nine tests, zero failures (00-test-summary.txt). If you only have a minute, the sections on failures are the ones worth reading, because the most surprising results are about what does not happen when a secret is missing.
A secret is just a property that must not leak
Spring builds one Environment out of a stack of property sources: command-line arguments, OS environment variables, application.yml, defaults and so on. When code asks for db.password, Spring walks the stack from the top and returns the first match. Secret stores plug in as extra rows in that stack, and the code reading the property cannot tell which row answered.
The diagram is the whole mental model. Every backend in this post, whether Vault, AWS, a Kubernetes mount or a decrypted ENC(...) value, does one job: put a row into the stack before beans are created. The rest of the post is about how each one does that, what it does when it cannot, and how the value can leak after it arrives.
The application reads the secret through a small record, bound from the db prefix. It overrides toString so that logging it cannot print the password (DbProperties.java):
@ConfigurationProperties("db")
public record DbProperties(String url, String username, String password) {
@Override
public String toString() {
return "DbProperties[url=" + url + ", username=" + username + ", password="
+ (password == null ? "<unset>" : "<redacted:" + password.length() + " chars>") + "]";
}
}
Secrets arrive in the stack through spring.config.import, a property that tells Boot to load extra configuration from a named location before the context starts. The Spring Boot reference describes the mechanism and the prefixes used below (optional:, configtree:) in externalized configuration.
Why not just put it in application.yml?
Because that file is packaged into the jar and committed to git. The module ships a deliberately bad application-leaky.yml to prove it, and the section on what never to do shows the password sitting in the packaged jar. Every approach below exists to keep the value out of that file.
Going deeper
- PropertySourceReport: the helper that lists which sources hold a key
- Spring Boot: externalized configuration
Vault through spring.config.import: the smallest working setup
Vault is a server that stores secrets and hands them out to clients that present a token. Spring Cloud Vault connects the two: with spring.config.import=vault:// the application contacts Vault during startup, reads the key-value secret stored under its own name, and adds it to the stack. The test starts a real Vault in a container and stores one secret at secret/secrets-demo.
The order matters and it will matter again in the last section. The import happens while the Environment is being prepared, before any bean exists, so by the time DbProperties binds, the password is already there.
The test setup (VaultImportTest.java):
@Container
static VaultContainer<?> vault = VaultSupport.container(
"kv put secret/secrets-demo db.password=s3cr3t-from-vault db.username=app");
private static java.util.Map<String, Object> base() {
return Support.of(
"spring.config.import", "vault://",
"spring.cloud.vault.enabled", "true",
"spring.cloud.vault.uri", VaultSupport.uri(vault),
"spring.cloud.vault.token", VaultSupport.ROOT);
}
And what the application saw (01-vault-import.txt):
Vault server: hashicorp/vault:2.2.1
spring.config.import = vault:// (default context: secret/${spring.application.name} = secret/secrets-demo)
bound: DbProperties[url=null, username=app, password=<redacted:17 chars>]
property sources that hold db.password (first one wins):
configurationProperties
secret/secrets-demo
Two things to read from that output. The default Vault context is secret/${spring.application.name}, here secret/secrets-demo, so the application name decides which secret it reads. And the property source is listed under that path as its name; the configurationProperties line above it is just Boot’s view of the same stack.
Intermediate: contexts, profiles and KV v2
Spring Cloud Vault also mounts a shared secret/application context; transcript 02 lists it next to the application-specific one. KV version 2 inserts data/ into the API path, which is why the least-privilege policy later refers to secret/data/secrets-demo even though you write secret/secrets-demo on the command line. The Spring Cloud Vault reference covers contexts and profile-specific paths in config data support, and HashiCorp documents the path rule in the KV v2 reference.
Going deeper
- Spring Cloud Vault 5.0: config data
- Vault KV v2 secrets engine
- VaultSupport: the Testcontainers setup used by every Vault test
When Vault cannot answer, the application usually starts anyway
This is the section most posts skip, and it is where the production incidents are. Run six failure cases and the pattern is not what you would hope for.
Read the grid column by column. With the default settings, every failure ends the same way: the application starts with db.password = null. Setting spring.cloud.vault.fail-fast=true turns a wrong token and an unreachable server into startup failures, which is what you want. It does not turn a missing secret into one. Transcript (02-vault-missing-and-denied.txt):
1. spring.cloud.vault.kv.application-name=no-such-app (nothing stored at secret/no-such-app), fail-fast off:
started: yes; db.password = null
vault property sources mounted: [secret/no-such-app, secret/application]
2. same, spring.cloud.vault.fail-fast=true:
started: yes; db.password = null
3. same, with the strict profile (RequireDbPassword guard in application code):
started: no; IllegalStateException: db.password is not configured: refusing to start
4. wrong token, fail-fast off (the default):
started: yes; db.password = null
wrong token, fail-fast=true:
started: no; Forbidden: 403 Forbidden on GET request for "http://localhost:<port>/v1/secret/secrets-demo": "{"errors":["2 errors occurred:\n\t* permission denied\n\t* invalid token\n\n"]}<EOL>"
5. Vault unreachable (127.0.0.1:1), import = vault://, fail-fast off:
started: yes; db.password = null
Vault unreachable, fail-fast=true:
started: no; HttpHostConnectException: Connect to http://127.0.0.1:1 failed: Connection refused (connect failed)
6. Vault unreachable, import = optional:vault://, fail-fast=true:
started: no; HttpHostConnectException: Connect to http://127.0.0.1:1 failed: Connection refused (connect failed)
Case 2 is the surprise: fail-fast=true and nothing stored at the path, and the application started with a null password. Spring Cloud Vault treats a path with no secret as nothing to import. The only thing that stops startup in case 3 is code in the application itself, a guard that checks for the value (RequireDbPassword.java):
RequireDbPassword(Environment env) {
String pw = env.getProperty("db.password");
if (pw == null || pw.isBlank()) {
throw new IllegalStateException("db.password is not configured: refusing to start");
}
}
Case 6 is a smaller trap. optional:vault:// is meant to say “carry on if this location is missing”, yet with fail-fast=true and an unreachable server the run still failed. Do not assume optional: softens the Vault client’s own failure mode; the transcript shows it does not when fail-fast is on.
The fingerprint of this bug. A service that boots cleanly and then fails on its first database call with an authentication error, and nothing in the startup log says why. The password was never imported. Add a startup guard for every secret the application cannot run without, and set fail-fast=true so the connection-level failures are loud as well.
Going deeper
- VaultImportTest: the full matrix, one test per case
- Spring Cloud Vault: fail-fast and config data options
Rotation: Vault changes instantly, your beans do not
Rotation means changing a secret on a schedule without redeploying. It has two halves: the store has a new value, and the running application uses it. The test changes the secret in Vault while the application is running and then asks Spring to refresh (VaultRotationTest.java):
var changed = ctx.getBean(ContextRefresher.class).refresh();
t.line("ContextRefresher.refresh() changed keys: %s", changed);
t.line("environment, after refresh: db.password = %s", env.getProperty("db.password"));
t.line("DbProperties record bean, after refresh: %s", ctx.getBean(DbProperties.class).password());
t.line("@RefreshScope bean, after refresh: %s", ctx.getBean(RefreshableCredentials.class).password());
assertThat(env.getProperty("db.password")).isEqualTo("password-v2");
assertThat(ctx.getBean(RefreshableCredentials.class).password()).isEqualTo("password-v2");
Each lane is one place the password lives. Vault changes the moment it is written. Nothing else changes until ContextRefresher.refresh() runs, and even then the constructor-bound DbProperties record keeps version 1 for the life of the context. Only a @RefreshScope bean is rebuilt. Transcript (03-vault-rotation.txt):
started with db.password = password-v1
-> vault kv put secret/secrets-demo db.password=password-v2
environment, no refresh: db.password = password-v1
DbProperties bean, no refresh: password-v1
@RefreshScope bean, no refresh: password-v1
ContextRefresher.refresh() changed keys: [db.password]
environment, after refresh: db.password = password-v2
DbProperties record bean, after refresh: password-v1
@RefreshScope bean, after refresh: password-v2
old version is still in Vault: vault kv get -version=1 -> password-v1
The refresh-scoped bean that produced password-v2 is small (RefreshableCredentials.java):
@Component
@RefreshScope
public class RefreshableCredentials {
private final String password;
RefreshableCredentials(@Value("${db.password:}") String password) {
this.password = password;
}
public String password() {
return password;
}
}
Rotation is not a property of the secret store. If your connection pool opened its connections with version 1, a refreshed @RefreshScope bean does not close them. A rotation design needs three pieces: a store that holds the new value, a trigger that refreshes the context, and a consumer that actually reconnects. The last line of the transcript also shows version 1 still readable in Vault, so keep the old credential valid for a short overlap.
Going deeper
- VaultRotationTest: both tests
- Spring Cloud Vault: advanced topics (lease renewal and rotation of dynamic secrets)
Least privilege: give the application a token that can only read
Everything so far used Vault’s root token, which is fine in a test and unacceptable anywhere else. The application should hold a token tied to a policy that allows exactly the reads it needs (app-read.hcl):
path "secret/data/secrets-demo" {
capabilities = ["read"]
}
path "secret/data/application" {
capabilities = ["read"]
}
Notice secret/data/... in the path: KV v2 adds data/ to the API path. A policy written against secret/secrets-demo would not match the path the API actually uses. Transcript (04-vault-least-privilege.txt):
policy app-read (vault/app-read.hcl) attached to a 1h token
application with that token: started, db.password = password-v1
same token, vault kv get secret/other-team:
* permission denied
same token, vault kv put secret/secrets-demo db.password=hijacked:
* permission denied
The application started and read its secret. The same token could not read another team’s path and could not overwrite the application’s own secret, so a compromised application cannot rewrite its own password.
Going deeper
AWS Secrets Manager: the same idea, a different failure mode
With Spring Cloud AWS the import is aws-secretsmanager:/secrets/secrets-demo. The secret is a JSON document and each key becomes a property. The configuration used by the test (AwsSecretsManagerTest.java):
private static java.util.Map<String, Object> props(String importValue, Object... more) {
var m = Support.of(
"spring.config.import", importValue,
"spring.cloud.aws.secretsmanager.enabled", "true",
"spring.cloud.aws.secretsmanager.endpoint", endpoint(),
// Passed with the import itself: in this harness a region set only in application.yml was not picked up.
"spring.cloud.aws.region.static", "us-east-1",
"spring.cloud.aws.credentials.access-key", "test",
"spring.cloud.aws.credentials.secret-key", "test");
for (int i = 0; i < more.length; i += 2) {
m.put((String) more[i], more[i + 1]);
}
return m;
}
A caveat on the evidence. These tests ran against moto, an emulator, not AWS. Binding, the version stages and the missing-secret behaviour come from the library and the SDK rather than from the emulator’s quirks, but treat them as observations, not guarantees. Anything about IAM permissions, KMS, latency or rotation Lambdas is not shown here.
Transcript (05-aws-secrets-manager.txt):
moto: created secret /secrets/secrets-demo (JSON with db.password, db.username)
spring.config.import = aws-secretsmanager:/secrets/secrets-demo
bound: DbProperties[url=null, username=app, password=<redacted:15 chars>]
property sources that hold db.password: [configurationProperties, aws-secretsmanager:/secrets/secrets-demo]
-> PutSecretValue with db.password=aws-password-v2
environment, no reload: db.password = aws-password-v1
secret version with stages=[AWSCURRENT]
secret version with stages=[AWSPREVIOUS]
restart with reload.strategy=refresh, reload.period=PT1S: db.password = aws-password-v2
After a second PutSecretValue the running application still saw version 1; Secrets Manager moved the previous value to the AWSPREVIOUS stage. Unlike Vault, a missing secret is a hard failure, and optional: is what turns it into a silent null (05-aws-secrets-manager.txt):
missing secret, import = aws-secretsmanager:/secrets/nope:
started: no; ResourceNotFoundException: Secrets Manager can't find the specified secret. (Service: SecretsManager, Status Code: 404, Request ID: <id> (SDK Attempt Count: 1)
missing secret, import = optional:aws-secretsmanager:/secrets/nope:
started: yes; db.password = null
The library also has a polling reload (reload.strategy=refresh with a period). In this run it picked up version 2 at restart but did not show version 3 after ten seconds of one-second polling, while a manual ContextRefresher.refresh() did (same transcript, 05-aws-secrets-manager.txt):
-> PutSecretValue with db.password=aws-password-v3
environment after 10 s of 1 s polling: db.password = aws-password-v2
ContextRefresher.refresh() changed keys: [db.password]
environment after manual refresh: db.password = aws-password-v3
@RefreshScope bean after manual refresh: aws-password-v3
Not established. The polling result is one observation against an emulator. It does not prove the feature is broken against real AWS, and the post does not claim that. If you depend on automatic reload, test it in your own account.
Going deeper
Kubernetes Secrets: files in a mount, read with configtree
A Kubernetes Secret is an object whose values are base64-encoded. Base64 is an encoding, not encryption: anyone allowed to get secrets can reverse it, which the transcript demonstrates in its first lines. The Secret reaches the pod as files in a mounted directory, and Spring reads such a directory with configtree:, one property per file. The manifest pieces (secret.yaml, deployment-snippet.yaml; not applied to a cluster):
env:
- name: SPRING_CONFIG_IMPORT
value: "configtree:/etc/secrets/db/"
volumeMounts:
- name: db-credentials
mountPath: /etc/secrets/db
readOnly: true
volumes:
- name: db-credentials
secret:
secretName: db-credentials
defaultMode: 0400
The diagram shows why updates are subtle. The kubelet does not edit files in place. It writes a new timestamped directory and re-points a ..data link at it. The module reproduces that layout (KubernetesConfigtreeTest.java):
/** Writes one generation of the Secret the way the kubelet does: a timestamped dir, a ..data link, one link per key. */
private void kubeletWrite(String generation, String password) throws IOException {
Path dir = Files.createDirectories(mount.resolve(generation));
Files.writeString(dir.resolve("db.password"), password, StandardCharsets.UTF_8);
Files.writeString(dir.resolve("db.username"), "app", StandardCharsets.UTF_8);
Path tmp = mount.resolve("..data_tmp");
Files.deleteIfExists(tmp);
Files.createSymbolicLink(tmp, Path.of(generation));
Files.move(tmp, mount.resolve("..data"), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
for (String key : List.of("db.password", "db.username")) {
Path link = mount.resolve(key);
if (!Files.exists(link, java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
Files.createSymbolicLink(link, Path.of("..data", key));
}
}
}
Transcript (06-kubernetes-configtree.txt):
k8s/secret.yaml data[db.password] = czNjcjN0LWZyb20tazhz
base64 -d -> s3cr3t-from-k8s (encoding, not encryption: anyone with 'get secrets' can do this)
mounted directory, as the kubelet lays it out:
..2026_11_08_10_00_00.111111111/
..data -> ..2026_11_08_10_00_00.111111111
db.password -> ..data/db.password
db.username -> ..data/db.username
spring.config.import = configtree:<mount>/
bound: DbProperties[url=null, username=app, password=<redacted:15 chars>]
config tree property source: Config tree '<mount>'
keys it exposes: [db.password, db.username]
-> kubelet-style update: new generation dir, ..data link swapped atomically
environment after the swap: db.password = s3cr3t-from-k8s
DbProperties after the swap: s3cr3t-from-k8s
@RefreshScope bean after swap: s3cr3t-from-k8s
ContextRefresher.refresh() changed keys: [db.password]
environment after refresh: db.password = s3cr3t-rotated
@RefreshScope bean after refresh: s3cr3t-rotated
directory missing, import = configtree:/no/such/dir/:
started: no; ConfigDataResourceNotFoundException: Config data resource 'config tree [/no/such/dir]' via location 'configtree:/no/such/dir/' cannot be found
directory missing, import = optional:configtree:/no/such/dir/:
started: yes; db.password = null
Right after the swap, the environment, the record and the @RefreshScope bean all still returned the old value; a refresh() picked up the new one. As with Vault, the file changing is not the application changing. A missing directory fails startup unless the import is optional:.
Where the real protection comes from. The Kubernetes documentation recommends enabling encryption at rest for Secrets and restricting access with RBAC. Neither is visible from Spring, and neither was tested here; see the two Kubernetes pages below. For the health and shutdown side of running Spring on Kubernetes, see Spring Boot 4 on Kubernetes: probes, graceful shutdown and HPA.
Going deeper
Jasypt: encrypted values you can commit, and the key you still have to hide
Jasypt takes a different route. The secret stays in the property file, but encrypted, written as ENC(...). At startup the starter decrypts it using a master key you supply separately. Nothing is fetched from a server, which is both its appeal and its limit.
Transcript (07-jasypt.txt, test JasyptTest.java):
StringEncryptor beans: [lazyJasyptStringEncryptor]
encrypt("jasypt-secret") twice with the starter's default encryptor:
L3j9uk0JtxuSgfME2mgcEdp4DqxtdXsrHnLaQ2dXyIEOEwzHncjtN3MJm0praytC
QcEwZQiMm5z1a1VIf17Gkbr9e0eunC0Riuy7tbvYMZ4zVMylXVoBek699CM2LRog
same ciphertext both times: false (random salt and IV per call)
db.password=ENC(...) + jasypt.encryptor.password=master-key:
environment: db.password = jasypt-secret
bound: DbProperties[url=null, username=null, password=<redacted:13 chars>]
same ENC(...) value, wrong master key:
started: no; EncryptionOperationNotPossibleException: null
same ENC(...) value, no master key configured:
started: no; IllegalStateException: either 'jasypt.encryptor.password', one of ['jasypt.encryptor.private-key-string', 'jasypt.encryptor.private-key-location'] for asymmetric encryption, or one of ['jasypt.encryptor.gcm-secret-key-string', 'jasypt.encryptor.gcm-secret-key-location', 'jasypt.encryptor.gcm-secret-key-password'] for AES/GCM encryption must be provided for Password-based or Asymmetric encryption
outside Spring, org.jasypt only, algorithm PBEWITHHMACSHA512ANDAES_256, 1000 iterations, random salt+IV:
decrypt(ciphertext) = jasypt-secret
Encrypting the same text twice gave different ciphertexts, because each call uses a random salt and initialisation vector. The value decrypted correctly with the right key, and startup failed with the wrong key and with no key. The last lines show the ciphertext also decrypts with plain org.jasypt, no Spring involved (excerpt from JasyptTest.java):
SimpleStringPBEConfig cfg = new SimpleStringPBEConfig();
cfg.setPassword("master-key");
cfg.setAlgorithm("PBEWITHHMACSHA512ANDAES_256");
cfg.setKeyObtentionIterations("1000");
cfg.setPoolSize("1");
cfg.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator");
cfg.setIvGeneratorClassName("org.jasypt.iv.RandomIvGenerator");
cfg.setStringOutputType("base64");
StandardPBEStringEncryptor plain = new StandardPBEStringEncryptor();
plain.setConfig(cfg);
t.line("outside Spring, org.jasypt only, algorithm PBEWITHHMACSHA512ANDAES_256, 1000 iterations, random salt+IV:");
t.line(" decrypt(ciphertext) = %s", plain.decrypt(cipher));
assertThat(plain.decrypt(cipher)).isEqualTo("jasypt-secret");
The honest limit. The master key must reach the application some other way, usually an environment variable, and then the key is your secret. Jasypt moves the problem; it does not remove it. It gives you rotation only by re-encrypting and redeploying. Also check the project’s published security advisories for the version you plan to use before adopting it.
Going deeper
What never to do: five ways the password walks out
Choosing a good store does not help if the value leaks after it is read. The module reproduces five exits, none of which needs an attacker with exotic skills.
The diagram is a checklist. Each exit was reproduced, and the transcripts show the exact text a reader would see. First, logging an object that holds the secret (08-leaks.txt):
1. a record's generated toString:
NaiveDb[url=jdbc:postgresql://db/app, password=hunter2]
DbProperties overrides toString: DbProperties[url=jdbc:postgresql://db/app, username=app, password=<redacted:7 chars>]
A record’s generated toString prints every field, which is why DbProperties overrides it. Second, the actuator. In Boot 4.1.2, with defaults, the values are masked; with show-values=ALWAYS they are not, and a key that does not look like a secret is exposed too:
2. /actuator/env and /actuator/configprops, Boot 4.1.2 defaults:
GET /actuator/env/db.password value shown: ******
GET /actuator/env/internal.signing.material value shown: ******
GET /actuator/configprops body contains the password: false
3. same, management.endpoint.env.show-values=ALWAYS and configprops.show-values=ALWAYS:
GET /actuator/env/db.password value shown: hunter2
GET /actuator/env/internal.signing.material value shown: also-secret-but-not-named-like-one
GET /actuator/configprops body contains the password: true
Third to fifth are operating-system exits (10-exposure.txt, script exposure.sh): a secret committed into a file ends up inside the packaged jar, a secret passed as --db.password=... is visible in the process arguments to any user on the host, and environment variables are readable from /proc by the same user:
1. the jar: src/main/resources/application-leaky.yml (a deliberately committed secret) as packaged
$ unzip -p target/secrets-1.0.0.jar BOOT-INF/classes/application-leaky.yml | grep password
password: hunter2-committed-to-git
2. command-line argument, as any user on the host sees it
$ ps -o args= -p <pid> | tr ' ' '\n' | grep db.password
--db.password=arg-secret-789
3. environment variables, readable by the same user
$ tr '\0' '\n' < /proc/<pid>/environ | grep -E 'DB_PASSWORD_FROM_ENV|SIGNING_KEY'
DB_PASSWORD_FROM_ENV=env-secret-123
SIGNING_KEY=env-signing-key-456
4. /proc/<pid>/cmdline, what the kernel keeps of the arguments
--db.password=arg-secret-789
Rules that follow from the transcripts. Never commit a secret into a resource file. Never pass one on the command line. Treat environment variables as readable by anything running as the same user. Leaveshow-valuesat its default. OverridetoStringon anything that holds a secret. The actuator post covers securing the endpoints themselves: Spring Boot Actuator in production. For what ends up inside an image, see dockerizing Spring Boot 4.
Going deeper
Local development with Testcontainers, and the timing trap
Testcontainers can run a real Vault for your tests, which is how every Vault result above was produced (VaultSupport.java). The obvious way to wire a container port into a Spring test is @DynamicPropertySource, and for Vault it does not work.
Dynamic test properties are added after the context has been created, but the Vault import ran earlier, while the Environment was prepared. It therefore used the default address, found nothing, and carried on without failing. The test (DynamicPropertyTrapTest.java):
@SpringBootTest(properties = {
"spring.config.import=vault://",
"spring.cloud.vault.enabled=true",
"spring.cloud.vault.token=root",
"spring.cloud.aws.secretsmanager.enabled=false",
"logging.level.com.ulisesbocchio=WARN" })
@Testcontainers
class DynamicPropertyTrapTest {
@Container
static VaultContainer<?> vault = VaultSupport.container("kv put secret/secrets-demo db.password=from-vault");
@DynamicPropertySource
static void vaultProps(DynamicPropertyRegistry r) {
r.add("spring.cloud.vault.uri", () -> VaultSupport.uri(vault));
}
Transcript (09-dynamic-property-source-trap.txt):
@SpringBootTest properties: spring.config.import=vault://, spring.cloud.vault.token=root
@DynamicPropertySource sets spring.cloud.vault.uri to the container's mapped port
environment spring.cloud.vault.uri = http://localhost:<port>
a secret is stored in that Vault at secret/secrets-demo
environment db.password = null
The environment reports the right Vault address, the secret is stored in that Vault, and db.password is still null. The module’s other tests avoid the trap by starting the application themselves with the container address passed as default properties (Support.java):
static ConfigurableApplicationContext start(Map<String, Object> props) {
// Every starter is on the classpath at once, and each one's auto-configuration wants its own
// settings (a Vault token, an AWS region) even when nothing is imported from it. A test that
// uses one backend switches the others off; its own properties below override these.
Map<String, Object> all = new HashMap<>();
all.put("spring.cloud.vault.enabled", "false");
all.put("spring.cloud.aws.secretsmanager.enabled", "false");
all.put("logging.level.com.ulisesbocchio", "WARN");
all.put("spring.main.banner-mode", "off");
all.putAll(props);
return new SpringApplicationBuilder(SecretsApplication.class)
.web(WebApplicationType.NONE)
.logStartupInfo(false)
.properties(all)
.run();
}
Going deeper
Which one should you use?
| Vault | AWS Secrets Manager | Kubernetes Secret | Jasypt | |
|---|---|---|---|---|
| Missing secret, default | starts, value null | fails (404) | fails unless optional: | n/a (value is in the file) |
| Unreachable store | starts unless fail-fast | not tested here | n/a (local files) | n/a |
| Live value after rotation | only after refresh() | only after refresh() in this run | only after refresh() | redeploy |
| Per-app access control | policy per path | IAM (not tested) | RBAC (not tested) | none |
| Where the root of trust sits | the token | AWS credentials | cluster access | the master key |
The table only restates what the transcripts showed. It cannot say which fits you; that depends on where you already run. A team on AWS has a store and an identity system there. A team on Kubernetes already has Secrets and RBAC and mainly needs encryption at rest. Vault earns its operational cost when you need per-application policies, auditing or dynamic credentials across several platforms.
Should you even do this? If the application runs in one place with a handful of secrets, platform-native storage plus a startup guard like the one in the failures section is usually enough, and a Vault cluster is more moving parts than the risk justifies. Whichever you pick, the lessons that cost real time were the same: a missing secret does not stop startup by itself, rotation needs a refresh and a consumer that reconnects, and the leaks in the never-do section happen after the store did its job.
Further reading
- Companion module: spring-boot-demo/secrets (code, scripts, all transcripts)
- Spring Boot Actuator in production
- Spring Boot 4 on Kubernetes: probes, graceful shutdown, CPU limits, HPA
- Dockerizing Spring Boot 4
- Spring Boot 3 to 4 migration guide
- Spring Boot: externalized configuration
- Spring Cloud Vault 5.0: config data
- Spring Cloud AWS reference
- Vault KV v2
- Kubernetes: good practices for Secrets
- Kubernetes: encrypt Secret data at rest
- jasypt-spring-boot security advisories
No Comments yet!