Build a Provider-Agnostic Secret Backend

A team running in two clouds, or migrating between them, needs application code that does not care which secret store supplied a value. The temptation is to build a rich abstraction covering versions, rotation, leases and caching, and that abstraction always ends up shaped like whichever provider was implemented first. The version that survives is far smaller: one method, a dictionary in, a dictionary out. This page shows where to draw the boundary and what deliberately stays outside it, extending the Azure and Google secret stores topic.

Problem 1: an interface shaped like one provider’s SDK

# ANTI-PATTERN: every concept here comes from one provider
class SecretBackend(Protocol):
    def get_secret_value(self, secret_id: str, version_stage: str) -> dict: ...
    def describe_secret(self, secret_id: str) -> dict: ...
    def rotate_secret(self, secret_id: str, lambda_arn: str) -> None: ...

version_stage is one vendor’s model, lambda_arn is another vendor’s compute product, and describe_secret returns a shape nobody else produces. Implementing this for a second provider means either inventing values for concepts it does not have or throwing NotImplementedError — and either way the interface no longer means anything.

Design from the consumer instead. The application needs values for its settings fields. It does not need to describe a secret, enumerate versions, or trigger rotation, because those are operational actions that belong to the platform and its tooling, not to the process that reads configuration at startup.

A wide interface leaks provider concepts; a narrow one does not An interface exposing versions, stages and rotation triggers cannot be implemented uniformly across providers, while an interface that only fetches values by key implements identically everywhere. wide interface version stages rotation triggers describe and list operations second provider raises NotImplementedError narrow interface fetch(names) -> values strings in, strings out nothing provider-shaped every provider implements it the same way
The test of an abstraction is the second implementation, not the first.

Problem 2: abstracting over version semantics

# ANTI-PATTERN: "version" means something different in each store
backend.fetch("app/token", version="latest")     # an alias here, a stage there, a number elsewhere

One store addresses numbered versions with a latest alias, another returns the current enabled value when no version is given, a third uses named stages that can be moved between versions. A common version parameter has to mean all three, which means it reliably means none of them — and the failure mode is silent, because passing an alias a provider does not recognise often falls back to a default rather than raising.

Keep version policy in provider-specific configuration. The interface fetches “the value this workload should use”, and each implementation is constructed with whatever it needs to determine that: a pin dictionary for one, a stage name for another, nothing at all for a third. The consumer never expresses a version, so no cross-provider meaning has to be invented.

Problem 3: pushing caching and retries into the interface

# ANTI-PATTERN: policy in the abstraction, so every implementation repeats it
class SecretBackend(Protocol):
    def fetch_with_cache(self, names: dict[str, str], ttl_seconds: int) -> dict[str, str]: ...

Caching and retry are policies of your application, not properties of a provider. Putting them in the interface means each implementation reimplements them, probably slightly differently, and testing the policy requires a provider implementation rather than a dictionary.

Keep them outside, wrapping the interface. A caching decorator around any implementation gives one cache behaviour for every provider, testable on its own with a fake that counts calls. The same applies to retry: the SDKs already retry transient failures internally, and your layer’s job is the higher-level policy — crash at startup, keep previous values on refresh — which is application logic wherever the values came from.

Secure implementation

# secret_backend.py — the whole abstraction, in one screen
from typing import Protocol


class SecretBackend(Protocol):
    def fetch(self, names: dict[str, str]) -> dict[str, str]:
        """{store key: field name} -> {field name: value}. Strings only, both ways."""


class DictBackend:
    """The fake, and the local-development implementation. Deliberately trivial."""

    def __init__(self, values: dict[str, str]) -> None:
        self._values = values

    def fetch(self, names: dict[str, str]) -> dict[str, str]:
        # 1. a fake with no network, no credentials and no setup
        return {field: self._values[key] for key, field in names.items()}


