Azure Key Vault & Google Secret Manager

A service that was written against one cloud’s secret store rarely survives contact with another unchanged. The API shapes differ, the identity model differs, and — the detail that causes real incidents — the semantics of a version differ. A team that moves a Python service from Google Secret Manager to Azure Key Vault, or runs one service across both, discovers this at the point where rotation is supposed to be transparent and turns out not to be. This page covers what a Python application needs from each service, where the two genuinely differ, and how to keep the difference confined to a small adapter. It sits under enterprise secrets management alongside the AWS and Vault integrations.

Both services solve the same problem in the same broad way: a named secret holds versioned values, access is governed by the cloud’s own identity system, and a client library fetches a value over the network. The similarities are close enough that a naive port compiles and runs. The differences that matter are narrow and sharp — how a version is addressed, what happens when a name is deleted, how aggressively you get throttled, and which identity the client library picks up when several are available — and each of them has bitten enough teams to be worth stating explicitly rather than discovering.

The same three stages in both clouds, with different mechanics A Python process authenticates with a platform identity, addresses a named secret and a version, and receives a value which it validates into a settings model; each stage has different mechanics in Azure Key Vault and Google Secret Manager. identity → address → value authenticateaddressvalidate Azure: managed identity via the credential chain Google: workload identity via application default vault URL + secret name version optional project/secret/versions/n or /versions/latest settings model typed, masked, fail-fast only the left two columns are provider-specific — keep the adapter that thin
Confine the differences to authentication and addressing; everything downstream is the same model.

Where the provider adapter belongs

The adapter should return a plain mapping of field names to string values and nothing else. Not a client, not a provider-specific object, not a lazily-evaluated proxy — a dictionary. That constraint is what keeps the rest of the application free of cloud-specific concepts, and it is also what makes the adapter trivially testable: a fake implementation is a dictionary literal, and no test needs a network or a credential. Everything downstream — typing, validation, masking, precedence against environment variables — is handled by the settings model exactly as it would be for any other source.

Where the adapter is invoked matters as much as what it returns. Fetch at process startup, before the application begins serving, so that a store outage, a missing permission or a deleted secret fails the boot rather than the first request that needs the value. That aligns secret loading with the rest of the validation-at-startup discipline the site recommends, and it means an environment with broken credentials never reaches a load balancer’s healthy pool. It also concentrates the network cost into one moment rather than spreading it across the request path, which is what makes the caching discussion below tractable.

The remaining architectural decision is naming. Each provider has its own constraints — Key Vault names permit letters, digits and dashes; Google’s are similarly restricted — and neither matches the underscore-separated names Python configuration tends to use. Rather than bending your field names to the intersection of two providers’ rules, keep an explicit mapping in the adapter: a dictionary from the store’s key name to the model’s field name. It costs a few lines, it documents exactly which secrets a workload consumes, and it means renaming a secret in the store is a one-line change rather than a search across the codebase.

Secure implementation

Two adapters, one interface, one settings model. Neither adapter holds a credential of its own.

# secret_providers.py — the whole cloud-specific surface, in one file
from typing import Protocol


class SecretProvider(Protocol):
    def fetch(self, names: dict[str, str]) -> dict[str, str]:
        """Map {store_key: field_name} to {field_name: value}."""


class AzureKeyVaultProvider:
    def __init__(self, vault_url: str) -> None:
        from azure.identity import DefaultAzureCredential
        from azure.keyvault.secrets import SecretClient
        # no secret in configuration: the chain resolves the host's managed identity
        self._client = SecretClient(vault_url=vault_url, credential=DefaultAzureCredential())

    def fetch(self, names: dict[str, str]) -> dict[str, str]:
        # omit the version argument to take the current enabled value
        return {field: self._client.get_secret(key).value for key, field in names.items()}


class GoogleSecretManagerProvider:
    def __init__(self, project_id: str) -> None:
        from google.cloud import secretmanager
        self._client = secretmanager.SecretManagerServiceClient()
        self._project = project_id

    def fetch(self, names: dict[str, str]) -> dict[str, str]:
        out: dict[str, str] = {}
        for key, field in names.items():
            path = f"projects/{self._project}/secrets/{key}/versions/latest"
            payload = self._client.access_secret_version(name=path).payload.data
            out[field] = payload.decode("utf-8").strip()   # strip: trailing newlines are common
        return out
# config.py — the model does not know which cloud it is running in
from pydantic import SecretStr, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict

