HashiCorp Vault for Spring Boot Developers — Dynamic Secrets, Leases, and Kubernetes Auth

by Eric Hanson, Backend Developer at Clean Systems Consulting

What Vault does that environment variables don't

Environment variables solve the "don't commit secrets to git" problem. They don't solve:

  • Rotation: a compromised environment variable credential remains valid indefinitely
  • Audit: no record of which application instance used which credential when
  • Blast radius: all instances share the same credential — one breach exposes all
  • Expiry: no automatic credential invalidation

Vault addresses all four. Dynamic secrets generate a unique credential per application instance with automatic expiry. Every secret access is logged. A compromised instance's credentials expire automatically. The trade-off: operational complexity. Vault is infrastructure that requires management, high availability, and a backup strategy.

For applications where a credential breach would cause significant harm — production databases, payment APIs, internal admin services — the trade-off is worth it.

Spring Cloud Vault setup

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-vault-config</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-vault-config-databases</artifactId>
</dependency>
spring:
  cloud:
    vault:
      uri: https://vault.internal:8200
      connection-timeout: 5000
      read-timeout: 15000
      config:
        lifecycle:
          enabled: true           # auto-renew leases
          min-renewal: 10s        # renew when 10s from expiry
          expiry-threshold: 1m    # treat as expiring when < 1m left
          lease-endpoints: legacy # or sys for Vault 1.x

Spring Cloud Vault connects at application startup, authenticates, and loads secrets into the Spring Environment. Secrets are available as Spring Boot properties — the same @Value("${my.secret}") and @ConfigurationProperties mechanism works without modification.

Kubernetes auth — the production authentication method

The Kubernetes auth method allows pods to authenticate with Vault using their Kubernetes service account token — no static Vault tokens or AppRole credentials required.

How it works:

  1. The pod's service account token is mounted automatically at /var/run/secrets/kubernetes.io/serviceaccount/token
  2. The application sends this token to Vault's Kubernetes auth endpoint
  3. Vault validates the token with the Kubernetes API server
  4. Vault issues a Vault token with policies attached to the service account
spring:
  cloud:
    vault:
      authentication: kubernetes
      kubernetes:
        role: order-service          # Vault role mapped to this service account
        kubernetes-path: auth/kubernetes  # Vault mount path for k8s auth
        service-account-token-file: /var/run/secrets/kubernetes.io/serviceaccount/token

Vault configuration (applied once by operations):

# Enable Kubernetes auth method
vault auth enable kubernetes

# Configure with the Kubernetes cluster details
vault write auth/kubernetes/config \
  kubernetes_host="https://kubernetes.default.svc.cluster.local" \
  kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt

# Create a role that maps a Kubernetes service account to Vault policies
vault write auth/kubernetes/role/order-service \
  bound_service_account_names=order-service \
  bound_service_account_namespaces=production \
  policies=order-service-policy \
  ttl=1h
# order-service-policy.hcl
path "secret/data/order-service/*" {
  capabilities = ["read"]
}

path "database/creds/order-service-role" {
  capabilities = ["read"]
}

path "transit/encrypt/order-service-key" {
  capabilities = ["update"]
}

path "transit/decrypt/order-service-key" {
  capabilities = ["update"]
}

The Kubernetes service account order-service in namespace production gets exactly the permissions in order-service-policy — least privilege, machine identity, no static credentials.

Kubernetes deployment:

apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      serviceAccountName: order-service  # must match Vault role binding
      containers:
        - name: order-service
          env:
            - name: SPRING_CLOUD_VAULT_URI
              value: https://vault.internal:8200
            - name: SPRING_CLOUD_VAULT_AUTHENTICATION
              value: kubernetes
            - name: SPRING_CLOUD_VAULT_KUBERNETES_ROLE
              value: order-service

Static secrets — KV store

For secrets that don't need to be dynamic (API keys, webhook secrets, third-party credentials), Vault's KV secrets engine is a more auditable alternative to Kubernetes Secrets:

# Write secrets to Vault KV v2
vault kv put secret/order-service/production \
  stripe-api-key="sk_live_..." \
  sendgrid-api-key="SG...." \
  webhook-signing-secret="whsec_..."
spring:
  cloud:
    vault:
      kv:
        enabled: true
        backend: secret          # KV mount path
        default-context: order-service/production

Spring Cloud Vault loads all key-value pairs from secret/order-service/production into the Spring Environment. The stripe-api-key becomes available as stripe-api-key or via relaxed binding as stripeApiKey:

@ConfigurationProperties(prefix = "")
@Validated
public class SecretsConfig {
    @NotBlank private String stripeApiKey;
    @NotBlank private String sendgridApiKey;
    @NotBlank private String webhookSigningSecret;
}

KV v2 versioning. Vault KV v2 keeps a configurable number of secret versions. Rolling back a credential to a previous version is a Vault operation, not a code deployment.

Dynamic database credentials

