Load Secrets from Azure Key Vault in Python

Reading a secret from Key Vault is three lines with the Azure SDK. Doing it so the application fails cleanly when a permission is missing, does not exhaust the vault’s request allowance under load, and never holds a credential in order to read credentials takes a little more thought. This page covers the shape that works in production, extending the Azure and Google secret stores topic.

Problem 1: a client secret used to read secrets

# ANTI-PATTERN: a credential in configuration, in order to read configuration
credential = ClientSecretCredential(
    tenant_id=os.environ["AZURE_TENANT_ID"],
    client_id=os.environ["AZURE_CLIENT_ID"],
    client_secret=os.environ["AZURE_CLIENT_SECRET"],   # who rotates this one?
)

This works, and it recreates the problem the vault was adopted to solve. The client secret is now a long-lived credential distributed to every environment, held in the same place your other secrets used to be, and rotating it is exactly as awkward as rotating anything else — with the added property that getting it wrong locks the application out of every other secret at once.

A managed identity removes it entirely. The platform assigns an identity to the host, the SDK obtains a token for that identity, and there is nothing in your configuration to rotate or leak. DefaultAzureCredential resolves it automatically in deployed environments and falls back to a developer’s signed-in session locally, so the same code path works everywhere.

Stored client secret versus platform-assigned identity A client secret is a long-lived credential that must itself be distributed and rotated, while a managed identity is assigned by the platform and requires nothing in the application's configuration. client secret a secret you must distribute and rotate it can read every other secret managed identity assigned by the platform, nothing stored revoked by removing an assignment the bootstrapping problem disappears rather than moving
A stored credential to read stored credentials is a loop worth breaking rather than managing.

Problem 2: reading a secret on the request path

# ANTI-PATTERN: a vault round trip per request
@app.get("/charge")
def charge():
    token = client.get_secret("payment-api-token").value    # network call, billed, throttled
    ...

Key Vault applies request limits per vault and per operation type, and those limits are low enough that a moderately busy service reading one secret per request will be throttled. The application also inherits the vault’s availability and its latency on every request, which is a poor trade for a value that changes a few times a year.

Fetch at startup into the validated settings model, and refresh on a timer if values rotate while the process runs. That reduces vault traffic by orders of magnitude, keeps the vault out of the request path entirely, and means a failure to reach it stops a deploy rather than degrading live traffic.

Problem 3: unclear failures from the credential chain

# ANTI-PATTERN: whichever identity happens to resolve first
credential = DefaultAzureCredential()      # which one did it pick? nothing says

The chain tries environment variables, managed identity, developer tooling and several other sources in order, taking the first that produces a token. That flexibility is convenient and makes failures confusing: a developer with a stale AZURE_CLIENT_ID in their shell gets an identity they did not intend, and the resulting Forbidden from the vault says nothing about which principal was refused.

Two habits fix it. Log the resolved credential type at startup, so the answer is in the first ten lines of any container log. And where ambiguity would be costly — production, or a shared environment — construct the specific credential explicitly rather than relying on the chain, keeping DefaultAzureCredential for local development where its flexibility is the point.

Secure implementation

# azure_secrets.py — one fetch, at startup, with the identity made visible
import logging
from azure.core.exceptions import ResourceNotFoundError, HttpResponseError
from azure.identity import DefaultAzureCredential, ManagedIdentityCredential
from azure.keyvault.secrets import SecretClient

logger = logging.getLogger(__name__)


class SecretUnavailable(RuntimeError):
    pass


def build_client(vault_url: str, *, in_azure: bool) -> SecretClient:
    # 1. explicit in deployed environments, chain-based only for local development
    credential = ManagedIdentityCredential() if in_azure else DefaultAzureCredential()
    logger.info("key vault credential: %s", type(credential).__name__)   # 2. no values, just the type
    return SecretClient(vault_url=vault_url, credential=credential)


