Vault dynamic database credentials in Python
Vault’s database secrets engine issues a brand-new database user on every request, valid only for its lease, then deletes it. There is no static password to leak. This page consumes those credentials from Python, extending the HashiCorp Vault Python SDK guide.
The complication that makes this more than an API call is the connection pool. A pooled connection is authenticated once, when it is opened, and then reused for hours — so a pool built with a credential whose lease has since expired holds connections belonging to a database user that no longer exists. Whether those connections keep working, fail immediately, or fail on the next statement depends on the database, and none of the three is a good thing to discover during an incident.
Problem 1: a static DB password in config
# ANTI-PATTERN: one long-lived password, unbounded blast radius
DATABASE_URL = "postgresql://app:staticPassword@db/app" # leaks forever
A static credential is valid until someone notices and rotates it. The practical consequence is that rotation becomes an event rather than a routine: it requires coordinating a password change with a redeploy of every service using it, so it happens rarely, which in turn means the credential in circulation is usually old, widely known, and present in more places than anyone can enumerate.
There is a second cost that matters during an investigation. Every service and every person using the shared credential appears in the database’s logs as the same user, so “who ran this query?” has no answer beyond “something with the app password”. Dynamic credentials give each consumer a distinct, timestamped username, which turns that question into a lookup against the lease log rather than a round of guesswork.
Problem 2: ignoring the lease
# ANTI-PATTERN: uses dynamic creds but never renews the lease
creds = client.secrets.database.generate_credentials(name="app")
# ... the lease expires and the database user is deleted mid-connection
Dynamic credentials are temporary; the connection must be rebuilt before the lease ends. This is the failure that makes teams conclude dynamic credentials are impractical, and it is entirely a client-side omission — the engine did exactly what it promised, and the application treated a lease like a password.
The symptom is characteristic: the service runs correctly for exactly the lease duration after each deployment and then throws authentication errors on every query at once, since every pooled connection belongs to the same deleted user. Because a redeploy fixes it instantly, the natural response is to redeploy and move on, which resets the clock and hides the cause for another lease period.
Secure implementation
# db/vault_creds.py
import time
import hvac
from pydantic import SecretStr
from sqlalchemy import create_engine
class VaultDB:
def __init__(self, client: hvac.Client, role: str, host: str, db: str):
self._c, self._role, self._host, self._db = client, role, host, db
self._engine = None
self._renew_at = 0.0
def engine(self):
if self._engine is None or time.monotonic() > self._renew_at:
lease = self._c.secrets.database.generate_credentials(name=self._role)
d, ttl = lease["data"], lease["lease_duration"]
pw = SecretStr(d["password"]).get_secret_value()
self._engine = create_engine(
f"postgresql://{d['username']}:{pw}@{self._host}/{self._db}",
pool_pre_ping=True,
)
self._renew_at = time.monotonic() + ttl * 0.7 # rebuild before expiry
return self._engine
A fresh credential is generated and the engine rebuilt at 70% of the lease TTL, so connections always use a live, short-lived user. The password is handled as SecretStr.
pool_pre_ping=True is doing quiet but essential work. It makes SQLAlchemy test each pooled connection before handing it out, discarding any that the server has closed — which is exactly what happens to connections belonging to a dropped user. Without it, a stale connection is handed to application code and fails on the query, turning a recoverable pool problem into a user-visible error.
The old engine is replaced rather than closed, which is deliberate. Calling dispose() immediately would sever connections that are still executing queries; letting the old engine fall out of reference means its pool drains naturally as those queries finish and Python collects it. If you want the resources released promptly, keep a reference and dispose it after a delay comfortably longer than the slowest expected query.
There is one race this design does not eliminate: a request that grabs a connection just before the rebuild point and runs a slow query past the lease expiry. It is rare and it is real, so the layer above should catch an authentication error, ask for a fresh engine, and retry once. That is the same “retry exactly once” rule that applies to every lease-based credential — a second failure means something other than expiry is wrong.
The database role is where least privilege lives
The Python side controls lifetime; the Vault database role controls power. The role holds the SQL statements Vault runs to create each user, and whatever those statements grant is what a leaked dynamic credential can do for the length of its lease.
# a read-mostly role: the creation statements are the privilege boundary
vault write database/roles/reports \
db_name=appdb \
default_ttl=1h \
max_ttl=4h \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
GRANT USAGE ON SCHEMA public TO \"{{name}}\"; \
GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";"
Two details in those statements matter beyond the grants. VALID UNTIL '' makes the database itself enforce the expiry, so even if Vault is unreachable when the lease ends and the revocation never runs, the credential stops working on schedule — a second, independent expiry mechanism. And granting only SELECT means this role produces credentials that cannot write, regardless of what the application code attempts.
Splitting roles by access pattern is where the model earns most. A reporting job gets reports; the API gets a role with INSERT, UPDATE, and DELETE on the tables it owns; a migration job gets a role with DDL rights and a very short TTL. Each service requests its own role, so the credential a compromised reporting job holds cannot modify anything, and the credential the API holds cannot drop tables.
The failure mode to watch is grant drift on new tables. GRANT SELECT ON ALL TABLES applies to tables that exist when the statement runs, not to tables created later — so a migration that adds a table leaves the reporting role unable to read it, in a way that appears as an application bug. ALTER DEFAULT PRIVILEGES on the owning role fixes this properly, and it is worth setting up once rather than rediscovering it after each schema change. The tell is a reporting query that fails with a permission error on exactly one table — the newest one — while everything else continues to work normally.
Rebuilding without dropping requests
The engine factory above swaps engines at a point in time, which is correct but leaves one question open: what should a request do if it arrives during the swap, or if it holds a connection whose user has just been deleted? Answering it explicitly is what makes the difference between a service that degrades gracefully and one that returns a burst of 500s once per lease.
# db/session.py — one retry on an authentication failure
from sqlalchemy.exc import OperationalError
def run(vault_db: VaultDB, statement, params=None):
for attempt in (1, 2):
engine = vault_db.engine()
try:
with engine.connect() as conn:
return conn.execute(statement, params or {})
except OperationalError as exc:
if attempt == 2 or not _is_auth_failure(exc):
raise
vault_db.invalidate() # force a fresh lease on the next engine() call
def _is_auth_failure(exc: OperationalError) -> bool:
# Postgres: 28P01 invalid_password, 28000 invalid_authorization_specification
return getattr(exc.orig, "sqlstate", None) in {"28P01", "28000"}
The _is_auth_failure check is what keeps this from being a blanket retry. Retrying every OperationalError would silently repeat writes after a network blip, which is a correctness problem rather than a resilience feature; narrowing to the two authentication SQLSTATEs means the retry only fires for the one condition it was written for. On MySQL the equivalent is error 1045, and it is worth writing the check against the driver’s numeric code rather than matching on the message text, which is localised and version-dependent.
invalidate() is a small addition to VaultDB — set self._renew_at = 0.0 so the next engine() call fetches a fresh lease. Splitting it out means the retry path and the scheduled rebuild path share one implementation, so a fix to either applies to both.
Observability matters more here than usual, because a working retry is invisible. Counting how often the retry fires tells you whether the rebuild fraction is right: near-zero is healthy, a steady trickle means 70% is too late for your query durations, and a spike means something other than expiry is failing authentication. That single counter is worth more than any amount of guessing about the correct TTL.
Gotchas & version-specific behaviour
- Read
lease_durationfrom the response; do not hard-code the TTL. - Rebuild the engine before the lease ends (e.g. at 70% TTL) — an expired user disappears from the database.
pool_pre_ping=Truedrops connections whose user was revoked.- The Vault DB role defines the SQL grants — scope it to least privilege.
- Postgres refuses to drop a role that still owns objects, so a dynamic user that creates a table blocks its own revocation; keep DDL out of long-lived roles.
- Every lease consumes a database connection slot until it is revoked; a short TTL with many services can exhaust
max_connectionsfaster than expected.
The connection-slot point is worth sizing before it bites. Each live lease implies a user that may hold pooled connections, so the peak is roughly the number of service instances multiplied by their pool size, plus whatever the previous generation of credentials still holds while draining. Overlapping generations during a rebuild means budgeting for two pools per instance for a short window, and a fleet that scales out under load can double that again at exactly the wrong moment.
Two adjustments keep that under control. Lower the per-instance pool size, since dynamic credentials make a large pool per instance less useful than it looks — connections are cheap to re-establish and expensive to hold across a rebuild. And put a connection pooler such as PgBouncer in front of the database if the fleet is large, accepting that a pooler in transaction mode changes what session state you can rely on. Either way, size the database’s max_connections against the peak with two overlapping generations rather than the steady state.
Production parity checklist
- The app uses dynamic credentials, not a static DB password.
- The engine is rebuilt before the lease expires.
- The connection pool drops revoked connections.
- The Vault database role is least-privilege.
- Passwords are
SecretStr-wrapped and never logged. VALID UNTIL ''is present so the database enforces expiry independently.
The last item is the cheapest resilience in this whole design. Vault revokes credentials by connecting to the database and dropping the user, which requires Vault to be running and able to reach the database at the moment the lease ends. VALID UNTIL moves the guarantee into the database itself, so a Vault outage during a lease expiry cannot leave a live credential behind. It costs one clause in the creation statement.
Rolling this out to an existing service does not have to be a single risky change. Point one low-traffic consumer — a reporting job, a scheduled task — at a dynamic role first, with a generous TTL, and watch the retry counter and the database’s connection count for a few days. Shortening the TTL afterwards is a one-line change to the role, and by then you have real numbers for how the pool behaves across a rebuild rather than an estimate.
Key takeaways
Vault’s database engine plus a lease-aware engine factory means every database connection uses a short-lived user that cannot outlive its lease. Two properties carry the design: rebuild the engine on a fraction of the lease rather than at expiry, so the pool is always backed by a live user, and put least privilege in the role’s creation statements, so a leaked credential is limited in power as well as in time. Add VALID UNTIL so the database enforces the deadline independently of Vault’s availability, and retry once on an authentication error to cover the slow query that outlives its lease. Authenticate the client first with Vault AppRole Auth in Python.