Dynamic secrets are Vault's most impactful feature for database-backed applications. Instead of a static username/password, each application instance receives a unique credential that expires automatically.

Vault database engine configuration:

# Enable the database secrets engine
vault secrets enable database

# Configure PostgreSQL connection
vault write database/config/order-service-db \
  plugin_name=postgresql-database-plugin \
  allowed_roles="order-service-role" \
  connection_url="postgresql://vault-admin:{{password}}@postgres.internal:5432/orderdb" \
  username="vault-admin" \
  password="vault-admin-password"

# Define the role — the SQL template for credential creation
vault write database/roles/order-service-role \
  db_name=order-service-db \
  creation_statements="
    CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';
    GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";
    GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO \"{{name}}\";
  " \
  default_ttl="1h" \
  max_ttl="24h"

Spring Boot configuration:

spring:
  cloud:
    vault:
      database:
        enabled: true
        role: order-service-role
        backend: database

Spring Cloud Vault fetches a dynamic credential from Vault and configures the Spring Boot DataSource with it. Before the credential expires, Spring Cloud Vault renews the lease — the application continues without interruption.

What the database sees for each instance:

v-kubern-order-ser-AbCdEfGhIjKl-1713360000

Each credential has a unique name (Vault-generated), a unique password, and an expiry timestamp (VALID UNTIL). If a pod is compromised, its credential expires within 1 hour — even without detecting the breach.

HikariCP behavior during renewal. Existing connections in the pool authenticated with the old credential continue to work — PostgreSQL doesn't invalidate existing connections when the role's password changes. New connections use the renewed credential. The transition is seamless.

When the lease expires (if renewal fails), new connection attempts fail. HikariCP logs connection errors; the application becomes degraded but running (existing connections still serve requests). The health probe detects the pool exhaustion and Kubernetes restarts the pod — which re-authenticates with Vault and gets a fresh credential.

Transit encryption — Vault as a key management service

The transit secrets engine encrypts data without ever exposing the encryption key to the application. The application sends plaintext to Vault; Vault returns ciphertext. The key never leaves Vault.

# Enable transit engine
vault secrets enable transit

# Create an encryption key
vault write -f transit/keys/order-service-key

From Spring Boot:

@Service
public class VaultEncryptionService {

    private final VaultTemplate vaultTemplate;
    private static final String KEY_NAME = "order-service-key";

    public String encrypt(String plaintext) {
        String base64Encoded = Base64.getEncoder()
            .encodeToString(plaintext.getBytes(StandardCharsets.UTF_8));

        VaultResponse response = vaultTemplate.write(
            "transit/encrypt/" + KEY_NAME,
            Map.of("plaintext", base64Encoded));

        return (String) response.getData().get("ciphertext");
        // Returns: "vault:v1:8SDd3WHDOjf7mq69CyCqYjBXAiQQAVZRkFM13ok481zoCmHnSeDX9vyf7w=="
    }

    public String decrypt(String ciphertext) {
        VaultResponse response = vaultTemplate.write(
            "transit/decrypt/" + KEY_NAME,
            Map.of("ciphertext", ciphertext));

        String base64Decoded = (String) response.getData().get("plaintext");
        return new String(Base64.getDecoder().decode(base64Decoded),
            StandardCharsets.UTF_8);
    }
}

JPA attribute converter for transparent column encryption:

@Converter
public class EncryptedStringConverter implements AttributeConverter<String, String> {

    private final VaultEncryptionService encryptionService;

    public EncryptedStringConverter(VaultEncryptionService encryptionService) {
        this.encryptionService = encryptionService;
    }

    @Override
    public String convertToDatabaseColumn(String plaintext) {
        if (plaintext == null) return null;
        return encryptionService.encrypt(plaintext);
    }

    @Override
    public String convertToEntityAttribute(String ciphertext) {
        if (ciphertext == null) return null;
        return encryptionService.decrypt(ciphertext);
    }
}

@Entity
public class PaymentMethod {
    @Id private Long id;

    @Convert(converter = EncryptedStringConverter.class)
    @Column(name = "card_number_encrypted")
    private String cardNumber;  // stored encrypted, retrieved decrypted transparently
}

The card number is encrypted on write and decrypted on read without any additional code in service or controller layers. The database stores vault:v1:... ciphertext — even direct database access reveals nothing.

Key rotation without re-encryption. Vault supports key rotation without re-encrypting all existing data:

# Rotate the key — creates version 2
vault write -f transit/keys/order-service-key/rotate

# Vault can still decrypt v1 ciphertext with the new key version
# Rewrap existing ciphertext to the new key version when convenient
vault write transit/rewrap/order-service-key \
  ciphertext="vault:v1:..."
# Returns: vault:v2:...

New encryptions use the current key version. Old ciphertext remains decryptable — Vault maintains all previous key versions. Rewrapping migrates old ciphertext to the new key version incrementally.

Vault agent sidecar — the Kubernetes-native pattern

