Secrets Management & Key Security
Getting secrets out of configuration: taking an inventory, Vault KV and dynamic database credentials, Kubernetes auth, the External Secrets Operator, and rotation that works.
On this page
Most credential leaks are not sophisticated. They are a key in a repository, an environment variable in a manifest, or a password that has not changed since the person who set it left.
Key Takeaways
- Start with an inventory — you cannot protect secrets you have not enumerated.
- Kubernetes
Secretobjects are base64, not encryption. - Dynamic credentials with short leases make a leak self-limiting.
- Rotation needs an overlap window, or it causes the outage it was meant to prevent.
- Every secret needs an owner and a rotation schedule.
Inventory first
| Secret | Where it lives now | Blast radius | Rotation |
|---|---|---|---|
| Database password | K8s Secret | Full data access | Never |
| JWT signing key | K8s Secret | Forge any identity | Never |
| Payment API key | Environment variable | Financial | Annually |
| SMTP password | application.yml | Phishing from your domain | Never |
| Encryption key | Keystore file | All encrypted data | Never |
| Service OAuth secrets | K8s Secrets | Per-service access | Never |
The "never" column is the finding. Most organisations discover during this exercise that credentials issued years ago are still live, still shared, and known to people who have left. The inventory itself is often more valuable than the tooling that follows.
Vault
spring:
cloud:
vault:
uri: https://vault.internal:8200
authentication: KUBERNETES
kubernetes:
role: order-service
# The pod's service account token — no static credential to distribute.
service-account-token-file: /var/run/secrets/kubernetes.io/serviceaccount/token
kv:
enabled: true
backend: secret
default-context: order-service
database:
enabled: true
role: order-service-db
backend: database
config:
import: 'vault://'Kubernetes authentication removes the bootstrap problem. The pod authenticates with a token the platform already provides, so there is no static Vault credential to distribute or rotate — which is otherwise the awkward part of any secret manager.
Dynamic credentials
vault write database/roles/order-service-db \
db_name=orders \
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}}\";" \
default_ttl="1h" \
max_ttl="24h"Note what the grant does not include: no DROP, no CREATE, no schema ownership. A dynamic
credential is also an opportunity to apply least privilege, since you are writing the grant explicitly
rather than reusing an account that accumulated permissions over years.
Spring Cloud Vault renews the lease automatically and refreshes the connection pool when credentials rotate. Test that path deliberately — a pool that does not pick up new credentials fails an hour after deployment, which is a confusing incident to diagnose.
AppRole outside Kubernetes
// role-id is configuration; secret-id is short-lived and delivered separately,
// so neither alone is sufficient to authenticate.
@Bean
ClientAuthentication clientAuthentication(VaultProperties props) {
return new AppRoleAuthentication(
AppRoleAuthenticationOptions.builder()
.roleId(RoleId.provided(props.roleId()))
.secretId(SecretId.provided(props.secretId()))
.build(),
restOperations);
}The two-part split is the point: a role id in configuration is not a credential on its own, and the secret id is delivered at deploy time with a short TTL and a use limit. Compromising the configuration repository does not yield Vault access.
External Secrets Operator
For teams not ready to add a Vault client to every application, the operator syncs secrets into native Kubernetes objects:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: order-service-secrets
spec:
refreshInterval: 1h
secretStoreRef: { name: vault-backend, kind: ClusterSecretStore }
target:
name: order-service-secrets
creationPolicy: Owner
data:
- secretKey: STRIPE_API_KEY
remoteRef: { key: order-service, property: stripe_api_key }
- secretKey: SMTP_PASSWORD
remoteRef: { key: order-service, property: smtp_password }Applications read ordinary environment variables and know nothing about Vault, while the source of truth gains rotation and audit. It is the pragmatic migration path — adopt the operator first, move individual services to direct integration and dynamic credentials as they need it.
Rotation with overlap
@Service
public class TokenVerifier {
/** During rotation both keys verify; only the current one signs. */
public Claims verify(String token) {
for (SigningKey key : keyRegistry.activeKeys()) { // current first, then previous
try {
return Jwts.parser().verifyWith(key.publicKey()).build()
.parseSignedClaims(token).getPayload();
} catch (JwtException ignored) {
// Try the next key.
}
}
throw new InvalidTokenException();
}
}The overlap window is what makes rotation routine rather than an event. Publish the new key alongside the old, switch signing, wait for everything signed by the old key to expire, then retire it — no coordinated deploy, no window where valid credentials are rejected.
Rotation that requires a simultaneous change everywhere gets deferred until a breach forces it, which is the worst possible time to attempt it for the first time.
What must never happen
Secrets in source control, including history — rotate anything found there rather than only
removing it. Secrets in container images, recoverable from docker history. Secrets in logs,
where they propagate into every downstream index and backup. Secrets in error messages returned to
clients. And secrets in environment variables printed by a debug endpoint — /actuator/env masks
common patterns, but only patterns it recognises.
Add a scanner for each of these paths rather than relying on review, because each has been a real breach for someone.
What to take away
Take the inventory first — it usually finds credentials nobody remembered. Move to a secret manager, using the platform identity to authenticate rather than a bootstrap credential. Prefer dynamic credentials with short leases so a leak expires on its own. And build rotation with an overlap window before you need it.
Frequently Asked Questions
Are Kubernetes Secrets good enough?
What makes dynamic credentials better?
How do I rotate a secret without downtime?
Related tutorials
- Threat ModellingFinding design flaws before they ship: drawing data flow diagrams, applying STRIDE per element, prioritising with DREAD, and running a session that produces actionable work.
- DevSecOps — Securing the PipelineSecurity gates that catch real problems without blocking delivery: pre-commit secret scanning, SAST with FindSecBugs, dependency and container scanning, DAST, and tuning out the noise.
- Penetration Testing for Java AppsA structured approach to testing your own application: reconnaissance, authentication and authorisation testing, injection, business logic flaws, and the tools that help.
- Testing Spring SecurityWriting security tests that catch real gaps: @WithMockUser and @WithUserDetails, MockMvc request post-processors, testing method security, mock JWTs, and the negative tests that matter.