Why this lesson exists
A static database password has three problems no storage can fix: everyone who ever saw it still knows it, every service shares it so you cannot tell who did what, and rotating it means a coordinated change in every app at once. Dynamic secrets remove all three: Vault creates a new database user for each client, with a password nobody else has, that expires on its own. The price is that you have to understand leases - because when a lease ends, the database user is dropped, and the app had better have a new one.
What you need to know already: tokens, TTLs and the token tree (the tokens lesson); policies (the policies lesson); AppRole logins (the previous lesson); SQL roles and GRANT at the level of "a role can be granted another role's privileges"; jq (7.11).
The words you need first
- Dynamic secret - a credential Vault creates when you ask, unique to you.
- Database secrets engine - the engine that creates database users. It talks to the database with an admin account (the root credentials of the connection).
- Connection -
database/config/<name>: how to reach one database, with which plugin and admin account, and which roles may use it. - Role -
database/roles/<name>: the SQL that creates a user (creation statements), and the TTLs of the credentials. - Lease - Vault's record of a credential it handed out: an id, a duration, whether it can be renewed. When the lease ends, Vault revokes the credential.
- Revocation - for a database credential: Vault drops the user from the database.
Setting it up (the platform team's part)
Mount the engine, then tell it how to reach PostgreSQL on pg.lab (simulator: a lab database with an orders schema):
$ vault secrets enable database
Success! Enabled the database secrets engine at: database/
$ vault write database/config/orders plugin_name=postgresql-database-plugin connection_url="postgresql://{{username}}:{{password}}@pg.lab:5432/orders?sslmode=require" username=vault password=vault-bootstrap-9c1e allowed_roles=orders-ro
Success! Data written to: database/config/orders
{{username}}and{{password}}are filled in by Vault fromusername=andpassword=- so the password never appears in the URL Vault stores and shows.- The
vaultdatabase user must be allowed to create roles (CREATEROLE) and grant the privileges your roles give out. It should not be a superuser. - allowed_roles - which Vault roles may use this connection. A role not in the list gets
* "orders-rw" is not an allowed role. - On write, Vault connects once to check (
verify_connection). A wrong password fails right there, with the driver's real error:
* error creating database object: error verifying connection: failed to connect to `user=vault database=orders`: 10.0.3.44:5432 (pg.lab): failed SASL auth: FATAL: password authentication failed for user "vault" (SQLSTATE 28P01)
Then a role - the SQL that runs for every credential:
$ vault write database/roles/orders-ro db_name=orders creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT orders_ro TO \"{{name}}\";" default_ttl=1h max_ttl=4h
Success! Data written to: database/roles/orders-ro
{{name}},{{password}},{{expiration}}are filled per credential.VALID UNTILmakes PostgreSQL itself refuse the password after the lease would end - a second lock in case revocation fails.GRANT orders_ro TO ...- give the user an existing group role with the right privileges, instead of listing table grants here. The privileges live in the database, reviewed with the schema; the Vault role only says "be an orders_ro".- default_ttl / max_ttl - each credential lives 1h, renewable up to 4h.
Getting a credential (the app's part)
$ vault read database/creds/orders-ro
Key Value
--- -----
lease_id database/creds/orders-ro/6eQKPsOQKtLL1oRd40PQXfCE
lease_duration 1h
lease_renewable true
password 2AtQFf-76KWOwkP3iDQk
username v-token-la-orders-r-I0l6cSmXZoorU0fDWnoT-1790107203
The username is built from a template: v-, the token's display name (8 characters: token-la from token-lab-admin), the role name (8: orders-r), 20 random characters, and the Unix time. You can tell from a database log line which Vault role and roughly which client created a user. It works like any user:
$ PGPASSWORD=$(jq -r .data.password /tmp/creds.json) psql -h pg.lab -U $(jq -r .data.username /tmp/creds.json) -d orders -c 'SELECT count(*) FROM orders;'
count
-------
18342
(1 row)
And the database shows what Vault did:
$ PGPASSWORD=vault-bootstrap-9c1e psql -h pg.lab -U vault -d orders -c "\du"
List of roles
Role name | Attributes
-----------------------------------------------------+---------------------------------------------
orders_app |
orders_ro | Cannot login
postgres | Superuser, Create role
v-token-la-orders-r-I0l6cSmXZoorU0fDWnoT-1790107203 | Password valid until 2026-09-22 21:00:03+00
v-token-la-orders-r-vArdzEIrP3ZkFvUnfnig-1790107203 | Password valid until 2026-09-22 21:00:03+00
vault | Create role
Two reads, two users. Each instance of an app gets its own; if one leaks, you revoke that one without touching the others, and the database's own logs say which instance did what.
Leases
Every dynamic credential comes with a lease. Leases are listed by path (a sudo path):
$ vault list sys/leases/lookup/database/creds/orders-ro
Keys
----
2rqS7GFpiIr1jLmaLcNEwL0n
6eQKPsOQKtLL1oRd40PQXfCE
$ vault lease lookup database/creds/orders-ro/2rqS7GFpiIr1jLmaLcNEwL0n
Key Value
--- -----
expire_time 2026-09-22T21:00:03.902099938Z
id database/creds/orders-ro/2rqS7GFpiIr1jLmaLcNEwL0n
issue_time 2026-09-22T20:00:03.902699938Z
last_renewal <nil>
renewable true
ttl 59m59s
Renewing works exactly like a token's: from now, capped by the role's max_ttl:
$ vault lease renew -increment=8h database/creds/orders-ro/2rqS7GFpiIr1jLmaLcNEwL0n
WARNING! The following warnings were returned from Vault:
* TTL of "8h" exceeded the effective max_ttl of "3h59m59s"; TTL value is
capped accordingly
Key Value
--- -----
lease_id database/creds/orders-ro/2rqS7GFpiIr1jLmaLcNEwL0n
lease_duration 3h59m59s
lease_renewable true
After 4 hours no renewal helps: the client must read database/creds/orders-ro again, get a new username and password, and reconnect. A database connection pool that cannot swap credentials at runtime will fail at that moment - design for it (Vault Agent re-rendering the credentials file, or the app re-reading them).
Revoking
$ vault lease revoke database/creds/orders-ro/2rqS7GFpiIr1jLmaLcNEwL0n
All revocation operations queued successfully!
$ PGPASSWORD=... psql -h pg.lab -U v-token-la-orders-r-vArdzEIrP3ZkFvUnfnig-1790107203 -d orders -c 'SELECT 1;'
psql: error: connection to server at "pg.lab" (10.0.3.44), port 5432 failed: FATAL: password authentication failed for user "v-token-la-orders-r-vArdzEIrP3ZkFvUnfnig-1790107203"
The user is gone from the database. Note the error: PostgreSQL says "password authentication failed" for a user that does not exist (or whose VALID UNTIL has passed), on purpose - it does not reveal which usernames exist. When an app with Vault credentials suddenly gets this error, the first question is "has its lease ended?".
vault lease revoke -prefix database/creds/orders-ro revokes every credential of the role at once (it needs sudo on sys/leases/revoke-prefix/...) - the move when a role's credentials are suspected compromised, or before changing what the role grants.
Leases belong to tokens
A lease is owned by the token that created it. Revoke the token - or let it hit its max TTL - and every lease it owns is revoked too:
$ T=$(vault token create -policy=lab-admin -ttl=5m -field=token)
$ VAULT_TOKEN=$T vault read -field=username database/creds/orders-ro
v-token-orders-r-7TKpbT1LVFTYZQb4bZDm-1790107205
$ vault token revoke $T
Success! Revoked token (if it existed)
The lease (and the database user) went with the token. That is the design: one revoke cleans up everything a compromised client had. It is also the mechanism behind a classic outage - an app whose token expires loses its database access at the same moment as its Vault access.
Rotating the root credentials
The vault database user's password was typed into Vault by a person, so it is a static secret again. Fix that:
$ vault write -f database/rotate-root/orders
Success! Data written to: database/rotate-root/orders
$ PGPASSWORD=vault-bootstrap-9c1e psql -h pg.lab -U vault -d orders -c "SELECT 1"
psql: error: connection to server at "pg.lab" (10.0.3.44), port 5432 failed: FATAL: password authentication failed for user "vault"
Vault set a new password for its own admin user and now is the only one who knows it. New credentials keep working. Do this right after configuring a connection - and know that from then on, if Vault's storage is lost, so is that password (you reset it in the database by hand).
Static roles, and other engines
- Static roles (
database/static-roles/<name>) - Vault manages the password of an existing database user and rotates it on a schedule, for apps that cannot handle a changing username. - The same lease model drives the other dynamic engines: AWS, GCP and other cloud credentials, RabbitMQ users, SSH certificates, Kubernetes service account tokens.
In an interview: "What is a dynamic secret and why is it better than a rotated static password?" - Vault creates a unique credential per client on request (vault read database/creds/<role>), with a lease; when the lease expires or is revoked, Vault deletes the credential (the database user is dropped). No shared password, every client is traceable, a leak is limited to one credential for a short time, and "rotation" is just the next read.
What you can now do
- Configure a database connection (plugin, URL template, allowed roles) and a role (creation statements, TTLs), and read Vault's connection errors.
- Get credentials, use them, and see Vault's users in the database.
- List, look up, renew and revoke leases; revoke a whole role with
-prefix. - Explain why a token's end is also its database users' end.
- Rotate the connection's root credentials.