External secret stores (SEC-011)

Embed
Preview of the card
<a href="https://forumulate.com/p/watchtower/docs/secret-stores"><img src="https://forumulate.com/api/public/orgs/watchtower/spaces/docs/pages/c5df8066-926a-4bd7-9ef9-38e5c7f4013d/social-card.svg?v=3e7ae33eec8b" alt="External secret stores (SEC-011)" width="600" height="315"></a>

Reference a secret instead of pasting it: wt+secret://<store-name>/<path>#<key>. Path segments and the key use [A-Za-z0-9_.-]; empty, . and .. segments are rejected.

Manage stores at /v1/orgs/{org}/secret-stores (needs manage_security).

Kind

Config

Notes

vault

addr, namespace?, mount, auth (token, approle, kubernetes), role?

KV v2. Credential: token string, or {role_id, secret_id}.

aws_sm

region, endpoint?, role_arn?

SigV4. Sealed static keys, else the SDK default chain (env, IRSA, instance role). With role_arn those credentials call STS AssumeRole and the temporary ones sign.

gcp_sm

project

Sealed service-account key, else workload identity.

  • env, file and k8s stores no longer exist: they read the server's own environment and disk. They cannot be created, and rows from before this are refused at read time.

  • A store without its own credential would use the server's identity (Kubernetes service-account token, cloud role or metadata token). That is refused unless the operator lists the host in WT_SECRET_STORE_AMBIENT_HOSTS (comma-separated, empty by default). This covers Vault kubernetes auth, aws_sm and gcp_sm.

  • Endpoints go through the SSRF guard. Private addresses need "allow_private": true in the config.

  • Values are cached 60 s in memory, zeroed on eviction, never stored, logged or audited. POST .../{id}/test returns {ok, error_code} only.

  • Rust services resolve vault (token auth); a reference into another kind is rejected at save time with 422 unsupported_in_this_service.

  • An unresolved reference marks the owning object unhealthy with error class secret_unresolved; it never crashes the service.

  • Deleting a store that receivers, export destinations or archives still reference returns 409 listing them.

Who may use a store

  • A store is usable by no project until PUT /v1/orgs/{org}/secret-stores/{id}/grants (manage_security) allows one: {"grants": [{"project_id", "path_prefix"}]}. A prefix covers that path and everything under it; empty is the whole store.

  • Saving a reference needs manage_credentials and a matching grant. It is audited as secret_ref.bound. Sending a receiver's stored reference back unchanged needs neither, but cannot move the receiver.

  • Both are checked again on every read. A reference saved before this, or whose grant was removed, fails with secret_ref_not_permitted until someone permitted saves it again.

  • manage_credentials holders can send a value they may bind, or a vault value, to a host of their own: that role is trusted with those secrets. Monitors and load tests record the names and target in the audit log (vault_names, target, allowed_hosts); values are never recorded.

Using a reference

Send credentials_ref instead of the secret value:

Object

Where the ref is kept

What the secret must hold

Stores

Receiver

config.credentials_ref

JSON like secrets (webhook: {"url","secret"}), or a plain string used as secret

all kinds (the notifier is Go)

Export destination

config.credentials_ref

JSON like secret (s3: access_key_id, secret_access_key; otlp_http: headers)

vault (token auth), file, env

Archive

archive_configs.credentials_ref

{"access_key_id","secret_access_key"}

all kinds (the archiver is Go)

Uptime monitor

any *_ref config value

the value itself

all kinds

Send either the value or the reference, never both (422). The reference is not secret and is returned as given. The reference replaces sealed secrets on update. Receivers and archives read the secret at delivery, so a rotated value takes effect within 60 s.

Updated