CoursesAdvanced secrets managementDynamic secrets at scale

Dynamic secrets at scale

Database and cloud engines, leases, max_ttl, and revocation trees.

Advanced35 min · lesson 4 of 15

You search the code repository for a database password and find the same one pasted into twelve different deployment config files. That is a static secret: stock you have to guard, hunt down, and rotate forever. A dynamic secret works the other way round. Vault generates it on demand, scopes it to one consumer, and destroys it on a timer. The database admin password never leaves Vault. Each app gets a brand-new database user of its own, good for a few minutes. When the lease runs out (the lease is the countdown Vault attaches to that credential), Vault deletes the user. Nothing long-lived is left lying around for anyone to steal.

In plain terms
The everyday version: a static credential is a house key you photocopied for every contractor who ever worked on the place. A dynamic credential is a locksmith at the door, cutting a fresh key for each visitor and melting it the moment they leave. Yesterday's copy opens nothing today.

Leases, TTL, and the max_ttl ceiling

Every dynamic secret arrives with a lease, and a lease carries two numbers. The TTL (time to live) is a clock; when it reaches zero Vault revokes the credential without being asked. The max_ttl is a ceiling; past that moment no amount of renewing keeps the credential breathing. A workload renews while it still has work to do, stops renewing when it finishes, and the credential dies quietly on its own. Turn that around and you can see the whole point. In the static world a credential defaults to valid forever. Here it defaults to about to die.

TTL length is the dial you will actually turn. Short enough that a leaked credential is worthless within minutes. Long enough that renewal traffic and revocation work do not swamp Vault or the database behind it. The max_ttl is your emergency brake: without it, whoever holds a stolen lease ID (the handle Vault uses to name one specific credential) can keep renewing that credential for as long as they please. Set default_ttl for ordinary Tuesdays and max_ttl for the day you need every outstanding credential dead by a known deadline.

Wiring up a real database role

You configure this once. Vault holds the privileged database connection plus a role template that says how to create a user and how to drop that user again. Every read of database/creds/<role> mints a fresh user with narrow grants and a lease of its own. Finished early? Revoke the lease and that user disappears everywhere at once. No emails to six teams asking them to please rotate the shared password by Friday.

The creation_statements template holds placeholders that Vault fills in per request: {{name}}, {{password}}, and {{expiration}}. The revocation_statements are the SQL (structured query language, the language databases speak) that drops the user when the lease expires or when you revoke it by hand. The postgres plugin falls back to a default drop if you leave them out, but write them yourself anyway, because a plugin with no such fallback leaves orphaned users piling up inside the database itself, standing privilege that nobody declared and nobody is watching.

terminal
vault secrets enable database
# username/password are Vault's own privileged database account;
# the {{username}}/{{password}} templates in connection_url are filled in from them
vault write database/config/app-postgres \
plugin_name=postgresql-database-plugin \
allowed_roles="payments-ro" \
connection_url="postgresql://{{username}}:{{password}}@db.internal:5432/app" \
username="vault-root" \
password="$PG_VAULT_ROOT_PW"
vault write database/roles/payments-ro \
db_name=app-postgres \
creation_statements="CREATE ROLE \"{{name}}\" LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
revocation_statements="REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM \"{{name}}\"; REVOKE USAGE ON SCHEMA public FROM \"{{name}}\"; DROP ROLE IF EXISTS \"{{name}}\";" \
default_ttl=20m max_ttl=1h
output
Success! Enabled the database secrets engine
Success! Data written to: database/config/app-postgres
Success! Data written to: database/roles/payments-ro
terminal
vault read database/creds/payments-ro
vault lease renew database/creds/payments-ro/9f2..
output
username v-kubernetes-payments-ro-x7Qb...
password A1b2C3...
lease_id database/creds/payments-ro/9f2..
lease_duration 20m
lease_id database/creds/payments-ro/9f2..
lease_duration 1200 # renewed while workload still active
terminal
vault lease revoke database/creds/payments-ro/9f2..
vault list sys/leases/lookup/database/creds/payments-ro
output
Revoked lease database/creds/payments-ro/9f2..
# DB user dropped; other consumers on different leases unaffected
Keys
----
(no entries) # lease gone from the tree

