A small Python library that fetches HashiCorp Vault credentials at startup and injects them into your application config by reference. It supports running in Kubernetes (JWT login) and locally (OIDC SSO in the browser), with environment detected automatically.
- Config injection: Pass a mutable config (
dataclassinstance,dict, nestedlist/dict, or DynaconfDynaconf(...)/LazySettings); any string value exactly equal tovault:path#keyis replaced with the secret from Vault (in place). Secret values may be JSON-typed in KV (numbers, booleans, lists, objects,null), not only strings. - Lazy init: The library does not contact Vault until at least one
vault:path#keyplaceholder is present. If there are no placeholders,inject_secretsreturns immediately and provider config is unused—no enable flag needed; you can always callnew+inject_secrets. - Optional config: All provider settings have defaults; you can pass
Noneor only override what you need. - Dual run mode: In-cluster uses Kubernetes auth; on a developer machine uses OIDC (browser). No extra configuration required.
pip install vault-provider-py
# optional file loaders for examples in settings.example.* :
# pip install "vault-provider-py[yaml]" # PyYAML
# pip install "vault-provider-py[toml]" # tomli on Python 3.10 (3.11+ has stdlib tomllib)
# pip install "vault-provider-py[dynaconf]" # if you use Dynaconf (often redundant; app already depends on it)Requires Python 3.10+, hvac, and httpx (OIDC callback / auth URL requests).
from dataclasses import dataclass
from vault_provider import new
@dataclass
class AppConfig:
api_key: str
db_conn_str: str
def main() -> None:
# None uses all defaults (see Defaults below).
vp = new(None)
cfg = AppConfig(
api_key="vault:app/data/myapp/credentials#api_key",
db_conn_str="vault:app/data/myapp/credentials#database_url",
)
vp.inject_secrets(cfg)
# cfg.api_key and cfg.db_conn_str now hold the real secret values.Any string field (including inside nested dataclasses, lists, or dicts) whose value is exactly:
vault:<path>#<key>
is replaced with the secret at that Vault path and key. The library walks your config and only mutates strings that match this pattern.
- path: Vault KV path (e.g.
app/data/myapp/credentials). - key: Key name at that path (e.g.
api_key).
Both KV v1 and KV v2 engines are supported (v2 nesting under data is handled automatically).
The value stored in Vault for key may be any JSON type (as in Vault’s JSON editor): strings, numbers, booleans, null, arrays, and objects (with string keys). After injection, your config field holds the corresponding Python value (str, int, float, bool, None, list, dict). Values that are not JSON-like (e.g. unexpected custom objects) are rejected with TypeError.
If a value is stored as a JSON string (e.g. the string "42"), it remains a Python str; the library does not coerce it to a number.
You can pass the object returned by Dynaconf(...) directly to inject_secrets:
from dynaconf import Dynaconf
from vault_provider import new
settings = Dynaconf(settings_files=["settings.toml"])
vp = new(None)
vp.inject_secrets(settings)- Top-level keys are taken from
settings.as_dict(internal=False)so Dynaconf’s own defaults (*_FOR_DYNACONF, etc.) are not scanned. - Top-level string placeholders are updated with
settings.set(key, value). - Nested
DynaBoxobjects behave likedictand are updated in place. - Lists (including Dynaconf’s
BoxList) are copied to a plainlist, injected, then written back (settings.setat the top level, or reassignment on the parentDynaBox) becauseBoxList[i] = ...does not persist into the settings store.
Dynaconf is an optional dependency: it is loaded only if installed. Use pip install "vault-provider-py[dynaconf]" if you want that extra pinned alongside this library.
Avoid setting top-level keys whose names shadow dict APIs on DynaBox (e.g. items, keys)—prefer settings["ITEMS"] / a different key name, or use bracket access when reading.
new(cfg) only applies defaults and never contacts Vault. The client is created and auth runs on the first inject_secrets call that finds at least one placeholder. If your config has no placeholders, inject_secrets returns without touching Vault, so provider config does not matter in that case. Auth errors (e.g. unreachable Vault, bad credentials) are raised from inject_secrets, not from new.
You can pass a partial or full config. Empty fields are filled from defaults.
from vault_provider import Config, new
# Use defaults for everything.
vp = new(None)
# Override only Vault address and role.
vp = new(Config(vault_addr="https://vault.example.com", role_name="my-role"))
# Load from your own config file (e.g. YAML with PyYAML).
import yaml
with open("provider.yml") as f:
raw = yaml.safe_load(f)
provider_cfg = Config(
vault_addr=raw.get("vault_addr") or "",
role_name=raw.get("role_name") or "",
auth_path=raw.get("auth_path") or "",
)
vp = new(provider_cfg)
# Or TOML (stdlib tomllib on Python 3.11+; on 3.10 use: pip install tomli, then import tomli as tomllib)
try:
import tomllib
except ModuleNotFoundError: # Python < 3.11
import tomli as tomllib
with open("provider.toml", "rb") as f:
raw = tomllib.load(f)
provider_cfg = Config(
vault_addr=raw.get("vault_addr") or "",
role_name=raw.get("role_name") or "",
auth_path=raw.get("auth_path") or "",
)
vp = new(provider_cfg)See settings.example.yml and settings.example.toml for sample layouts (same keys in both formats).
| Field | YAML / TOML key | Description |
|---|---|---|
vault_addr |
vault_addr |
Vault server URL. |
role_name |
role_name |
Auth role name (e.g. Kubernetes role or OIDC). |
auth_path |
auth_path |
Auth method mount path (see Run modes). |
When a field is not set (or config is None), the library uses:
| Field | Default |
|---|---|
vault_addr |
http://127.0.0.1:8200 |
role_name |
default |
auth_path |
Local: oidc — Kubernetes: kubernetes (chosen automatically) |
No environment variables are used; only the config (or these defaults) drive behaviour.
- Kubernetes: If the process runs inside a pod (detected via
/var/run/secrets/kubernetes.io/serviceaccount/token), the library uses Kubernetes auth (JWT from the service account). No browser; no local token file. - Local: Otherwise it uses OIDC auth: it opens a browser for SSO, then stores the token in
~/.vault-tokenand reuses it on the next run.
Same code and config work in both environments; override auth_path only if your Vault uses a non-default mount (e.g. oidc-dev or kubernetes-staging).
.
├── src/vault_provider/
│ ├── config.py # Config, defaults, apply_defaults
│ ├── client.py # new(), VaultProvider, lazy ensure_client, read_secret_at
│ ├── inject.py # inject_secrets walk, placeholder parsing
│ ├── auth_local.py # OIDC flow (browser, ~/.vault-token)
│ └── auth_k8s.py # Kubernetes JWT login
├── tests/
│ ├── test_client.py # Unit tests (mock Vault, defaults, inject)
│ ├── test_dynaconf.py # Dynaconf LazySettings injection
│ └── test_integration.py # Real Vault (RUN_VAULT_INTEGRATION=1)
├── settings.example.yml
├── settings.example.toml
├── pyproject.toml
├── LICENSE
└── README.md
- Code style: Use Ruff (
ruff check src tests) or your formatter of choice; keep the public API small:Config,new,VaultProvider.inject_secrets,apply_defaults. - Tests: Run unit tests with
pytest tests/test_client.py tests/test_dynaconf.py. Mocks usepytest-httpserverso CI does not need Vault. - Integration tests: Set
RUN_VAULT_INTEGRATION=1and runpytest tests/test_integration.py -vwhen changing auth or injection. - Defaults: Changing defaults affects all users; document any change in the README and consider backwards compatibility.
- Auth flow: Do not alter the existing auth branching in
client.py(is_kubernetes()→authenticate_kubernetes/authenticate_local) without updating this README and the run modes section.
This project is licensed under the Apache License 2.0. See LICENSE for the full text.