class CachingBackend:
    """Policy lives here, once, for every provider."""

    def __init__(self, inner: SecretBackend) -> None:
        self._inner = inner
        self._cache: dict[str, str] | None = None

    def fetch(self, names: dict[str, str]) -> dict[str, str]:
        if self._cache is None:                 # 2. one fetch per process, by default
            self._cache = self._inner.fetch(names)
        return dict(self._cache)

    def refresh(self, names: dict[str, str]) -> bool:
        try:
            fresh = self._inner.fetch(names)    # 3. validate-then-swap, provider-independent
        except Exception:
            return False                        # 4. keep the values we have; caller logs
        self._cache = fresh
        return True
# backends/aws.py — one provider, all of its quirks confined here
import boto3
from botocore.exceptions import ClientError


class AwsSecretsManagerBackend:
    def __init__(self, region: str, stage: str = "AWSCURRENT") -> None:
        self._client = boto3.client("secretsmanager", region_name=region)
        self._stage = stage                     # 5. version policy: provider-specific, in the constructor

    def fetch(self, names: dict[str, str]) -> dict[str, str]:
        out: dict[str, str] = {}
        for key, field in names.items():
            try:
                response = self._client.get_secret_value(SecretId=key, VersionStage=self._stage)
            except ClientError as exc:
                raise SecretUnavailable(f"cannot read '{key}': {exc.response['Error']['Code']}") from None
            out[field] = response["SecretString"].strip()   # 6. normalise here, like every backend
        return out

Every implementation ends up around twenty lines, and each one absorbs its provider’s peculiarities — a stage name here, a version path there, a bytes decode somewhere else — so that no consumer ever sees them. The normalisation is part of the contract: fetch returns clean strings, whitespace stripped, whatever the provider handed over. Writing that rule down once means the trailing-newline class of bug is fixed in every backend rather than discovered separately in each.

DictBackend is the piece that proves the design. It is four lines, needs no mocking library, and is the implementation used both by tests and by local development where secrets come from a .env file. If a change to the interface would make DictBackend complicated, that is the signal to stop and reconsider — the fake’s simplicity is a direct measure of how much provider-shaped thinking has leaked into the abstraction.

Layering: policy outside, providers inside, model above The settings model consumes a caching wrapper that holds application policy, which wraps one thin provider backend per store, each confining its own quirks. settings model typing, validation, masking caching wrapper refresh policy, one place AWSAzureGoogleDictBackend stagescurrent valueversions tests and local
Policy above the seam, provider quirks below it, and a four-line fake in the same row as the real ones.

When not to build this

An abstraction over one provider you have no plan to leave is cost without benefit. If everything runs in one cloud, use the SDK directly and confine the fetch to a single module — that gives you the testability and the single point of change without an extra layer, and the module can grow into a backend later if a second provider ever appears.

The other case where it is the wrong tool is when you need provider-specific capabilities. Dynamic database credentials with leases, transit encryption, per-request credential minting: these are the reasons to choose a particular store, and flattening them into “fetch me a string” discards exactly the feature you paid for. Use the specific client for the specific capability, and let the generic backend cover the ordinary static-value case alongside it. A codebase with one narrow abstraction and one direct client for the special path is more honest than one abstraction pretending to cover both.

The migration case is where it earns its keep most clearly, and it is worth building even if the migration is hypothetical, because the same seam that lets you swap providers is the one that makes a dictionary-backed test possible. That secondary benefit accrues from day one, which is often the stronger argument in practice: the abstraction pays for itself in the test suite long before anybody changes cloud.

One more design note: keep the mapping of store keys to field names in application code rather than inside a backend. It is the workload’s inventory of which credentials it consumes, it changes when the application changes rather than when the provider does, and keeping it out of the backend is what allows the same backend instance to serve two different models — a web tier and a worker with different requirements — without any duplication.