Revocation trees: pull one thread, the branch unravels

Leases hang off one another like a family tree. The token a workload logs in with has a lease, and that lease is the parent of every secret the token leases afterwards. Revoke the parent and Vault walks the branch below it, revoking every child on the way down. That single property is what makes incident response survivable: kill the token an attacker stole, and Vault revokes every database user, cloud key, and certificate it ever minted, from one command instead of twenty cleanup tickets. It is a sweep rather than a switch, though, and revoked does not always mean dead. The database user really is dropped. A temporary cloud key keeps working until it expires on its own, and a revoked certificate only goes onto Vault's revocation list, the published roster of certificates nobody should trust any more, so anything that does not check that roster will carry on accepting it. Vault also retries the revocations that fail and marks the ones it cannot finish as irrevocable leases, so check what is left behind instead of assuming the branch came away clean.

It is also the reason each workload gets a token of its own. Share one token across ten services and you share one revocation blast radius, so you can no longer cut off the compromised service without cutting off the other nine. During an incident, vault lease revoke -prefix database/creds/payments-ro/ is a scalpel. Revoking the token everybody shares is an outage.

Lease tree during incident response
1stolen token
parent lease compromised
2revoke token
one command at the root
3child leases die
DB users, AWS keys, certs
4other tokens untouched
scoped per workload
Revocation trees turn incident response from "rotate everything" into "pull one thread."

The same trick, everywhere else

Databases are where most teams start, and the engine model repeats with almost nothing changed. Cloud IAM (identity and access management, the system that decides which account may call which API), where Vault assumes a role and hands back short-lived AWS, Google Cloud, or Azure credentials. Message queues. PKI (public key infrastructure, the machinery that issues TLS certificates). SSH (secure shell) access to hosts. One rule holds across all of them: Vault keeps a single privileged root credential under heavy audit, consumers never lay eyes on it, and what they receive instead are disposable children with narrow scope.

Notice what happened to the trust problem. It did not vanish. It moved, and it shrank. You stop chasing thousands of copies of a password scattered across repos and .env files, and you start guarding a handful of engine root credentials on a rotation schedule. Smaller target, better defended, still a target.

Static vs dynamic: what an attacker gets
static credential
one shared password
copied everywhere
valid until someone rotates
months, usually
revoke = rotate for all
coordinated outage
dynamic credential
unique per consumer
attributable in DB logs
minutes-long lease
expires itself
revoke one leaf
others untouched
Dynamic secrets convert "rotate the world" into "let the lease expire", and they make database logs finally attributable.

Tuning it once it is live

Watch two numbers above all: renewal rate and revocation failures. A jump in renewals usually means your TTL is shorter than your connection pool's patience. Failed revocations mean orphaned database users, so schedule a reconciliation query that lists every role matching Vault's naming prefix and alerts you on the stragglers. And be honest about outages. Dynamic secrets degrade gracefully while Vault is unreachable only if the application knows how to refresh a credential. Write clients that retry and re-fetch, not clients that grab a password at startup and cling to it until the process dies.

Then rehearse the revocation path in staging, under load. Mint credentials, revoke the parent token, and watch what the database actually does. In Postgres, dropping a role does not hang up the sessions that role already has open, so pooled connections carry on working until something recycles them; it is the next login that gets refused. Time that gap, and if it is longer than you can stomach, terminate the leftover backends yourself as part of the revocation rather than trusting the drop to do it. An untested dynamic setup finds its gaps during the first real incident, at the worst hour of the worst night.

Cloud credentials and SSH follow the same shape