def fetch(client: SecretClient, names: dict[str, str]) -> dict[str, str]:
    """Map {vault secret name: model field name} to {field name: value}."""
    values: dict[str, str] = {}
    for vault_name, field in names.items():
        try:
            # 3. no version argument: take the current enabled value
            values[field] = client.get_secret(vault_name).value.strip()   # 4. strip: newlines are common
        except ResourceNotFoundError as exc:
            # 5. our error names the missing secret; the SDK's names an HTTP status
            raise SecretUnavailable(f"secret '{vault_name}' not found in vault") from None
        except HttpResponseError as exc:
            raise SecretUnavailable(f"cannot read '{vault_name}': {exc.status_code}") from None
    return values
# config.py — the model is unaware that Azure exists
from pydantic import SecretStr, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict

SECRET_NAMES = {                       # vault name -> field name
    "app-database-url": "database_url",
    "app-api-token": "api_token",
}


class Settings(BaseSettings):
    model_config = SettingsConfigDict(extra="forbid")
    environment: str = "local"
    database_url: PostgresDsn
    api_token: SecretStr


def load(vault_url: str | None) -> Settings:
    if vault_url is None:              # 6. locally the model falls back to env vars and .env
        return Settings()
    client = build_client(vault_url, in_azure=True)
    return Settings(**fetch(client, SECRET_NAMES))    # 7. init values outrank the environment

The SECRET_NAMES mapping is the piece that pays for itself repeatedly. Vault names have their own constraints — letters, digits and dashes — which do not match Python field names, and keeping an explicit dictionary rather than deriving one means renaming a secret in the vault is a one-line change. It also serves as the workload’s credential inventory, which is the document you want in front of you during a rotation.

Wrapping the SDK’s exceptions matters more here than it might seem. ResourceNotFoundError and a 403 both surface as HTTP-shaped errors whose messages talk about status codes and request identifiers rather than about which secret your application wanted. Replacing them with an error that names the secret turns a support conversation into a glance at a log line.

Startup fetch with a scheduled refresh Secrets are fetched once at startup and validated into the settings model, with a periodic refresh, so the vault is never on the request path and a failure at startup stops the deploy. startup one fetch per secret validate into the model typed and masked failure here stops the deploy every request served from the model no vault call, no vault latency, no throttling
One fetch per secret per process, and the vault never appears on the request path.

Access, deletion and other vault-shaped surprises

Key Vault access can be granted at the vault level or per secret, and the simplest configurations grant at the vault level — which means a workload that needs one value can read every value in that vault. Scope grants to the specific secrets a workload consumes, and separate vaults by environment so a mistake in a staging grant cannot expose production. Trust boundaries within an environment are worth a separate vault too, if the difference between two services’ credentials matters.

Soft-delete is the behaviour that surprises everyone at least once. A deleted secret keeps its name reserved for the retention period, and with purge protection enabled — which is the sensible setting — that reservation cannot be shortened. Recreating a secret with the same name fails with a conflict, and the error reads like a naming problem rather than a deletion one. The remedies are to recover rather than recreate in production, and to generate unique names in automated tests so a suite never collides with its own leftovers.

The third surprise is versioning. Omitting the version argument returns the current enabled value, which is what makes rotation transparent — but disabling a version also changes what callers receive, and disabling is a common way to stage a rollback. That means a change nobody thought of as a deployment can alter what a restarting pod picks up. Where determinism matters, request an explicit version and record that decision beside the secret’s entry in the name mapping.

Diagnostic logging on the vault itself is the last piece, and it has to be enabled deliberately because it is off by default. Vault diagnostics record every read with the calling identity, which is the only evidence that answers “who accessed this secret and when” during an investigation. Turn it on when the vault is created rather than when you need it, and route it somewhere with a retention period longer than a plausible detection delay — weeks rather than days, since credential misuse is rarely noticed the same afternoon.

