Secrets Rotation Zero-Downtime: Vault Dynamic Credentials with Lease Renewal Strategies
How to use HashiCorp Vault dynamic credentials with lease renewal to rotate database secrets without dropping a single connection.
The 3am page is always the same: “Database authentication errors, 503s spiking, on-call is investigating.” Root cause, two hours later: a static database password was rotated by the security team, the secret in Kubernetes wasn’t updated fast enough, and every pod restarted simultaneously when the new secret finally propagated. That’s not a security improvement — it’s a self-inflicted outage. The team traded a theoretical breach risk for a guaranteed availability incident.
Dynamic credentials solve this at the architecture level. Instead of a long-lived password you rotate occasionally, Vault issues short-lived credentials on demand — each app instance gets its own username and password, valid for 1 hour, renewable up to 24 hours, then automatically revoked. The rotation problem becomes a lease renewal problem, which is tractable. Here’s how to build this properly for production.
How Vault Dynamic Credentials Actually Work
Vault’s database secrets engine creates credentials at request time. When your app calls vault read database/creds/app-role, Vault executes a CREATE USER statement against your Postgres or MySQL instance, hands you a fresh username and password, and records a lease with a TTL. When the lease expires — or you revoke it — Vault runs DROP USER.
The critical distinction most teams miss: there are two TTLs. The default_ttl (how long before you must renew) and the max_ttl (the hard ceiling, no renewals past this point). Your application must renew leases actively before the default TTL expires, and must handle max TTL gracefully by requesting new credentials and replacing its connection pool.
# snippet-1
# vault-database-role.yaml - Vault database role configuration
# Applied via: vault write database/roles/app-production @vault-database-role.yaml
db_name: "postgres-production"
creation_statements:
- |
CREATE ROLE "" WITH
LOGIN
PASSWORD ''
VALID UNTIL ''
CONNECTION LIMIT 20;
GRANT CONNECT ON DATABASE appdb TO "";
GRANT USAGE ON SCHEMA public TO "";
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "";
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO "";
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "";
revocation_statements:
- |
REASSIGN OWNED BY "" TO postgres;
DROP OWNED BY "";
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM "";
DROP ROLE IF EXISTS "";
default_ttl: "1h"
max_ttl: "24h"
The VALID UNTIL clause in Postgres is belt-and-suspenders: even if Vault’s revocation fails (network partition, Vault outage), the database itself rejects the credentials after expiry. Always set this.
Building a Lease-Aware Connection Pool in Go
Most database drivers don’t understand Vault leases. You need a wrapper that tracks lease expiry, renews proactively, and swaps the connection pool when max TTL forces a credential rotation. This is not optional boilerplate — it’s the core of zero-downtime rotation.
The 30-second drain window is the critical detail. When you swap the pool, you can’t immediately close the old *sql.DB — active transactions will get sql: database is closed. The drain window lets in-flight work complete before the old credentials are fully abandoned. Tune this to your p99 query latency plus some headroom.
Kubernetes Deployment with Vault Agent Injector
For most services, you don’t want to manage Vault tokens and lease renewal in application code. The Vault Agent sidecar handles token renewal, template rendering, and secret rotation — your app just reads files.
# snippet-3
# deployment.yaml - Vault Agent sidecar injection annotations
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-service
spec:
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "api-service"
vault.hashicorp.com/agent-inject-secret-db-creds: "database/creds/api-service-role"
vault.hashicorp.com/agent-inject-template-db-creds: |DB_USER=
DB_PASS=
DB_DSN=postgres://:@db-primary.internal:5432/appdb?sslmode=require
# Agent watches lease expiry and re-renders the file when rotation occurs
vault.hashicorp.com/agent-inject-command-db-creds: "/usr/local/bin/reload-db-pool"
# Prevent the pod from starting until secrets are available
vault.hashicorp.com/agent-init-first: "true"
# Lease-aware: agent renews 10s before expiry
vault.hashicorp.com/agent-revoke-on-shutdown: "true"
vault.hashicorp.com/agent-revoke-grace: "5s"
spec:
serviceAccountName: api-service
containers:
- name: api
image: api-service:latest
env:
- name: DB_CREDENTIALS_FILE
value: /vault/secrets/db-creds
volumeMounts:
- name: vault-secrets
mountPath: /vault/secrets
readOnly: true
The agent-inject-command annotation is the hook most teams skip. When Vault Agent re-renders the template (because it fetched new credentials), it executes that command inside your container. That command should trigger a graceful pool reload — not a process restart. A process restart means downtime. A pool reload means zero-downtime.
The Reload Signal Pattern
Your application needs to respond to file changes without restarting. The canonical pattern is a SIGHUP handler or a file-watch goroutine:
Watch the directory, not the file. Vault Agent writes secrets atomically via rename (write to temp file, rename to target). On Linux, rename operations don’t generate inotify IN_MODIFY events on the target path — they generate IN_CREATE on the directory. Tools like fsnotify abstract this, but only if you watch the directory.
Failure Modes and How to Handle Them
Vault is unreachable during renewal. Your app is running fine on valid credentials. Vault goes down. What happens? If you’re running Vault in HA mode with Raft, the election takes 5-10 seconds. During that window, lease renewal attempts fail. Your credentials remain valid because Vault’s revocation requires Vault to be up — if Vault is down, credentials survive. Implement exponential backoff on renewal failures with a maximum retry window shorter than your default_ttl. If the TTL would expire before Vault returns, emit a metric and alert before attempting rotation.
The database runs out of roles. Vault creates a new Postgres role per credential issuance. If you have 50 pods and each gets a fresh credential every hour, you’ll have up to 50 active roles at steady state — not a problem. But if your pods crash-loop (OOMKilled, bad deploy), each restart creates a new role, and the old ones don’t get revoked until their TTL expires. With a 24-hour max TTL and 100 restarts, you hit Postgres’s default role limit (typically limited by pg_hba.conf or max_connections indirectly). Set max_ttl conservatively and enable Vault’s lease count limits per role.
Mid-transaction credential expiry. This cannot happen if you set ConnMaxLifetime to half the lease duration. A connection established at t=0 on a credential valid until t=3600 will be retired at t=1800. By t=1800, you’ve either renewed the lease (credential is still valid) or already rotated to new credentials. Connections from the old pool drain within the 30-second window. No transaction spans a credential boundary.
Observability for Lease Health
You need metrics, not just logs. Three signals matter:
Alert on vault_lease_expiry_seconds < 300 for any role (5 minutes to expiry with no successful renewal is a bad sign). Alert on vault_pool_rotations_total{reason="renewal_failure"} increasing — that means your renewal loop is failing silently and relying on the fallback rotation path more than you’d like.
The Production Checklist
Before shipping dynamic credentials in production, verify these hold:
revocation_statementsin your Vault role actually work. Runvault lease revoke -prefix database/creds/your-rolein staging and confirm roles are cleaned up inpg_roles.- Your connection pool’s
ConnMaxLifetimeis strictly less thandefault_ttl / 2. Connections must not outlive their credentials. - You’re watching the directory in fsnotify, not the file.
- Vault Agent’s
agent-revoke-on-shutdown: trueis set so pod termination revokes leases immediately rather than waiting for TTL expiry. - You have a Prometheus alert on lease expiry seconds. Silent renewal failures cause outages 24 hours later when max TTL hits.
- Vault is in HA mode with at least 3 nodes. Single-node Vault turns your dynamic credentials into a single point of failure worse than a static secret.
Dynamic credentials don’t eliminate all risk — they shift it from “will someone steal this password” to “will my renewal loop work reliably.” That’s a much better trade. The failure modes are predictable, monitorable, and recoverable. The old failure mode (stolen static secret) isn’t. ```