The AWS secrets engine assumes an IAM role on your behalf and returns access_key, secret_key, and security_token, with a lease attached like any other dynamic secret. The Google Cloud and Azure engines are built the same way. One difference is worth knowing before an incident: temporary cloud keys like these cannot be called back early, so revoking the lease tidies up Vault's books while the keys themselves keep working until they expire on their own. That is the argument for keeping the time to live short on cloud roles. No access keys frozen into Terraform state files. No permanent IAM user per microservice.

The SSH engine does the same job for host access, handing out one-time OTP (one-time password) credentials or short-lived signed certificates, so standing access to a server is measured in minutes instead of years. Nobody has to walk the fleet afterwards pulling stale public keys out of authorized_keys files that somebody edited by hand two years ago.

terminal
vault write aws/roles/deploy \
credential_type=assumed_role \
role_arns=arn:aws:iam::111122223333:role/deploy-scoped \
default_sts_ttl=30m max_sts_ttl=1h
vault read aws/creds/deploy
output
Success! Data written to: aws/roles/deploy
lease_id aws/creds/deploy/a8b9...
lease_duration 30m
lease_renewable false
access_key ASIA...
secret_key ...
security_token IQoJb3JpZ2luX2VjE...
terminal
kubectl exec -n prod payments-0 -- env | grep -i AWS_ACCESS || echo "no static AWS keys in pod"
vault list sys/leases/lookup/aws/creds/deploy | head -5
output
no static AWS keys in pod
# dynamic creds fetched at runtime, nothing in env at rest
Keys
----
a8b9...
c7d2...

Put lease creation and revocation rates on a dashboard. A sudden spike in database/creds reads with no deployment behind it deserves a look; it might be a retry storm, or it might be a leaked token being replayed. Pair those metrics with Vault audit queries so whoever is on call can take a username out of a slow query log and trace it back to the token that minted it.

Connection pools need to be part of this conversation. A pool holds a credential for as long as it holds the connection, so the pool's maximum connection lifetime has to sit below default_ttl. Run it the other way round and the pool goes on serving connections built from a user Vault has already dropped, and the next connection it opens comes back as an authentication failure. Write that pool timer into the runbook right beside the TTL values. Whoever tunes one has to tune the other.

terminal
curl -s -H "X-Vault-Token: $VAULT_TOKEN" \
"$VAULT_ADDR/v1/sys/metrics?format=prometheus" | grep expire_num
vault audit list
output
vault_expire_num_leases 1428
vault_expire_num_irrevocable_leases 3
# graph those two; vault_database_CreateUser tracks how long a mint takes
Path Type Description
---- ---- -----------
file/ file n/a

Rolling it out without a bad week

The payoff lands hardest where every service used to share one password. Changing that password meant coordinating a dozen deploys, half the fleet broke anyway, so in practice nobody ever changed it. With a user per lease, you revoke one lease or wait out the TTL, and exactly one consumer loses access. Start with a service that will not page anyone when it breaks. Pick a default_ttl short enough to make a stolen credential worthless and long enough for your connection pool. Then widen the rollout.

Watch the user count as you go. You wrote one role; every read of it mints another user, so users and leases are what pile up. Each revocation that quietly fails leaves its user behind, and some engines start creaking once tens of thousands have collected, which is what that reconciliation query is really for. And if an application caches a password past max_ttl, expect authentication errors that everyone in the channel will describe as the database flapping. The database is fine. The lease discipline is not.

For cloud roles, prefer STS-style temporary credentials (STS is the AWS Security Token Service, the API that vends time-limited keys) over having Vault create real IAM users with long-lived access keys. Temporary credentials expire on their own with no delete call required, which shrinks your exposure window when a revoke is slow or the cloud API is throttling you.

Try this

Three commands tell the whole story. Mint a database credential, look at the lease behind it, revoke it, and then check the database to confirm that user really is gone.

