Credential rotation
Every deployment holds credentials that eventually have to change: database passwords, API keys, per-tenant DSNs. The dangerous part is not generating a new password — it is the window between "the backend accepted the new credential" and "every container observed it". Get the ordering wrong in exactly one way (publish before verify) and the failure mode is a fleet-wide outage triggered by your own rotation signal.
Forze splits the problem into layers you can adopt one at a time. Each layer is an accelerator over the one guarantee that is always on:
Signals accelerate, the TTL floor guarantees. Routed tenant pools re-resolve credentials and rebuild when their fingerprint changes;
fingerprint_ttlmakes that happen within a bounded window even if every signal below is lost. Wiring a change source does not remove the TTL — it lets you raise it.
Versioned reads¶
Every secrets backend serves resolve_versioned / current_version — a
SecretVersion is an opaque, equality-only token (Vault yields integers, files
and env yield content hashes). Same token at one ref ⇒ same value; nothing more.
That contract is what makes change detection universal instead of Vault-only.
Backends declare what they honor through SecretsCapabilities, and every consumer
below fails closed (secrets_feature_unsupported) rather than degrading silently —
a watcher over a backend without versions, a rotator over a read-only store, a
lease manager over a store without a lease engine all refuse at wiring.
Watching for changes¶
The change feed is its own small contract: SecretChanged(ref, version) delivered
by a SecretsChangeSource. Events never carry values — a consumer re-resolves
through its own authenticated store connection, which also makes a spoofed or
replayed event harmless (it can trigger a refetch, never inject a credential).
Two shipped sources cover most deployments:
from forze_kits.integrations.secrets import (
DirectorySecretsChangeSource,
SecretsPollWatcher,
)
watcher = SecretsPollWatcher(secrets=versioned_backend, refs=[dsn_ref])
step = watcher.lifecycle_step() # a supervised 30s tick; first tick primes silently
SecretsPollWatcherpollscurrent_versionfor a ref set and emits on deltas. Thirty seconds is the deliberate default: kubelet's own secret sync is minute-granular, and against Vault a tick is one metadata read per ref.DirectorySecretsChangeSourcewatches mounted secret files with Kubernetes semantics: it re-stats the path each tick (kubelet updates a Secret by atomically swapping the..datasymlink — an inode watch sees the old file forever), and a stat gate keeps unchanged files at onestatper tick.subPathmounts never update — that is a Kubernetes fact no watcher can fix.
With the watchfiles package installed (an app-level dependency, deliberately not
a Forze extra), the directory source can also react to OS-native events instead of
waiting out the poll interval:
steps = [
file_source.lifecycle_step(interval=timedelta(minutes=5)), # the floor, raised
file_source.native_events_lifecycle_step(), # the accelerator
]
Event paths are deliberately never trusted — every native event just triggers the
same stat-gated diff, so a spurious burst costs one stat per ref. Keep the poll
step wired: native events let you raise its interval, never remove it.
Delivery is at-least-once, unordered, and advisory. That costs nothing: eviction on an unchanged secret re-resolves, recomputes an equal fingerprint, and rebuilds nothing — over-notification is free by design.
Hot reload¶
The binder turns changes into evictions:
from forze_kits.integrations.secrets import SecretsHotReloadBinder
binder = SecretsHotReloadBinder(
sources=[watcher],
routed_clients=[routed_postgres, routed_mongo],
)
steps = [watcher.lifecycle_step(), binder.lifecycle_step()]
For each event it recomputes the cached tenants' refs against the changed ref and
calls evict_tenant on the matches; the next access re-resolves and rebuilds.
Non-routed singleton pools follow a different doctrine: re-resolve at
connection-establishment time. A Postgres/MySQL password is checked only at
connect and established connections survive rotation, so a connect-time
resolve_str makes the rotation window race-free with or without a signal. The
binder's on_change callbacks (e.g. a soft pool recycle) only accelerate draining
of old connections — they are never the correctness mechanism.
Rotation notifications across containers¶
The rotator publishes SecretRotated {ref, version, at} — never a value — through
the outbox, relayed onto a broadcast pub/sub channel; every app container wires
a PubSubSecretsChangeSource into its binder exactly like a poll watcher:
from forze_kits.integrations.secrets import (
PubSubSecretsChangeSource,
secret_rotated_pubsub_spec,
)
spec = secret_rotated_pubsub_spec()
query = ctx.deps.resolve_configurable(ctx, PubSubQueryDepKey, spec, route=spec.name)
binder = SecretsHotReloadBinder(sources=[PubSubSecretsChangeSource(query=query)], ...)
Pub/sub is at-most-once and live-only; a missed message is covered by the TTL floor. The channel needs no dedup, ordering, or persistence beyond the outbox's.
The rotator¶
SecretRotator runs one rotation as a durable four-step run per
(ref, tenant) — create → set → test → finish, the AWS Secrets Manager ordering,
because it is the known-safe one:
- create — mint from CSPRNG (
SecretEntropy; a seeded simulation source cannot be passed here), compose the pending value through the backend target, stage it at<path>.pending; - set —
RotationTargetPort.apply: make it valid at the backend (idempotent); - test —
RotationTargetPort.verify: a real connection, not a syntactic check. Failure halts the run before promote — promoting an unverified credential and evicting the fleet onto it is a self-inflicted outage; - finish — promote, confirm, then publish
SecretRotatedvia the outbox. Promotion is fenced in depth, because the distributed lock is advisory: the staged version must still be the one this run verified (no unverified text can be promoted); the write to the primary ref is compare-and-set against the version observed at create (a competing rotation that already promoted wins; the stale run fails loudly instead of clobbering it); and because a backend write likeALTER ROLEis not fenceable at all, the winner re-verifies the promoted credential after the promote and converges the backend if a stale apply landed late — a credential that still fails then fails the run loudly rather than publishing. Finally, a delayed reconfirmation run (reconfirm_after, default 90s) re-asserts the canonical credential past the stale apply's whole possible lifetime — client-side pool checkout plus the server-side statement timeout. The shipped Postgres target declares and enforces that composite bound itself, with no opt-out (apply_statement_timeout, default 30s, always applied viaSET LOCAL;pool_checkout_allowance, default 30s, covering the wait before the server clock starts); the rotator validatesreconfirm_afteragainst the target's declared bound at wiring and warns when a custom target declares none. Apply the same pairing to any custom target.
The pending ref is what makes this crash-safe: after set, the only copy of a
password already live at the backend exists durably in the secret store. A rotator
container that dies mid-rotation is reclaimed after its durable lease and resumes
from the last completed step; step results carry {ref, version} only, so secret
text never touches a journal. A distributed lock single-flights concurrent
rotations of one ref across replicas.
from forze_kits.integrations.secrets import SecretRotator
from forze_postgres import PostgresRotationTarget
target = PostgresRotationTarget(secrets=secrets, client=admin_pg, role_pair=("app_a", "app_b"))
rotator = SecretRotator(target=target)
rotator.register(durable_registry)
await rotator.rotate_now(ctx, dsn_ref) # admin trigger
await rotator.ensure_cron(ctx, dsn_ref, cron="0 4 * * 0") # weekly policy
Multi-tenant fleets enqueue one run per tenant (rotator.enqueue_tenants) — one
failing verify never blocks the rest, and a partial pass resumes where it stopped.
The targets: dual-principal alternation¶
PostgresRotationTarget defaults to two roles (app_a/app_b): the rotation
sets the minted password on the idle role, verifies it with a live connection,
and promotes a DSN naming that role. The previously-active role stays valid through
the whole propagation window and becomes the idle target of the next rotation — no
moment exists where a credential in flight is invalid.
Single-role ALTER ROLE ... PASSWORD is available behind an explicit
single_role_degraded=True: between promote and full propagation, new
connections with the old password fail (established ones survive), so with
connect-time re-resolution the blast radius is retry noise — but it is documented
degraded for a reason.
MongoRotationTarget is the same shape on MongoDB: two users, updateUser on the idle
one, a real authenticated ping to verify, and single_user_degraded=True for the
one-user case. It rotates the credential inside a mongodb:// or mongodb+srv:// URI and
leaves every other part of it — hosts, replica set, options — byte-identical.
from forze_mongo import MongoRotationTarget
target = MongoRotationTarget(
secrets=secrets, client=admin_mongo, user_pair=("app_a", "app_b")
)
Both targets declare an apply_latency_bound the rotator validates the reconfirmation
window against, and both enforce it at the server: statement_timeout on one,
maxTimeMS on the other. One MongoDB detail worth knowing if you manage those users
yourself: an updateUser carrying pwd recomputes the user's SCRAM mechanism set back to
the server default, so a deliberately narrowed mechanism list does not survive a rotation.
Run the rotator as a utility container in the outbox-relay shape: a headless
build_runtime(...) composition wiring {secrets + secrets_admin, durable, dlock,
outbox/pubsub, rotation targets, tenant directory} with the durable recovery and
scheduler steps — no HTTP surface. See the runnable walkthrough in
examples/recipes/secrets_rotation/.
Which backends have a target, and why the rest don't¶
A rotation target only exists where the framework can administer the backend's own principals. Everywhere else the answer is a different mechanism, not a missing one — so here is every credential-holding integration and the doctrine it falls under. "No target" is a decision on this page, never an omission.
| Backend | Doctrine | Why |
|---|---|---|
| Postgres | target (shipped) | Named roles, settable passwords, dual-user alternation |
| MongoDB | target (shipped) | updateUser over two users; same alternation |
| ClickHouse | target (planned) | Per-request auth makes the overlap window the only protection, so single-user mode is refused rather than degraded |
| Redis, RabbitMQ | leases | A Vault engine exists, and neither can bound a late write server-side (see below) — short TTLs are the better mechanism |
| Kafka, Meilisearch, Neo4j | undecided | No confirmed server-side bound; demand-gated until there is one |
| S3, SQS, GCS, BigQuery, Firestore | platform IAM | The credential is a cloud IAM artifact. Prefer ambient identity (IRSA, workload identity) and hold no static secret at all |
| Temporal, Inngest, outbound HTTP | operator-rotated | Keys are minted in a vendor console with no admin API; the framework's job ends at hot reload |
| Third-party OAuth grants | counterparty-rotated | The provider rotates at you — see below |
The line that decides the hard cases is whether the backend can kill a late write itself.
A rotator's delayed reconfirmation is only meaningful if a stale apply has a real deadline,
and a client-side timeout is not one: abandoning a request does not stop a write already at
the server. Postgres has SET LOCAL statement_timeout, MongoDB has maxTimeMS on
updateUser — both verified against live servers rather than assumed. Redis cannot: a stale
ACL SETUSER sitting in a socket buffer executes whenever the server gets to it, with no age
check. That is why Redis is a leases backend and not a target.
Every shipped target runs the same conformance battery against its real backend, including a deliberately stalled apply that must fail rather than land late.
When the other side rotates it¶
Everything above rotates a credential you control. Some third parties invert that: a provider doing refresh-token rotation burns the token you present and hands back a replacement, so the rotation has already happened by the time you learn of it. There is nothing to promote and nothing to verify — the only question is whether you survive it, and two failures make that a crash-consistency problem rather than a convenience.
The provider commits the burn before you can commit anything, so a process that dies holding an unpersisted replacement has destroyed the credential: the old token is dead, the new one is gone, and only a human re-authorization recovers it. And a concurrent exchange is destructive rather than merely wasteful — reuse detection treats a replayed refresh token as an attack and may revoke the entire token family.
RotatingCredentialStorePort owns both. Read, and when the token is spent hand the
version you read back:
credential = await store.get(SecretRef("oauth/crm"))
if credential.expires_before(utcnow()):
credential = await store.refresh(SecretRef("oauth/crm"), observed=credential.version)
That observed version is the whole single-flight mechanism. Under a per-credential
lock — in-process, plus a cross-process exclusion the store owns — it re-reads first: if
the stored version has moved past yours, someone already exchanged and you get their
credential instead of a second exchange. The replacement is committed before
refresh returns, so nothing ever observes a credential that is not durable, and a
write or commit that fails after a successful exchange raises credential_persist_lost
rather than a retryable-looking storage error — no retry can undo the provider's burn.
The converse matters just as much: once a token has been presented it is never presented again. Any ending that loses the outcome — a failed commit, or a timeout where no answer came back — leaves the grant marked unusable instead of restoring a row that still looks refreshable, because the next worker would otherwise replay a possibly-consumed token into the provider's reuse detection and lose the whole family. A timeout is transient for the network and terminal for the credential.
Your half is the exchanger: one bounded call to the provider's token endpoint. Its
one hard obligation is classification. Report a permanent rejection with
code=INVALID_GRANT_CODE and the store records a terminal burn notice
(credential_burnt), so callers escalate to re-authorization instead of hammering a
provider that has already said no. Report anything transient — timeout, 5xx, reset —
as anything else, and the stored credential is left untouched. Reporting a network
blip as an invalid grant destroys a working credential, so when the answer is
ambiguous, call it transient.
The refresh token never appears in a caller-facing type: get returns the access
token only, and the store hands the refresh token straight to the exchanger. A caller
cannot replay a rotated token because it never holds one.
Because every row is a replayable credential, the stored payload is sealed at rest by
default — plaintext requires an explicit acknowledge_plaintext=True, and wiring fails
closed if encryption is on with no keyring. The envelope's associated data binds each
credential to its (tenant, ref), so a row copied into another ref or tenant fails
authentication rather than decrypting into the wrong grant. expires_at stays a plain
column, so operators keep visibility into expiring grants without holding a key. Turning
sealing on needs no migration: existing plaintext reads through and seals on next write.
Two shipped stores answer the port, and the same conformance battery runs against both:
| Store | Exclusion | Notes |
|---|---|---|
forze_postgres.PostgresRotatingCredentialStore |
SELECT … FOR UPDATE, held across the exchange |
One row per (tenant_id, ref). A racer blocks on the row |
forze_mongo.adapters.rotating_credentials.MongoRotatingCredentialStore |
A lease on the credential's own document | One document per (tenant, ref). A racer waits for the lease, then converges. No transaction needed, so a standalone deployment is not excluded |
The difference is not a preference. Mongo has no blocking wait on a document — a second transaction writing it is aborted rather than queued — and it caps a transaction's lifetime, which a third party's token endpoint has no obligation to respect. So the Mongo store takes an explicit lease instead, and answers the one thing a lease has that a row lock does not: a lease expires. Each document records that its token was presented, so a worker inheriting an expired lease can tell the two cases apart — a holder that died before calling the provider leaves a grant that is still good, and one that died after leaves an outcome nobody knows, which is marked unusable rather than re-exchanged.
Mongo's store needs one index for the sweep's idleness scan:
db.<collection>.createIndex({tenant_id: 1, updated_us: 1})
updated_us is microseconds since the epoch, not a BSON date: dates carry milliseconds,
and a batch of grants stored back to back all land inside one — which would leave
"oldest first" decided by storage order rather than by the clock. Postgres gets the same
guarantee free from timestamptz.
See the runnable walkthrough in examples/recipes/rotating_credentials/.
On-demand refresh keeps a grant alive only while something uses it — and providers expire
refresh tokens from non-use (weeks to months, reset by every exchange), so an idle
tenant's grant dies silently no matter how correct the on-demand path is.
CredentialSweeper closes that gap: a scheduled durable sweep asks the control-plane scan
(RotatingCredentialsAdminPort.due_for_refresh) which grants sit unexchanged past a
configured idle window and enqueues one refresh run per grant, converging with live
traffic through the store's own single-flight. Burnt grants come back as a
needs_reauthorization list instead of retries. The idle window is per-provider
configuration set well inside the documented inactivity limit; see the
recipe.
Leases (dynamic credentials)¶
Where a backend adopts a lease engine (Vault database engines), short TTLs are
the rotation and the rotator becomes unnecessary for that backend. Credentials are
per-issuance (each container becomes a distinct DB principal) and revocation is
hard-edged (it kills established connections), so SecretsLeaseManager
renews at ~⅔ TTL with jitter, reissues-then-drains before max_ttl, and on
renewal failure retries forever while escalating log severity — abandoning means
certain credential death at TTL.
from forze_kits.integrations.secrets import SecretsLeaseManager
manager = SecretsLeaseManager(
dynamic=vault_dynamic, # forze_vault.VaultDynamicSecrets
roles=[SecretRef("app-readwrite")],
on_credential=rebuild_pool, # the same hot-reload path as any rotation
)
step = manager.lifecycle_step()
Security invariants¶
- Values never leave the resolve path. Not in change events, not in rotation
notifications, not in durable journals, not in logs, not in watcher snapshots.
Everything except
resolve_*/issuetraffics in{ref, version}. - No caching inside secrets adapters. Freshness is the entire point of this plane; a resolve-path cache reintroduces exactly the staleness the watcher exists to kill.
- New secrets are minted from
SecretEntropyonly — the type split makes a replayable entropy source unpassable, so a simulated rotator can never produce a predictable production credential.