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:
- The pod's service account token is mounted automatically at
/var/run/secrets/kubernetes.io/serviceaccount/token - The application sends this token to Vault's Kubernetes auth endpoint
- Vault validates the token with the Kubernetes API server
- 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:
- Write the secret to Vault KV:
vault kv put secret/order-service stripe-api-key="sk_live_..." - Add Spring Cloud Vault to the dependency list
- Configure Kubernetes auth (or token auth for testing)
- Remove the
STRIPE_API_KEYenvironment variable from the Kubernetes deployment - Verify the application starts and the secret is loaded from Vault
- 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.