terminal
vault read database/creds/payments-ro
vault lease lookup <lease_id>
vault lease revoke <lease_id>
# optional: psql check that the role vanished
psql -c "\du" | grep payments-ro || echo "dynamic user gone"
output
Key Value
--- -----
lease_id database/creds/payments-ro/abcd
lease_duration 20m
username v-kubernetes-payments-ro-x7k2
password A1b2C3...
lease_id database/creds/payments-ro/abcd
expire_time 2026-07-28T08:10:00Z
Success! Revoked lease: database/creds/payments-ro/abcd
dynamic user gone

Takeaway

Dynamic secrets trade "guard this password forever" for "mint it, use it, let it expire." Leases buy you revocation trees. A username per consumer buys you a database log that names names. And max_ttl is what stops a stolen lease from renewing its way into next year.

Next up: rotate the engine root credential, line your connection pool lifetimes up with default_ttl, and move one shared static password off the critical path this sprint.

The engine root credential is your new crown jewel
Dynamic secrets do not remove the powerful credential, they concentrate it. The database or cloud account Vault uses to mint users is highly privileged and long-lived by design. Give it exactly the grants it needs to create and drop scoped users, stopping short of full admin wherever the database allows. Put it behind Vault's own rotation (vault write -f database/rotate-root/...) so that after the first rotation not even you know the value. Then alarm on any use of it that did not come from Vault. Whoever holds that credential holds everything it can mint.
Quick check
01Your role is written with default_ttl=20m and max_ttl=1h, and the connection pool in front of that Postgres keeps each connection for up to 30 minutes. What shows up in production?
Incorrect — Renewal is the workload's job, done with vault lease renew against the lease_id. Vault never stretches a lease for you, and max_ttl would stop it at an hour even if it did.
Incorrect — max_ttl is only the ceiling on renewals, not the life of a credential nobody renews. Left alone this one dies at the 20m default_ttl, so a 30 minute connection is already ten minutes past its user.
Correct — A pool holds that credential for as long as it holds the connection. Vault drops the database user at 20 minutes, so open connections start failing and every fresh one comes back as an auth error. Put the pool's max lifetime below default_ttl and write both numbers in the runbook.
Incorrect — A plain pool tracks connections, not leases. Re-fetching only happens if you wrote the client to retry and pull a new credential, which is exactly the work teams skip when they grab a password at startup.
02You revoke lease aws/creds/deploy/a8b9 during an incident, Vault reports it revoked, and the stolen access_key still works against AWS minutes later. What is going on?
Correct — That engine assumes an IAM role and hands back STS credentials. Revoking tidies Vault's records, but nothing can pull the keys back, so the default_sts_ttl of 30m on this role is your real containment window. Keep cloud TTLs short for exactly this reason.
Incorrect — Revocation is not just a tap on future issuance. On the database engine it runs your revocation_statements and drops that user immediately. Cloud keys are the exception, and for a different reason than this.
Incorrect — Irrevocable leases are real and worth checking after any large revoke, but here Vault confirmed the revocation. The books are clean while the keys keep working.
Incorrect — Cascading runs downward, from parent to child. Revoking a child lease on its own is valid and is what you want when you are being surgical about one consumer.
03An attacker's token minted five database credentials in the past hour. You revoke that token, then check the database and find two of the five users still present. Where do you look?
Incorrect — Children do not keep an independent countdown once the parent goes. Vault walks the branch and revokes them there and then, so waiting out a TTL is not the explanation.
Incorrect — A parent revocation sweeps the children that already exist, not just future ones. That is why two survivors are a signal rather than the expected behaviour.
Incorrect — The tree follows lease parentage, not mount boundaries. One token's revocation takes its database users, cloud keys and certificates down together.
Correct — Vault retries failures and marks what it cannot complete. List what survived under sys/leases/lookup for that path, drop the leftover users by hand, and alert on revoke errors so you find them before the next incident rather than during it.

Related