Gotchas and version-specific behaviour

  • Keep the return type to plain strings; returning provider response objects leaks their shape into consumers immediately.
  • Normalisation — decode, strip — belongs in each backend so the contract is uniform.
  • Version policy is constructor configuration per backend, never a parameter on the interface.
  • Caching and refresh policy wrap the interface rather than living inside it, so they are tested once.
  • Errors should be your own type naming the key, not the provider’s exception, or callers end up importing every SDK.
  • A fake that needs more than a few lines is the signal that the interface has grown too wide.
  • Provider SDKs retry transient failures internally; adding your own retry inside a backend multiplies the delay before a startup failure surfaces.

Production parity checklist

  • The interface has one method and no provider-specific vocabulary.
  • Each backend is small enough to read in one screen and owns its own normalisation.
  • A dictionary-backed implementation serves both tests and local development.
  • Caching, refresh and failure policy live in a wrapper shared by every provider.
  • Errors are a single application type that names the key and the reason.
  • Provider-specific capabilities use their own client directly rather than being forced through the interface.
What belongs inside the interface and what stays outside Fetching values and normalising them belong inside a backend, while caching, refresh policy, version pinning and provider-specific capabilities stay outside it. inside a backend authenticating to the store addressing keys and versions decoding and stripping values about twenty lines each outside it caching and refresh policy startup versus refresh failure rules typing, validation and masking written once, for every provider
Anything written once for all providers belongs outside the seam.

Frequently asked questions

What should the interface actually expose?

One method that takes a mapping of store keys to field names and returns a mapping of field names to string values. Anything richer starts encoding one provider’s concepts and will not fit the next one — version stages, rotation triggers, describe operations and lease renewals are all provider vocabulary. The narrow shape has a second advantage: it matches exactly what the settings model needs as initialisation arguments, so there is no translation step between the backend’s output and the model’s input.

Should the abstraction handle versioning and rotation?

No. Version semantics differ enough between providers that a common abstraction over them either loses meaning or becomes a leaky union of every provider’s model, and rotation is an operational action performed by platform tooling rather than by the process reading configuration. Express version policy as constructor configuration on each backend — a stage name, a pin dictionary, nothing at all — so each provider’s model stays intact and the consumer never has to know which one is in use.

How do I test code that uses the interface?

With a dictionary-backed implementation. If your fake needs more than a few lines, the interface is too wide — that is the most useful signal the design gives you, and it is worth taking seriously rather than working around. A four-line fake means no mocking library, no network, no credentials, and tests that run in milliseconds; it also means the same implementation can serve local development, where values come from a .env file instead of a store.

Does this add much overhead compared with using an SDK directly?

Almost none. It is one method call and a dictionary, and the SDK still does the work — what it adds is a seam for testing and a single place where each provider’s quirks are handled. The overhead people actually notice is conceptual rather than computational: one more file to read when tracing where a value came from. Keeping each backend to about twenty lines is what keeps that cost negligible.

When is a provider abstraction the wrong choice?

When you have one provider and no plan to change, and when you need provider-specific features such as dynamic credential leases or transit encryption. Use the SDK directly and keep the fetch confined to one module instead — that gives most of the testability benefit with no extra layer. A codebase can also legitimately have both: a narrow backend for ordinary static values, and a direct client for the one capability that is the reason you chose that store in the first place.

Key takeaways

The abstraction that survives a second implementation is small: one method, strings in and strings out, with each backend absorbing its provider’s addressing, authentication and normalisation quirks in about twenty lines. Everything that can be written once for all providers — caching, refresh policy, the startup-versus-refresh failure asymmetry, typing and masking — belongs above the seam, where it is tested once against a dictionary.

Judge the design by its fake. A four-line DictBackend that serves both tests and local development means no provider vocabulary has leaked into the interface; a fake that needs setup means it has. And be willing not to build it at all: with one provider and no migration in prospect, a single module using the SDK directly gives you the same single point of change, and provider-specific capabilities are better used directly than flattened into a lowest common denominator. The provider-by-provider details are on the Azure and Google secret stores topic page.