The Vault agent runs as a sidecar container in the same pod as the application. It handles authentication with Vault and writes secrets to a shared volume. The application reads secrets from files — no Vault SDK required:

# deployment.yaml
spec:
  template:
    spec:
      serviceAccountName: order-service
      volumes:
        - name: vault-secrets
          emptyDir:
            medium: Memory  # in-memory, not persisted to disk
      initContainers:
        - name: vault-agent-init
          image: hashicorp/vault:1.15
          command: ["vault", "agent", "-config=/vault/config/agent.hcl", "-exit-after-auth"]
          volumeMounts:
            - name: vault-secrets
              mountPath: /vault/secrets
      containers:
        - name: vault-agent
          image: hashicorp/vault:1.15
          command: ["vault", "agent", "-config=/vault/config/agent.hcl"]
          volumeMounts:
            - name: vault-secrets
              mountPath: /vault/secrets
        - name: order-service
          image: order-service:latest
          env:
            - name: SPRING_CONFIG_IMPORT
              value: "optional:configtree:/vault/secrets/"
          volumeMounts:
            - name: vault-secrets
              mountPath: /vault/secrets
              readOnly: true

Vault agent configuration (agent.hcl):

vault {
  address = "https://vault.internal:8200"
}

auto_auth {
  method "kubernetes" {
    mount_path = "auth/kubernetes"
    config = {
      role = "order-service"
    }
  }
  sink "file" {
    config = {
      path = "/vault/secrets/.vault-token"
    }
  }
}

template {
  destination = "/vault/secrets/database-url"
  contents = <<EOT
{{- with secret "database/creds/order-service-role" -}}
postgresql://{{ .Data.username }}:{{ .Data.password }}@postgres.internal:5432/orderdb
{{- end }}
EOT
}

template {
  destination = "/vault/secrets/stripe-api-key"
  contents = "{{ with secret \"secret/data/order-service/production\" }}{{ .Data.data.stripe-api-key }}{{ end }}"
}

SPRING_CONFIG_IMPORT: optional:configtree:/vault/secrets/ loads each file in the directory as a Spring Boot property named after the filename. /vault/secrets/stripe-api-key becomes the property stripe-api-key.

The Vault agent renews credentials automatically and rewrites the template files when credentials change. The optional: prefix means the application starts even if the secrets directory is empty — the init container ensures secrets are populated before the main container starts.

What happens when Vault is unavailable

Vault unavailability at startup is fatal — Spring Cloud Vault fails the context load if it can't reach Vault and fetch required secrets:

spring:
  cloud:
    vault:
      fail-fast: true  # default: true — fail startup if Vault unreachable

This is the correct behavior. An application that starts without its required credentials is either broken or insecure — failing fast surfaces the infrastructure problem immediately.

For optional secrets (feature flags, non-critical configuration), set fail-fast: false and provide defaults:

spring:
  cloud:
    vault:
      fail-fast: false
  config:
    import: "optional:vault:"  # optional: prefix — don't fail if unavailable

At runtime, Vault unavailability during lease renewal causes credential refresh to fail. Spring Cloud Vault logs errors and retries. HikariCP continues serving requests from existing authenticated connections — the application degrades gracefully until Vault recovers or existing connections expire.

Vault itself should be deployed in high-availability mode (3+ nodes with Raft consensus) with automated unseal (via AWS KMS, GCP Cloud KMS, or Azure Key Vault). A single-node Vault in production is a single point of failure that will cause outages.

The migration path from environment variables

Migrating from environment variables to Vault doesn't require a flag day. Migrate one secret at a time:

  1. Write the secret to Vault KV: vault kv put secret/order-service stripe-api-key="sk_live_..."
  2. Add Spring Cloud Vault to the dependency list
  3. Configure Kubernetes auth (or token auth for testing)
  4. Remove the STRIPE_API_KEY environment variable from the Kubernetes deployment
  5. Verify the application starts and the secret is loaded from Vault
  6. Move to the next secret

The spring.config.import order controls which source wins when the same property appears in both environment variables and Vault — environment variables take precedence over Vault in Spring Boot's default precedence order. This allows gradual migration without conflicts.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Handling Clients Who Think You’re a 24/7 Developer

It starts with a “quick message” at night. Then suddenly, you’re expected to reply at 2 AM like it’s normal.

Read more

Why Boston Tech Startups Struggle to Hire Backend Engineers Despite the University Pipeline

Boston mints engineers at an extraordinary rate. The startups trying to hire them are still coming up empty.

Read more

Backwards Compatibility Is a Promise. Stop Breaking It.

Every time you make an unannounced breaking change, you are telling your users that their time is worth less than your convenience. Here is how to take that promise seriously.

Read more

Consistent Error Handling Across Your API Is Not a Nice to Have

Inconsistent error shapes across endpoints force developers to write defensive code for every route they touch. Consistency is not polish — it is correctness.

Read more