HashiCorp Vault Python SDK
A static database password is a credential with an unbounded blast radius — one leak compromises the system until someone notices. HashiCorp Vault can mint a credential that lives for an hour, scoped to a single role, then revokes it automatically. This page authenticates to Vault with hvac and consumes dynamic, short-lived secrets in Python.
Vault is the cloud-agnostic option in the enterprise secrets section, and its dynamic credentials are the strongest form of the rotation discipline described in automated rotation patterns.
Why dynamic beats stored
The distinction that makes Vault worth the operational weight is not that it stores secrets more safely than a file — plenty of things do that. It is that for the credentials most worth protecting, Vault stores nothing at all. A dynamic secrets engine holds the authority to create a database user and the policy describing what such a user may do; the credential itself is created when asked for and destroyed when the lease ends.
That changes what a leak means. A stored credential that leaks stays valid until a human notices and rotates it, which is a window measured in days at best and quarters at worst. A dynamic credential that leaks stays valid until its TTL expires, which is a window you chose in advance — an hour, fifteen minutes, the length of a batch job. The attacker’s advantage shrinks from “until discovered” to “until the clock runs out”, and discovery is no longer on the critical path.
It also changes what an audit log can tell you. Because every credential is issued to a named role at a known time, the log answers “which service had access to this database at 03:00 on Tuesday?” precisely, rather than “everyone who has ever had the shared password”. That is often the more valuable property in practice, because it turns an incident investigation from an exercise in speculation into a query.
Secure implementation
# secrets/vault.py
import hvac
from pydantic import SecretStr
def vault_db_credentials(role: str, secret_id: SecretStr) -> dict[str, SecretStr]:
client = hvac.Client(url="https://vault.internal:8200")
client.auth.approle.login(
role_id="app-role-id", # non-secret, shipped with the app
secret_id=secret_id.get_secret_value(), # delivered at runtime, never in git
)
if not client.is_authenticated():
raise SystemExit("Vault authentication failed")
lease = client.secrets.database.generate_credentials(name=role)
data = lease["data"]
return { # valid only for the lease TTL
"username": SecretStr(data["username"]),
"password": SecretStr(data["password"]),
}
The role_id is not a secret and can ship with the application; the secret_id arrives at runtime. The returned credential is dynamic — Vault revokes it when the lease ends. The split between the two halves of AppRole is the design’s central idea: knowing the role_id alone proves nothing, and the secret_id is short-lived, single-use where configured, and never present in anything that gets committed or baked into an image.
raise SystemExit on a failed authentication is deliberate rather than defensive noise. A service that cannot authenticate to Vault cannot obtain a database credential, so continuing means starting a process that will fail on its first query with a much less informative error. Failing at startup, before the health check reports ready, turns an authentication problem into a failed rollout — the same fail-fast principle that governs configuration validation.
Wrapping both returned values in SecretStr matters more here than for a static credential, counterintuitive as that sounds. Dynamic credentials are fetched at runtime and passed around in memory, so they end up in more places than a value read once from the environment — connection factories, retry wrappers, pool configuration — and every one of those is a candidate for a debug log. SecretStr means the value only appears where someone explicitly called get_secret_value().
Configuration reference
| Element | Type | Notes | Security implication |
|---|---|---|---|
role_id |
string | Non-secret identifier | Safe to bake into config |
secret_id |
SecretStr |
Runtime-delivered | Never commit; short-lived |
| dynamic lease | TTL-scoped | Auto-revoked | Minimal blast radius |
| KV v2 read | versioned | read_secret_version |
Pin versions for audit |
| token TTL | seconds | Renew before expiry | Avoid mid-request failures |
The KV v2 row is where most teams start, because not everything can be dynamic. A third-party API key issued by a vendor cannot be minted on demand — it exists because someone signed up for a service — so it lives in the KV store as static data. Vault still adds value there: versioning, access policies, an audit trail of every read, and a single place to update the value when the vendor rotates it. What it cannot add is automatic expiry, which is why the general rule is dynamic where the engine supports it, KV where it does not.
Version pinning on KV reads deserves a note. read_secret_version without a version argument returns the latest, which means a value updated in Vault takes effect on the next read with no deployment involved. That is usually what you want for a rotated credential and occasionally alarming for anything else — a configuration value changed in Vault propagates to every process the next time it reads. If a value needs to change only with a deploy, pin the version and treat bumping it as a code change.
A client that survives expiry
The single-shot function above is correct and incomplete: it fetches a credential once. A real service runs for days, and every credential it holds has an expiry date it did not choose. The client that survives contact with production is one that treats “this credential no longer works” as an ordinary, expected event rather than an error.
# secrets/vault_client.py
import time
import hvac
from pydantic import SecretStr
class VaultCredentials:
"""Holds a dynamic credential and refreshes it before the lease expires."""
REFRESH_AT = 0.5 # refresh once half the TTL has elapsed
def __init__(self, url: str, role_id: str, secret_id: SecretStr, db_role: str):
self._client = hvac.Client(url=url)
self._role_id, self._secret_id = role_id, secret_id
self._db_role = db_role
self._username = self._password = None
self._expires_at = 0.0
def _login(self) -> None:
self._client.auth.approle.login(
role_id=self._role_id,
secret_id=self._secret_id.get_secret_value(),
)
if not self._client.is_authenticated():
raise RuntimeError("vault: authentication failed")
def _fetch(self) -> None:
if not self._client.is_authenticated():
self._login() # token may have hit its max TTL
lease = self._client.secrets.database.generate_credentials(name=self._db_role)
data = lease["data"]
self._username = SecretStr(data["username"])
self._password = SecretStr(data["password"])
self._expires_at = time.monotonic() + lease["lease_duration"] * self.REFRESH_AT
def current(self) -> tuple[SecretStr, SecretStr]:
if self._username is None or time.monotonic() >= self._expires_at:
self._fetch()
return self._username, self._password
Three decisions in that class are worth stating explicitly. Refreshing at half the TTL rather than at expiry leaves room for a failed attempt and a retry before anything breaks — a credential that refreshes at 99% of its lifetime has no margin for a momentarily unreachable Vault. Using time.monotonic() rather than wall-clock time means an NTP correction or a clock jump cannot make a valid credential look expired or an expired one look valid. And re-authenticating inside _fetch when the client is no longer authenticated handles the token max-TTL case without a separate code path.
What the class deliberately does not do is renew leases. Renewal is the right tool when the credential is expensive to obtain or when the connection using it cannot be re-established cheaply; for a database credential, fetching a fresh one is simpler, has no max-TTL ceiling to reason about, and exercises the same code path every time rather than a rarely-tested renewal branch. Long-running workers holding a connection open across the lease boundary are the case where renewal genuinely wins, and that case is covered separately in renewing leases in long-running workers.
The remaining piece is what happens when a credential expires anyway — because it will, eventually, on the request that arrives just as the lease ends. The connection layer needs to catch an authentication failure, ask the credential holder for a fresh value, and retry once. Retrying more than once is a mistake: a second authentication failure means something is genuinely wrong, and a retry loop against a failing Vault turns one service’s problem into everyone’s, because every instance of every service retries at once and the recovering Vault is hit hardest exactly when it can least afford it.
Deployment parity: local to production
- Local dev — developers use a dev-mode Vault or a short-lived token against a non-prod mount.
- CI — the pipeline authenticates with a scoped AppRole and tears down its lease at job end.
- Staging/Production — the orchestrator injects the
secret_id; the app obtains dynamic database credentials per process and renews leases.
The parity property to protect is that the code path is identical in all three: authenticate, fetch, use, renew. What differs is the Vault address, the mount, and how the secret_id arrives. A service that branches on an environment name to decide whether to talk to Vault at all ends up with a production path nobody exercises until deployment, which is the same anti-pattern as branching on ENV for configuration.
CI deserves particular care because it is the environment most likely to accumulate a long-lived credential quietly. A pipeline that authenticates once with a generous AppRole and uses that token for every job is convenient and defeats the model — the token is effectively a static credential that happens to live in the CI platform. The arrangement that holds is a narrowly scoped role per pipeline, a secret_id issued for that run, and an explicit revoke at the end of the job so nothing outlives the run even if the TTL is generous. Vault’s sys.revoke_self on the token is one line and turns “expires eventually” into “gone now”.
Local development is where this most often breaks down, because running a full Vault feels heavy. A dev-mode server started with vault server -dev is a single command, holds everything in memory, and is more than enough to exercise the real code path — it is also unauthenticated-by-default, unsealed automatically, and must never be reachable from anywhere but the developer’s own machine — bind it to localhost and treat any dev-mode Vault on a shared network as an open credential store. The alternative worth avoiding is a if not settings.vault_enabled: return static_credentials() branch, which quietly means the Vault integration is only ever tested in production.
Delivering the secret_id without a chicken-and-egg problem
AppRole moves the problem rather than eliminating it: the application no longer holds a database password, but it does need a secret_id, and something has to deliver that. Getting this step wrong undoes most of the benefit, because a long-lived secret_id sitting in a deployment manifest is functionally a permanent credential with extra steps.
There are three deliveries that hold up, in rough order of preference. The best is to skip AppRole entirely where the platform supports a native identity: Kubernetes auth exchanges a pod’s service account token for a Vault token, and cloud auth methods do the same with instance identity. In both cases the “credential” is something the platform already issues and rotates, so there is nothing to deliver and nothing to leak.
Where AppRole is the right method, response wrapping is the mechanism that makes delivery safe. A trusted orchestrator asks Vault for a wrapped secret_id; Vault returns a single-use wrapping token with a short TTL; the orchestrator hands that token to the application, which unwraps it to obtain the real secret_id. If anyone intercepted the token in transit, the application’s unwrap fails — and that failure is itself the tamper alarm, because a wrapping token can only be used once.
# secrets/unwrap.py — the app receives a wrapping token, not the secret_id
import hvac
from pydantic import SecretStr
def unwrap_secret_id(url: str, wrapping_token: SecretStr) -> SecretStr:
client = hvac.Client(url=url, token=wrapping_token.get_secret_value())
result = client.sys.unwrap() # single use — a second call fails
return SecretStr(result["data"]["secret_id"])
The third option — a secret_id injected directly as an environment variable, with a short TTL and a low use limit — is acceptable when the first two are unavailable, provided the TTL is genuinely short and the value is re-issued per deployment rather than reused. What is not acceptable is a secret_id with no TTL committed to a manifest repository, which is the shape this whole mechanism exists to avoid.
Whichever delivery you choose, the property to preserve is that nothing long-lived is written down anywhere an attacker could reach without already having compromised the platform. If your answer to “what would an attacker need to steal to get persistent access?” is “the contents of one file”, the delivery step needs revisiting.
Security boundaries & guardrails
- Deliver
secret_idat runtime; never store it in the image or repo. - Prefer dynamic secrets engines over static KV for databases and cloud credentials.
- Wrap all returned credentials in
SecretStr. - Renew or re-fetch before the lease expires; never assume a credential is still valid.
- Scope each AppRole to the minimum policies it needs.
- Enable and ship the audit device — an unaudited Vault answers “who read this?” with silence.
The least-privilege item is the one that decays. An AppRole starts scoped to exactly what a service needs, then someone adds a feature, hits a Forbidden, and widens the policy to unblock the deploy — reasonably, under time pressure, with the intention of tightening it later. The tightening rarely happens, and after a year the role can read half the secret tree. Reviewing policies on a schedule, and keeping them in version control next to the service rather than only in Vault’s configuration, are what make the drift visible. A policy defined in a file that a reviewer sees in a pull request is a policy somebody will question; a policy widened through a web console at midnight is one nobody will ever look at again. Treating Vault configuration as code — policies, roles, and mount definitions in the repository, applied by the pipeline — puts the same review pressure on access grants that already applies to application changes.
The audit device deserves its place on the list because it is the control that makes every other one verifiable. Vault’s audit log records each request with the authenticated identity, the path, and the outcome, with secret values hashed rather than recorded. Without it, a compromised secret_id leaves no trace at all, and questions like “did this role ever read the production database credentials?” have no answer. Ship it to somewhere append-only, and treat a Vault that has stopped writing audit logs as an outage — that is the intended behaviour, since Vault blocks requests rather than serving them unaudited.
Vault’s own availability becomes part of your service’s availability once you depend on it, and that deserves a deliberate decision rather than a discovery during an outage. A service that fetches a dynamic credential on every request fails entirely when Vault is unreachable; a service that caches a credential for its lease duration keeps working until the lease ends, which is usually long enough for a Vault restart to complete. That is a strong argument for the caching client above and against fetching per request — the cache is not only a performance optimisation, it is the thing that decouples your uptime from Vault’s.
The corollary is that TTLs are an availability decision as much as a security one. A fifteen-minute lease is excellent for blast radius and gives you fifteen minutes of tolerance for a Vault outage; a twelve-hour lease inverts both. Choosing them per credential rather than globally lets a rarely-used administrative role be short-lived while a high-traffic database role has enough headroom to ride out maintenance.
Testing code that talks to Vault
Vault-backed code has a reputation for being untestable, which is mostly a consequence of reaching for hvac.Client directly inside business logic. Push the client behind a small interface — current() returning a username and password, as above — and the tests write themselves.
# tests/test_vault_credentials.py
import time
import pytest
from secrets.vault_client import VaultCredentials
class FakeVault:
"""Minimal stand-in: counts fetches, returns predictable credentials."""
def __init__(self, ttl=60):
self.fetches, self.ttl = 0, ttl
def generate(self):
self.fetches += 1
return {"data": {"username": f"u{self.fetches}", "password": "p"},
"lease_duration": self.ttl}
def test_credential_is_cached_until_halfway(monkeypatch):
creds, fake = _wire(FakeVault())
creds.current(); creds.current()
assert fake.fetches == 1 # second call reuses the cached credential
def test_credential_refreshes_after_half_the_lease(monkeypatch):
creds, fake = _wire(FakeVault(ttl=60))
creds.current()
monkeypatch.setattr(time, "monotonic", lambda: time.monotonic() + 31)
creds.current()
assert fake.fetches == 2 # refreshed once past the halfway point
Those two tests pin the behaviour that actually matters and that a manual check will never catch: the credential is reused while fresh, and it is replaced before it expires. Neither test needs a running Vault, both run in milliseconds, and both fail loudly if someone changes REFRESH_AT to 1.0 in the belief that refreshing at expiry is more efficient.
For the layer above — the connection code that catches an authentication failure and retries — a fake that fails once and then succeeds is enough to assert that exactly one retry happens and that the second attempt uses a different credential. That is the behaviour with the most production value and the least chance of being exercised by accident during development, since it only occurs when a lease expires at precisely the wrong moment.
Integration tests against a real Vault still have a place, and a dev-mode server in a container makes them cheap. Keep them few and focused on the things a fake cannot verify: that the policy actually grants the paths the service reads, and that the database engine’s role produces a credential the database accepts. Those two assertions catch configuration mistakes that no amount of unit testing can, because they live in Vault’s configuration rather than in the application’s code.
Troubleshooting
InvalidRequest: failed to validate SecretID— thesecret_idexpired or was already used; request a fresh one. See Vault AppRole Auth in Python.- Credential rejected by the database — the lease expired; re-fetch and renew earlier next time.
Forbiddenon a path — the AppRole policy does not grant that mount; widen the policy minimally.- Token not authenticated — clock skew or wrong
url; verify TLS and the Vault address. - Everything works, then fails after exactly the same interval — you are hitting the token’s max TTL, which renewal cannot extend; re-authenticate rather than renew.
That last symptom is worth recognising by its shape, because it looks like a renewal bug and is not. Vault tokens have both a TTL, which renewal extends, and a max TTL, which it cannot — once the max is reached the token dies regardless of how diligently you renewed it. A long-running worker that renews perfectly and still fails at, say, exactly 32 days is hitting the max TTL, and the fix is to detect the failure and log in again rather than to renew more often.
The Forbidden symptom has a diagnostic worth knowing. Vault’s sys.read_token_capabilities reports exactly which capabilities the current token has on a given path, so instead of guessing at the policy you can ask directly: does this token have read on database/creds/app? Running that check at startup and logging the answer turns a class of confusing runtime failures into a startup message, and it makes a policy that was quietly narrowed during an unrelated change visible immediately rather than at the next deploy that happens to need the path.
Frequently asked questions
Which Vault auth method should a Python service use?
AppRole is the standard for machine authentication. The role_id is non-secret and shipped with the app; the secret_id is delivered at runtime by a trusted orchestrator, keeping a long-lived token out of the image.
What are Vault dynamic secrets?
Vault generates a credential on demand — for example a database username and password that exists only for the lease TTL, then is automatically revoked. Nothing static is stored, so a leak expires on its own.
How do I keep a Vault lease from expiring mid-request?
Renew the lease before it expires using sys.renew_lease, or re-fetch when the remaining TTL drops below a threshold. Treat the credential as ephemeral and always be ready to obtain a fresh one.
Key takeaways
The invariant: authenticate with AppRole, prefer dynamic short-lived credentials, wrap them in SecretStr, and renew before expiry. A leaked Vault credential should expire on its own within the hour, which is what turns a credential leak from a full-scale incident into something much closer to a non-event.
The mental shift Vault asks for is treating credentials as leases rather than values. A value is fetched once at startup and assumed good; a lease has a remaining lifetime, must be renewed or replaced, and will eventually be taken away whatever the application thinks. Code written for values fails intermittently against a lease-based system — usually at 3 a.m., usually after the shortest TTL in the stack. Code written for leases handles a revoked credential as an ordinary event: notice, re-fetch, retry, continue. Get that right in one client module that every service imports, and everything else on this page becomes configuration.
If you are adopting Vault incrementally, the order that produces value soonest is to start with the credentials that hurt most when leaked and that Vault can mint itself — database users, cloud access keys — and leave the vendor API keys in KV for later. The dynamic engines are where the security improvement is dramatic rather than incremental, because they remove a stored credential entirely instead of relocating it. Moving a static key from an environment variable into KV is worth doing eventually for the audit trail and the single source of truth, but it does not change what an attacker gets if they read the value, and it is easy to mistake that reorganisation for the security win that dynamic credentials actually deliver.