SECRET_NAMES = {                      # store key -> model field
    "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_settings(provider: SecretProvider | None) -> Settings:
    # provider is None locally: the model falls back to env vars and .env
    overrides = provider.fetch(SECRET_NAMES) if provider else {}
    return Settings(**overrides)      # init values outrank the environment

The Protocol is deliberate rather than decorative. It gives the two implementations a checked shape without a base class, and it lets a test pass a plain object with a fetch method — or a dict-returning lambda wrapper — with no mocking library involved. The strip() in the Google implementation is not paranoia: values created by piping a file into the CLI routinely carry a trailing newline, and a token with \n appended fails authentication in a way that looks like a wrong credential rather than a formatting problem.

Note what is absent from both adapters: any credential. DefaultAzureCredential resolves an identity from the environment it runs in — a managed identity on an Azure host, a developer’s signed-in session locally — and the Google client does the equivalent through application default credentials. That is the property that makes this bootstrappable at all. A design that stores a client secret in order to read secrets has simply moved the problem to a credential nobody rotates, and it is worth rejecting that design explicitly whenever it is proposed as a shortcut for local development.

Version semantics differ between the two stores Google Secret Manager addresses an explicit version number or the latest alias, while Azure Key Vault returns the current enabled version when no version is given, so the same rotation produces different behaviour. Google: numbered versions /versions/1 — pinned, immutable /versions/2 — pinned, immutable /versions/latest — highest enabled two processes can resolve "latest" differently Azure: current enabled value get_secret(name) — current value get_secret(name, version) — pinned disabled versions are not returned disabling a version changes what callers get
Both offer pinning and both offer "whatever is current" — the trap is assuming one store's default is the other's.

Concept mapping

Concept Azure Key Vault Google Secret Manager Consequence for Python
Identity managed identity via the credential chain workload identity via application default credentials no credential in your configuration either way
Address vault URL plus secret name projects/p/secrets/s/versions/v keep the full path out of the model
Default version current enabled value must be stated: a number or latest be explicit rather than relying on a default
Payload a string bytes, decode yourself strip whitespace in both cases
Deletion soft-delete, name reserved destroy a version, name reusable plan test-name cycling accordingly
Throttling per-vault request limits per-project access quotas cache; never read per request
Audit vault diagnostic logs data access audit logs enable before you need them

The payload row hides a genuine asymmetry rather than a cosmetic one. Azure hands back a string, so a value that was stored as binary has already been through an encoding decision somebody else made. Google hands back bytes, which is more honest and slightly more work: you decode explicitly, and the decode is the natural place to notice that a certificate or a binary key was stored base64-encoded and needs decoding twice. Where a workload consumes binary material — a private key, a keystore — prefer to keep it base64 in the store and decode once in the adapter, so both providers behave identically and the model receives the same bytes either way.

The audit row is easy to skip and expensive to skip. Access logging on both platforms is a configuration option rather than a default, and the value of it is entirely retrospective: it answers “who read this secret, and when” during an investigation that has already started. Enabling it on the day you create the store costs one setting; enabling it after an incident tells you nothing about the period you care about. Route the logs somewhere with a retention period longer than your likely detection delay, which for credential misuse is measured in weeks rather than days.

The deletion row is the one that produces the most confusing first encounter. Azure’s soft-delete keeps a removed secret’s name reserved for a retention period, and with purge protection turned on it cannot be shortened — so a test that creates and deletes test-secret cannot recreate it, and the error message speaks of a conflict rather than a deletion. The workaround is to generate unique names in tests and to recover rather than recreate in production. Google’s model is version-oriented instead: you destroy versions and the name remains, which is friendlier for automation but means a “deleted” secret whose versions were left intact is still readable.

Deployment parity, step by step

  1. Use a platform identity everywhere it exists. Managed identity on Azure hosts, workload identity on Google. A stored client secret is a credential that itself needs rotating.
  2. Keep local development on environment variables. Developers should not need access to a production store to run the application; the provider is None locally and the model falls back to a .env file.
  3. Fetch once at startup and validate immediately. A missing permission or a deleted secret becomes a failed boot, not a failed request.
  4. Grant read on named secrets, not on the whole store. Both platforms support scoping to individual secrets; the default of “read everything in the vault or project” is convenient and too broad.
  5. Decide the version policy per secret. Pin where determinism matters — signing keys, anything a rollback must reproduce — and take the current value where transparent rotation matters.
  6. Enable audit logging before the first real secret lands. Both services can log every access; both default to less than you will want during an investigation.

Step 5 is the one that most often lacks an explicit decision. Taking the current value makes rotation invisible, which is exactly right for a database password rotated on a schedule. It is exactly wrong for a signing key, where two processes started ten minutes apart would sign with different keys and one of them would produce signatures the other rejects. Write the policy next to each entry in the name mapping so the choice is visible where the secret is declared, rather than buried in whichever call site happened to be written first.

Security boundaries and operational guardrails

  • No credential in configuration to read credentials. If the design needs a client secret, it has moved the problem rather than solved it.
  • Scope reads to named secrets. A workload that needs one value should not be able to enumerate the store.
  • Never read a secret on a request path. Beyond cost and latency, it couples your availability to the store’s.
  • Wrap every fetched value in SecretStr immediately. The adapter returns plain strings; the model is where they stop being plain.
  • Treat the name mapping as an inventory. It is the authoritative list of which workload holds which credential — invaluable during a rotation and during an incident.
  • Separate stores by environment, not just by name prefix. A single vault holding both staging and production values makes one over-broad grant a production exposure.

The last two guardrails work together in a way that is easy to underestimate. When an incident requires knowing which services held a compromised credential, the name mapping in each repository answers the question in minutes, and environment-separated stores bound the blast radius before the question is even asked. Teams that skip both end up reconstructing the answer from deployment history under time pressure, which is how a rotation that should take twenty minutes takes a day.

Costs, quotas and why caching is not optional

Both services charge per access operation and both enforce request limits — Key Vault at the vault level, Secret Manager at the project level. Neither limit is generous enough to survive a per-request read from a service under load, and neither charge is trivial at that volume. A service handling a hundred requests per second that fetches one secret per request generates around nine million access operations a day, which is enough to be both throttled and billed noticeably for something that could have been read once.

The correct shape follows from the startup-fetch rule above: read every secret once at boot into the validated model, and refresh on a timer if values rotate while the process runs. A refresh interval measured in minutes reduces access volume by three or four orders of magnitude compared with per-request reads and still tracks rotation quickly enough for any realistic schedule. The caching guidance written for Parameter Store applies unchanged here — the numbers differ, the shape does not.

Throttling deserves one design note beyond caching. When a store does rate-limit you, the failure arrives as an exception on a code path that may be a startup or a refresh, and the two want different handling. A startup failure should crash the process, loudly and immediately: the deploy stalls and the previous version keeps serving. A refresh failure should not — the process already holds working values, so it should log, keep the current values, and retry with backoff. Conflating the two by wrapping both in the same handler produces either a service that crashes hours after a successful deploy or one that boots with no credentials and pretends to be healthy.

Startup fetch with scheduled refresh versus per-request reads Reading a secret on every request multiplies access operations and adds store latency to the request path, while a startup fetch with a periodic refresh reduces access volume by orders of magnitude. same secret, two access profiles per request one store call per request — throttled, billed, and on the latency path at boot fetch served from the validated model — no store call at all refreshrefresh a refresh failure keeps the old values; a startup failure must stop the deploy
Two failure policies, not one: crash at boot, degrade gracefully on refresh.

Migrating between the two

Moving from one store to the other is a routine piece of work if it is sequenced properly, and a bad weekend if it is not. A migration is mostly an inventory exercise, and the inventory you need is the name mapping each service already declares. Start by listing every secret each workload consumes, its version policy, and which identity reads it. That list is short — usually a handful of entries per service — and it is the entire specification of what has to exist on the other side. Everything else in the migration is mechanical.

The order that avoids downtime is: create the secrets in the destination store first, grant the destination identity read access, deploy a build that can read from either store selected by an environment variable, cut environments over one at a time, then remove the old provider once nothing points at it. The dual-read build is what makes the cutover reversible: if the destination store is misconfigured, flipping one variable puts the service back on the working path without a rollback of the application image. Keeping that build around for one release cycle after the final cutover costs nothing and has saved more than one team an emergency.

Two details tend to be discovered late. First, values do not always copy cleanly: a secret written to one store through a console text box and copied to another through a CLI can pick up or lose a trailing newline, which is exactly the failure the adapter’s strip() guards against — but only for the store the adapter handles that way, so apply it uniformly. Second, version history does not migrate. The destination secret starts at version one, so any process pinned to a specific version number has to be repointed explicitly, and any rollback plan that assumed an old version was retrievable needs rewriting before the cutover, not after.

Reversible cutover between two secret stores Secrets are created in the destination store, a dual-read build is deployed, environments are cut over one at a time by an environment variable, and the old provider is removed only once nothing points at it. create + grant in the destination dual-read build provider chosen by a var cut over one env flip back if wrong remove the old once nothing reads it version history does not migrate — repoint anything pinned first
One variable decides which store a build reads, which is what makes each environment's cutover reversible.

Troubleshooting

Locally the client authenticates as the wrong identity. Azure’s credential chain tries several sources in order, and a stale environment variable or a signed-in CLI session can outrank the one you intended. Set the chain explicitly in development, or exclude the sources you do not want, and log which credential type resolved so the answer is visible rather than inferred.

PermissionDenied on Google despite an assigned role. Reading a value requires permission on the version access operation, which is a different permission from listing or viewing secret metadata. A principal that can see the secret in the console and cannot read it has exactly this gap.

A rotated value is not picked up. Either the version is pinned, or the process fetched at boot and has not refreshed. Both are working as designed; decide which behaviour you want per secret and make it explicit in the name mapping.

Startup is slow after adding several secrets. Each entry in the name mapping is a separate round trip, and a dozen of them in sequence is a noticeable delay on every container start. Fetch them concurrently, or where the provider supports retrieving several values in one call, group them — but keep the adapter’s return type unchanged so nothing downstream notices the optimisation.

Intermittent failures under load. Throttling. Confirm against the store’s own metrics rather than guessing from your error rate, then move to a startup fetch with scheduled refresh — retries alone will not fix a design that reads per request.

A name cannot be recreated after deletion. Azure soft-delete with purge protection is holding the name for the retention period. Recover the secret instead of recreating it, and use unique generated names in tests so the reservation never blocks a suite.

The value is right but authentication fails. Suspect a trailing newline or a base64 payload decoded with the wrong assumption. Google returns bytes and a value written by piping a file usually ends with \n; strip whitespace in the adapter and the class of failure disappears.

Multi-cloud secret integration checklist Use a platform identity, keep local development on environment variables, fetch at startup, scope reads to named secrets, decide a version policy per secret and enable audit logging early. Platform identity in every deployed environment — no stored client secret Local development falls back to environment variables, not the real store One fetch at startup, refresh on a timer, never a read per request Read access granted per named secret, and per environment store Version policy recorded next to each name, audit logging already enabled
The same five checks apply to both clouds — only the mechanism behind each one differs.

Frequently asked questions

How should a Python service authenticate to Azure Key Vault in production?

With a managed identity, resolved through DefaultAzureCredential. The credential chain finds the identity assigned to the host without any secret in your configuration, which removes the bootstrapping problem of needing a credential to read credentials. The same chain works on a developer machine by falling back to a signed-in session, which is why the code is identical in both places. The one operational caution is that the chain’s flexibility can surprise you: if several sources are available it picks the first that succeeds, so log the resolved credential type at startup and, in environments where ambiguity would be costly, construct the specific credential explicitly instead of relying on the chain.

What does “latest” mean in Google Secret Manager?

It resolves to the highest-numbered enabled version at the moment of the call. That makes rotation transparent, but it also means two processes started minutes apart can hold different values, so pin a version when you need determinism. The distinction matters most for values used in verification: a signing key resolved as latest by one process and pinned by another produces signatures that fail to verify, and the resulting bug is intermittent and extremely hard to attribute. Decide per secret, and record the decision where the secret name is declared.

Do I need to cache secret reads?

Yes. Both services are rate-limited and billed per access, and both add network latency to whatever path reads them. Read at startup into a validated settings model and refresh on a schedule rather than reading per request. The refresh interval is a trade-off between how quickly you want a rotation to take effect and how much access volume you want to generate; minutes is almost always the right order of magnitude. Keep the two failure policies distinct — a failed startup fetch should stop the process, a failed refresh should log and keep the previous values.

Can one Python codebase target both clouds?

Yes, by defining a small provider interface that returns a mapping of names to values and implementing it once per cloud. Keep the interface at the level of “fetch these keys”, not at the level of the providers’ own API shapes, or the abstraction will leak the moment one provider grows a concept the other lacks. In practice the interface stays stable and the implementations stay short, because everything interesting — typing, validation, masking, precedence against environment variables — happens in the settings model afterwards and is identical regardless of where the values came from.

Why does my deleted Azure secret still block the name?

Key Vault soft-deletes by default, and with purge protection enabled the name stays reserved for the retention period. Recover the secret rather than trying to recreate it, or choose a new name. This is a deliberate safety feature — it prevents an accidental deletion from being unrecoverable, and it prevents an attacker from deleting and recreating a secret to substitute a value — but it makes naive test cleanup impossible. Generate unique names in automated tests, and treat deletion in production as an operation that requires a recovery plan rather than a routine cleanup.

Key takeaways

The invariant this topic enforces is that cloud-specific behaviour stops at a single adapter that returns a dictionary. Authentication and addressing are the only genuinely provider-shaped concerns; typing, validation, masking, precedence and failure behaviour all belong to the settings model and are identical whichever store supplied the values. Keeping that line clean is what makes running in two clouds — or moving between them — a matter of adding an implementation rather than reworking configuration handling.

The differences worth memorising are few and specific: version semantics, where Google requires you to name a version or the latest alias while Azure returns the current enabled value; deletion, where Azure reserves a soft-deleted name and Google destroys versions under a name that stays reusable; and quotas, which are strict enough in both that a per-request read is a design error rather than an inefficiency. Fetch once at boot, refresh on a timer, crash on a startup failure and degrade on a refresh failure, scope reads to named secrets per environment, and record a version policy beside every name. Those decisions cost an afternoon and remove the entire category of surprises that a port between clouds usually produces.