Skip to content
Presented at KCD Budapest · early development

Waterwheel

Workload identity for Kubernetes. Your pods call AI and cloud APIs with short-lived tokens, and never hold a key.

An API key in a pod can be taken by anyone who gets into the pod. It sits in an environment variable or a Secret, where every process, every kubectl exec and every leaked backup can read it.

Anthropic, OpenAI and the cloud providers now offer workload identity federation instead. The workload proves who it is with a signed token and gets a short-lived one back. Every pod already has such a token: Kubernetes signs one for its service account. Waterwheel uses it for every pod, without changing the app.

We presented the idea at KCD Budapest on 30 October 2026. In the live demo, a pod with no key called Claude: SPIRE gave it an identity, tokenex exchanged that for a short-lived Anthropic token, and an egress sidecar added the token on the way out.

Waterwheel turns that demo into a product. By default the pod’s service account token takes SPIRE’s place, so there’s nothing extra to install. SPIRE is still an option, and the demo’s five steps run as an automated test with both.

Talk KCD Budapest · 30 October 2026

Every Workload Deserves an Identity Instead of an API Key

Zsolt Varga and Nándor Krácser, Riptides

A live demo on kind: a pod with no key calls Claude, through SPIRE, tokenex and an egress sidecar.

  1. 1The pod has no key
  2. 2The API call fails
  3. 3Turn on the egress proxy
  4. 4The same command succeeds
  5. 5Nothing to steal

The example is support-bot, a Python service in the support namespace that answers customer tickets with Claude through Anthropic’s Python SDK.

The pod’s identity comes from its Kubernetes service account token, the default, or from SPIRE. Everything else works the same way.

KUBERNETES CLUSTERNODEPOD support-bot · SA support-botANTHROPICkube-apiserversigns SA tokenswaterwheel-controllerbindings · injectorappPython · anthropic SDKwaterwheel-proxysidecar · :3128SA tokenssidecar onlykubeletwrites, rotatesCluster's JWKSissuer URL or uploadToken endpointworkload identity federationapi.anthropic.comMessages APIpublishes the issuer's keysissues tokens1one per audience2my bindings?TokenReview3CONNECTno key4exchange5checks6+ tokenshort-lived

Waterwheel’s components have teal outlines. The app never sees a key or a token: the service account tokens are mounted only into the sidecar, and the Anthropic token only exists in its memory.

  1. The kubelet writes the pod’s service account tokens, signed by the API server, into a volume only waterwheel-proxy mounts. There’s one per audience the pod’s bindings need, and the kubelet replaces them before they expire.

  2. waterwheel-proxy, a sidecar in the same pod, asks waterwheel-controller for its bindings. It proves who it is with its token, which the controller checks with a TokenReview.

  3. The app sends its request through HTTPS_PROXY, which the injector set. It has no key.

  4. The proxy exchanges the service account token at Anthropic for a short-lived token.

  5. Anthropic checks the token’s signature against the cluster’s published keys, and that its subject is system:serviceaccount:support:support-bot.

  6. The proxy adds the token to the request and sends it on.

Ordinary SDK code, with no Waterwheel library and no key. The SDK picks up the proxy and the CA bundle from HTTPS_PROXY and SSL_CERT_FILE, which the injector sets.

import anthropic
# There's no API key in the pod. The SDK needs some value to send;
# waterwheel-proxy removes it and adds a short-lived token instead.
client = anthropic.Anthropic(api_key="unused")
def answer(ticket: str) -> str:
response = client.beta.messages.create(
model="claude-opus-5-5",
max_tokens=16000,
betas=["server-side-fallback-2026-07-01"],
fallbacks="default",
system="You answer customer support tickets for Example Corp. Be brief and friendly.",
messages=[{"role": "user", "content": ticket}],
)
if response.stop_reason == "refusal":
return "A person from our team will follow up on this one."
return "".join(block.text for block in response.content if block.type == "text")

Two objects say which credential the pod’s service account gets, and for which destinations. A namespace label turns the sidecar on.

apiVersion: core.riptides.io/v1alpha1
kind: CredentialSource
metadata: {name: anthropic, namespace: support}
spec:
anthropic:
organizationId: f5a4ab71-21b3-44b8-a3fb-54097ce876ad
workspaceId: default
serviceAccountId: svac_01WDRm8rfFaGYa5VVFzEYlF0
federationRuleId: fdrl_01Hx1vufYmRP9iyeD7UdKvyZ
---
apiVersion: core.riptides.io/v1alpha1
kind: CredentialBinding
metadata: {name: support-bot-anthropic, namespace: support}
spec:
workloadID: ns/support/sa/support-bot # pods running as this service account
credentialSource: anthropic
propagation:
injection:
destinations:
- host: api.anthropic.com
Terminal window
kubectl label namespace support waterwheel.riptides.io/inject=enabled

No keys in pods

The app container gets no key, no token and no socket. A shell in the pod finds nothing to steal.

Nothing extra to run

Kubernetes signs and rotates the identity tokens, so there’s no agent on the nodes. With SPIRE, WorkloadIdentity selectors give pods that share a service account their own SPIFFE IDs.

Namespaces are the boundary

A binding only applies to service accounts in its own namespace. This is checked at admission and again when the controller hands out bindings.

Many token endpoints

Anthropic, OpenAI, Google Cloud and Microsoft Entra ID (Azure) federation, and any RFC 7523 or RFC 8693 endpoint. AWS is next.

Trust without setup

The sidecar’s CA goes into the pod’s system trust store and the usual environment variables, so curl, Python, Node.js and Go accept it.

Open source

Apache-2.0. Token exchange runs on tokenex, our open-source exchange library.

Waterwheel is built by Riptides.