The idea

The usual pattern for application database access is a long-lived account with a password stored somewhere — a ConfigMap, a Secret, a config file, or worst of all hard-coded. That password is shared by every replica, it never rotates on its own, and revoking it means coordinating a change across everything that uses it.

Vault's database secrets engine inverts this. Instead of handing out one password that lives forever, Vault creates a brand-new database role with a unique password every time an application asks for credentials, and then drops it when its time to live runs out:

app + vault client  -->  vault server  -->  DB (create temp user + password)

app (uses that temp user)  -->  DB

The application never sees a static credential. Each instance gets its own identity at the database level, the lease expires on its own, and revoking access means revoking a lease rather than rotating a shared secret. If a credential leaks, the damage is bounded by its TTL.

1. Create the database connection

The connection tells Vault how to reach PostgreSQL and which account to use when creating other users. That account needs the CREATEROLE privilege, since Vault will be issuing CREATE ROLE statements.

Open Secrets Engines → database → Create connection and fill in the plugin, connection URL, and the admin credentials Vault should use:

Vault UI: Edit Connection form for the database plugin, with the PostgreSQL connection URL and the administrative username

The connection URL follows the standard libpq format:

postgresql://{{username}}:{{password}}@<db-host>:15432/postgres?sslmode=disable

The {{username}} and {{password}} placeholders are filled from separate fields in the form; they are not part of the DSN you type literally. Leave sslmode=disable only for a trusted network — over anything else, use require or verify-full.

Checking Connection will be verified makes Vault test the connection immediately, which is worth doing: it turns a permissions problem into an error at configuration time instead of a failure the first time an app asks for credentials.

2. Create the role

A role is a template for what gets created. The role name is what applications request — here app1-rw — and the type is dynamic, meaning every request produces a fresh database user.

Vault UI: Edit Role form showing the role name app1-rw, the postgresql connection, dynamic role type, and TTL settings

The two TTL settings control the lifecycle:

  • Time-to-Live — how long a credential is valid before Vault revokes it. This is the number that bounds your exposure.
  • Max Time-to-Live — the ceiling on renewal. A client can extend its lease past the TTL, but never beyond this.

The screenshot shows both set to 1 minute, which is a test configuration. It is actually a good way to start, because a minute is short enough to watch the whole create-and-revoke cycle while you are wiring things up. Raise it to something like 1–24 hours once you have confirmed the flow works, and remember that the application needs to handle the credential expiring underneath it.

3. The SQL statements

This is where the role's behavior is actually defined. Vault substitutes {{name}}, {{password}}, and {{expiration}} into the statements you provide.

Vault UI: statement fields for creation, revocation, rollback, and renewal

Four statement types, each used at a different point in the lease lifecycle:

StatementWhen it runs
CreationWhen a client requests credentials
RevocationWhen the lease ends, is revoked, or Vault shuts down cleanly
RollbackWhen creation fails partway through, to clean up
RenewalWhen a client extends its lease

A working set for a read/write role:

-- creation
CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';
GRANT app_rw TO "{{name}}";

-- revocation
DROP ROLE IF EXISTS "{{name}}";

-- rollback
DROP ROLE IF EXISTS "{{name}}";

-- renewal
ALTER ROLE "{{name}}" VALID UNTIL '{{expiration}}';

A few details that matter:

  • Grant a role, not table privileges. The creation statement here adds the new user to an existing app_rw group role. Permissions are managed once on app_rw and every dynamic user inherits them. Issuing GRANT SELECT ON ... per user means editing the Vault role every time your schema changes.
  • VALID UNTIL is a second layer of enforcement. Vault revokes the role at expiry, but the database itself also refuses the login after {{expiration}}. If Vault is unreachable, the credential still cannot be used indefinitely.
  • Rollback is not optional. If a creation statement fails after the role was made but before the grant succeeded, the rollback statement is what removes the orphan. Leaving it empty accumulates stray roles over time.
  • Revocation must be idempotent. DROP ROLE IF EXISTS rather than plain DROP ROLE — Vault may retry, and a failure here leaves credentials alive after their lease ended.

4. Test it

Generate credentials from the Vault CLI:

vault read database/creds/app1-rw
Key      Value
------   ------------------------------------------------
username v-root-app1-rw-pR07X8aIrBhyx2hylf6W-1763201934
password <generated-password>

Note the shape of the username. It is not a fixed name with a rotating password — it is a unique name per lease, built from the role and a random suffix. That is what makes per-instance attribution possible in database logs, and it is why revoking one lease never affects another.

Minutes later, the same view in a database client shows the difference directly — here through Navicat, where several v-root-app1-rw-* roles from earlier test runs have appeared, each with its own expiry:

Navicat role list showing multiple v-root-app1-rw-* roles with individual expiry timestamps

That is the payoff. Every app instance that asked for credentials has its own database role and its own expiry, and none of them shares a password with any other.

Moving to production

  • Never disable VALID UNTIL. Dropping it from the creation statement makes credentials permanent, which defeats the entire design.
  • Give the connection's admin account the minimum it needs. It requires CREATEROLE and enough privilege to grant the target group role. It does not need superuser, and using one makes Vault itself the biggest credential in the system.
  • Separate roles per access level. One role granting read/write, a second granting read-only, rather than a single role that everything uses. This is the part that actually reduces risk — a compromised reporting job cannot write.
  • Plan for lease expiry in the application. The driver will lose its connection when the credential is revoked. Either renew the lease ahead of expiration, or re-read credentials and rebuild the connection pool. Ignoring this is the most common way a working Vault setup turns into an outage.
  • Watch the lease count. Every vault read creates a role. A client that re-requests credentials on every operation will flood the database with users; cache the credential for its lease duration instead.
  • Mind connection limits. PostgreSQL has a max_connections ceiling. Dynamic users each hold their own sessions, so a pool per instance per lease multiplies quickly.