Skip to main content

Secrets in Spring Boot: Vault vs AWS Secrets Manager vs Kubernetes Secrets vs Jasypt

Four ways to keep a password out of your Spring Boot code, each run for real on Boot 4.1.2: what happens when the secret is missing, how rotation behaves, and five ways the value leaks anyway.

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 against moto 5.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.

highest prioritycommand-line argumentsOS environment variablessecret/secrets-demo (imported from Vault)application.ymldefaultslowest priorityEnvironmentdb.password = ?DbPropertiesrecordThe application asks for db.password. It cannot tell which row answered.Vault, AWS Secrets Manager and a Kubernetes mount are more rows in this stack.

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

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.

1. Environmentis prepared2. Vault importreads uri + token3. GETsecret/secrets-demo4. Beans are built,DbProperties bindsVault serverKV v2 at secret/Steps 1 to 3 finish before the first bean exists. The secret is already in the Environmentwhen Spring starts creating objects, which is why the last section matters.

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

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.

fail-fast = false (default)fail-fast = truenothing stored at the pathstarts, db.password = nullstarts, db.password = nullwrong token (403)starts, db.password = nullfails: 403 ForbiddenVault unreachablestarts, db.password = nullfails: connection refusedunreachable, optional:not runfails: connection refusedAmber: the application runs without the secret. Green: startup stops. Gray: not exercised.

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

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");
startsecret changedrefresh()Vaultv1v2Environmentv1v2DbProperties (record)v1, for the life of the context@RefreshScope beanv1v2Only the Vault lane changes at the moment of the write. The record never changes.

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

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
Secret objectbase64 valueskubelet writesfiles into the podmount: ..data link to atimestamped directoryconfigtree: importone property per fileupdate: new directory,..data re-pointedAfter the re-point the running application still returned the old value (transcript 06).A refresh re-read the files and the new value appeared.

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.

db.passwordin plain textin the jarapplication-leaky.ymlin ps–db.password=…in /procenviron, same userin actuatorshow-values=ALWAYSin logsrecord toStringFive exits that need no attacker: every one was reproduced in this module.

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. Leave show-values at its default. Override toString on 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.

1. Environmentprepared, configimports resolved2. Contextcreated3. @DynamicPropertySourcevalues added4. Beanscreatedtoo late for step 1The container port arrives in step 3. The Vault import already ran in step 1,against the default address, found nothing, and moved on.

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?

VaultAWS Secrets ManagerKubernetes SecretJasypt
Missing secret, defaultstarts, value nullfails (404)fails unless optional:n/a (value is in the file)
Unreachable storestarts unless fail-fastnot tested heren/a (local files)n/a
Live value after rotationonly after refresh()only after refresh() in this runonly after refresh()redeploy
Per-app access controlpolicy per pathIAM (not tested)RBAC (not tested)none
Where the root of trust sitsthe tokenAWS credentialscluster accessthe 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

No Comments yet!

This site uses Akismet to reduce spam. Learn how your comment data is processed.