Gotchas and version-specific behaviour

  • DefaultAzureCredential tries sources in order and takes the first that succeeds — log the resolved type so the answer is never a guess.
  • Omitting the version returns the current enabled value; disabling a version changes what every caller gets.
  • Soft-delete with purge protection reserves a deleted name for the retention period; plan test names accordingly.
  • Values written through the portal or piped from a file frequently carry a trailing newline — strip in the adapter.
  • Vault request limits are per vault, so several chatty workloads sharing one vault can throttle each other.
  • The SDK retries transient failures itself; adding another retry layer around it multiplies the wait before a startup failure surfaces.
  • Fetching many secrets sequentially at startup adds a round trip each — fetch concurrently if the count grows.

Production parity checklist

  • Deployed environments authenticate with a managed identity; no client secret exists in configuration.
  • The resolved credential type is logged at startup, and no values are.
  • Secrets are fetched once at startup and refreshed on a timer, never per request.
  • Read access is granted per secret, and vaults are separated by environment.
  • SDK exceptions are wrapped in an error that names the secret your application wanted.
  • Version policy is recorded next to each entry in the name mapping.
Three Key Vault behaviours that surprise newcomers Vault-level access grants read of every secret, soft-delete reserves a deleted name for the retention period, and disabling a version changes what unversioned callers receive. vault-level access one grant reads everything scope per secret instead soft-delete the name stays reserved recover, or use unique test names disabling a version changes unversioned reads pin where determinism matters
Each is reasonable in isolation and each produces a confusing first encounter.

Frequently asked questions

Which credential should a Python app use for Key Vault?

A managed identity in every deployed environment, resolved through DefaultAzureCredential or constructed explicitly as ManagedIdentityCredential. It requires no secret in your configuration, which removes the problem of needing a credential in order to read credentials — and it means revoking a workload’s access is a matter of removing a role assignment rather than rotating a value that has been copied into several environments. Keep DefaultAzureCredential for local development, where falling back to a signed-in session is exactly the behaviour you want.

Why does the credential chain pick the wrong identity locally?

Because it tries several sources in order and takes the first that succeeds. A stale AZURE_CLIENT_ID left in a shell profile, or a CLI session signed in as a different account, can outrank the identity you meant. Log which credential type resolved at startup so the answer is visible rather than inferred, and where ambiguity would be costly, construct the specific credential explicitly. The chain’s flexibility is a feature for a laptop and a liability in an environment where a wrong identity is a silent misconfiguration.

How do I avoid rate limits on Key Vault?

Fetch at startup rather than per request, and refresh on a timer measured in minutes. Per-request reads exhaust the vault’s request allowance quickly, add its latency to every response and couple your availability to the vault’s. If several workloads share a vault, remember that the limit is shared too — one chatty service can throttle its neighbours, which is a good argument for both caching and for separating vaults along service boundaries.

Why can I not recreate a deleted secret with the same name?

Soft-delete keeps the name reserved for the retention period, and purge protection prevents shortening it. Recover the secret instead of recreating it, and generate unique names in automated tests so a suite never collides with its own leftovers. The behaviour exists for good reasons — it makes an accidental deletion recoverable and prevents an attacker from deleting and recreating a secret to substitute a value — but the error message speaks of a conflict, which reads like a naming clash rather than a deletion.

Should each service have its own vault?

Separate by environment at minimum, and by trust boundary where it matters. Access is granted at the vault level in the simplest configurations, so one over-broad grant on a shared vault exposes everything in it, and request limits are shared across everything that reads from it. Per-secret grants mitigate the access concern, but a separate vault per environment is a structural boundary that does not depend on anybody configuring a grant correctly.

Key takeaways

The two decisions that matter most are both about avoiding a stored credential and a hot path. Authenticate with a managed identity so nothing in your configuration needs rotating in order to read the things that do; fetch every secret once at startup into a validated model so the vault is never on the request path and a permission problem stops a deploy rather than degrading live traffic.

Around those, the operational details are specific and worth knowing before you meet them: log which credential resolved, wrap SDK errors so they name the secret your application wanted, strip whitespace from values, scope read access per secret with vaults separated by environment, and record a version policy for anything where two processes resolving different values would matter. See the Azure and Google secret stores topic for how the same shape maps onto the other provider.