CoursesVault from dev to productionDynamic secrets for databases and cloud

Dynamic secrets for databases and cloud

Credentials that expire faster than attackers can use them.

In short. Vault dynamic secrets for databases and cloud: credentials that expire before attackers reuse them.

Intermediate30 min · lesson 3 of 5

A static database password in your app config is a hotel room key that never stops working — copy it once and you are inside forever, long after checkout. Vault's dynamic secrets flip that model: the app asks Vault at boot, Vault mints a brand-new credential on the spot with a stamped expiry, hands it over, and schedules its own deletion. Every workload gets a unique, disposable identity that dies in an hour whether or not anyone remembers to clean up. An attacker who scrapes the credential from a heap dump or a leaked env var wins a countdown, not a key.

The database secrets engine

Vault does not store your app's database password — it holds a privileged admin account and uses it to create and drop short-lived users on demand. You configure a connection (the credentials Vault itself logs in with) and a role that carries the exact SQL Vault runs to mint a user. The templated {{name}}, {{password}}, and {{expiration}} are filled per request. Define revocation_statements explicitly: the Postgres plugin ships a bare default that only revokes public-schema privileges and drops the role, so anything beyond that — owned objects, grants in other schemas, open sessions — leaks expired accounts into the database unless you spell out the teardown yourself. Keep allowed_roles tight so a compromised role config can't reach an unrelated connection.

enable and wire up PostgreSQL
# enable the database secrets engine
vault secrets enable database
# tell Vault how to reach Postgres, using ITS OWN admin login (not the app's)
vault write database/config/app-postgres \
plugin_name=postgresql-database-plugin \
allowed_roles="app-readwrite" \
connection_url="postgresql://{{username}}:{{password}}@postgres.internal:5432/appdb?sslmode=require" \
username="vault_admin" \
password="$VAULT_DB_BOOTSTRAP_PW"
# a role = the SQL Vault runs to create and later drop a throwaway user
vault write database/roles/app-readwrite \
db_name=app-postgres \
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}}\";" \
revocation_statements="DROP ROLE IF EXISTS \"{{name}}\";" \
default_ttl="1h" \
max_ttl="24h"
# the app calls this at boot to get a unique 1-hour user
vault read database/creds/app-readwrite

Cloud credentials on demand

The same pattern maps onto AWS: instead of baking a long-lived access key into a CI job or a pod, Vault issues one scoped to a single IAM policy and expires it. The iam_user type creates a real IAM user per request — simple, but IAM is eventually consistent, so the key may need a few seconds and a retry before it authenticates. For high-churn callers prefer assumed_role with STS, which returns temporary credentials instantly and never litters your account with users. GCP and Azure engines follow the identical config-plus-role shape, so once you internalize this you can rotate the cloud provider without relearning the workflow.

temporary AWS keys via policy
# enable and give Vault a set of AWS creds it can mint children from
vault secrets enable aws
vault write aws/config/root \
access_key=$AWS_VAULT_ACCESS_KEY \
secret_key=$AWS_VAULT_SECRET_KEY \
region=us-east-1
# a role scoped to exactly the permissions the workload needs
vault write aws/roles/s3-reader \
credential_type=iam_user \
policy_document=-<<'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::app-artifacts", "arn:aws:s3:::app-artifacts/*"]
}]
}
EOF
# mint a short-lived AWS key pair
vault read aws/creds/s3-reader
Lifecycle of a dynamic secret
1App authenticates
presents its token, requests database/creds/app-readwrite
2Vault mints
runs creation SQL, returns creds + a lease_id with a TTL
3Lease ticks down
app renews within max_ttl or lets it lapse
4Auto-revoke
TTL hits zero, Vault runs revocation SQL and drops the user
Every credential is born with a death date; revocation is a first-class operation, not an afterthought.

Leases, renewal, and the kill switch

Every dynamic secret comes wrapped in a lease — the accounting record that ties a credential to its expiry. A long-running service renews before the lease lapses to keep the same credential alive up to max_ttl; a batch job just lets it expire. The lease is also your incident kill switch: revoke by ID to burn one credential, or revoke by prefix to invalidate every credential a compromised role ever handed out, in one command. This is the payoff over static secrets — containment is a single API call, not a coordinated password reset across every consumer.

operate on leases
# inspect a specific lease's remaining TTL
vault write sys/leases/lookup lease_id=database/creds/app-readwrite/<lease_id>
# extend it (capped at the role's max_ttl)
vault lease renew database/creds/app-readwrite/<lease_id>
# kill one credential immediately
vault lease revoke database/creds/app-readwrite/<lease_id>
# incident response: nuke EVERY credential from a role at once
vault lease revoke -prefix aws/creds/s3-reader
DROP ROLE fails when the user owns things
The most common production surprise is a revocation that silently doesn't happen. On PostgreSQL, DROP ROLE refuses to run if the dynamic user still owns any object or holds any grant — so if your creation statement ever does a CREATE TABLE or the app leaves an open transaction, revocation errors out and the lease sticks in Vault while a live, over-privileged account lingers in the database. Watch for it by alerting on Vault's lease-revocation failures and by periodically diffing pg_roles against Vault's active leases. The fix is a fuller revocation statement that reassigns and drops owned objects and terminates live backends before the role goes: REASSIGN OWNED BY "{{name}}" TO vault_admin; DROP OWNED BY "{{name}}"; SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = '{{name}}'; DROP ROLE IF EXISTS "{{name}}";. Test revocation deliberately — never assume the happy path cleaned up.

Related