How pods can talk to AWS, GCP, and Azure with no static keys — using audience-bound projected ServiceAccount tokens and the cluster OIDC issuer.
Every incident review I've sat through that started with "how did an attacker read our S3 bucket" ended in the same place: a long-lived access key sitting in a Kubernetes Secret, mounted into a pod, never rotated, and copied into three other namespaces because someone needed it fast. Static cloud credentials in a cluster are the worst kind of liability. They don't expire, they're readable by anyone with get secrets in the namespace, they end up in env dumps and crash logs, and when they leak you have no idea which of forty deployments actually used them.
The fix is to stop putting keys in the cluster at all. Modern Kubernetes hands each pod a short-lived, audience-scoped token, and each cloud IAM trusts your cluster's OIDC issuer to validate it. This is workload identity federation: the pod proves who it is with a token that expires in an hour, and the cloud exchanges it for real credentials. No secret to leak.
The old ServiceAccount token was a JWT with no expiry, stored in a Secret, mounted at a fixed path. It was a bearer token for the whole cluster lifetime. Projected tokens replace that. The kubelet requests a token from the API server on the pod's behalf, scopes it to a specific audience, gives it a TTL, and rotates it before it expires — all without a Secret object existing anywhere.
The volume looks like this:
volumes:
- name: aws-token
projected:
sources:
- serviceAccountToken:
path: token
audience: sts.amazonaws.com
expirationSeconds: 3600
Three things matter here. audience binds the token to one relying party — a token minted for sts.amazonaws.com is rejected by GCP and vice versa. expirationSeconds sets the TTL; the kubelet refreshes the file in place at roughly 80% of the lifetime, so your SDK just re-reads the path. And the token never touches etcd as a Secret. If a pod is compromised, the attacker gets a token good for at most an hour, usable against exactly one audience.
For any cloud to trust these tokens, it needs to fetch your cluster's signing keys. The API server exposes an OIDC discovery document at /.well-known/openid-configuration and a JWKS endpoint with the public keys. Check what your issuer URL is:
kubectl get --raw /.well-known/openid-configuration | jq .issuer
On EKS this is an oidc.eks.<region>.amazonaws.com/id/<hash> URL that AWS hosts for you. On self-managed clusters you configure it with --service-account-issuer on the API server, and you must publish the discovery and JWKS documents somewhere the cloud provider can reach over HTTPS. That last part trips people up: the issuer has to be publicly resolvable, or configured explicitly in the cloud's federation setup. A private issuer URL that only resolves inside your VPC will fail validation every time.
The pattern is identical across providers: annotate the ServiceAccount, tell the cloud to trust system:serviceaccount:<namespace>:<name> as a subject, done.
AWS (IRSA). Create an IAM role with a trust policy tied to your OIDC provider, then annotate the SA:
apiVersion: v1
kind: ServiceAccount
metadata:
name: billing-worker
namespace: payments
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/billing-s3-reader
The EKS pod identity webhook sees the annotation and injects the projected token volume and AWS_WEB_IDENTITY_TOKEN_FILE for you. EKS Pod Identity (the newer approach) skips the OIDC provider entirely and uses an agent on the node, which is simpler to manage if you're all-in on EKS, but IRSA is still what you want for portability.
GCP (Workload Identity). Bind the KSA to a Google service account:
gcloud iam service-accounts add-iam-policy-binding \
billing@my-project.iam.gserviceaccount.com \
--role roles/iam.workloadIdentityUser \
--member "serviceAccount:my-project.svc.id.goog[payments/billing-worker]"
metadata:
annotations:
iam.gke.io/gcp-service-account: billing@my-project.iam.gserviceaccount.com
Azure (Workload Identity). Create a federated credential on the app registration pointing at your issuer and the subject system:serviceaccount:payments:billing-worker, then annotate:
metadata:
annotations:
azure.workload.identity/client-id: 00000000-0000-0000-0000-000000000000
Here's the SA plus a Deployment that mounts the token itself, which is what you do when the cloud SDK doesn't auto-inject:
apiVersion: apps/v1
kind: Deployment
metadata:
name: billing-worker
namespace: payments
spec:
replicas: 2
selector:
matchLabels: { app: billing-worker }
template:
metadata:
labels: { app: billing-worker }
spec:
serviceAccountName: billing-worker
containers:
- name: worker
image: registry.internal/billing-worker:1.9.2
env:
- name: AWS_ROLE_ARN
value: arn:aws:iam::111122223333:role/billing-s3-reader
- name: AWS_WEB_IDENTITY_TOKEN_FILE
value: /var/run/secrets/aws/token
volumeMounts:
- name: aws-token
mountPath: /var/run/secrets/aws
readOnly: true
volumes:
- name: aws-token
projected:
sources:
- serviceAccountToken:
path: token
audience: sts.amazonaws.com
expirationSeconds: 3600
Audience mismatch is the number one failure. The aud claim in the token must equal what the cloud's federation config expects — sts.amazonaws.com for AWS STS, api://AzureADTokenExchange for Azure. Get it wrong and you get an opaque "invalid identity token" with no hint which field is off.
Issuer reachability, as above: if the cloud can't GET your JWKS, nothing works, and the error rarely says so plainly.
Clock skew matters because these tokens are short-lived and validated on nbf/exp. If your nodes drift more than a couple of minutes from the cloud's clock, valid tokens get rejected as expired or not-yet-valid. Run NTP and mean it.
Finally, don't set expirationSeconds too low chasing security theater. Under 600 seconds the kubelet may clamp it, and you add refresh churn for no real gain since the token is already single-audience and single-pod. If you want cryptographic identity that travels across service meshes rather than just to cloud IAM, look at SPIFFE instead — it's the right tool for in-mesh mTLS.
Kill every static cloud key in your clusters and federate through the OIDC issuer. On EKS, use IRSA unless you're certain you'll never leave EKS, in which case Pod Identity is less to babysit. Set token TTL to 3600, pin the audience explicitly per cloud, and put a CI check in place that fails any manifest mounting an AWS or GCP key from a Secret. The migration is a few hours per workload; the alternative is explaining to an auditor why a five-year-old access key was still valid.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Buffered SSR makes users wait for the slowest query before they see anything. Streaming from the edge flips that — send the shell now, fill the gaps as data lands.
A shared API key between two internal services proves nothing about who is calling. mTLS makes every service present a cryptographic identity instead.
Explore more articles in this category
GitHub Actions OIDC gets all the attention, but GitLab, Buildkite, and CircleCI issue the same signed tokens. Here's how to trust them without opening a hole.
Our best engineer quit citing on-call. We rebuilt the whole thing: saner rotations, runbooks that actually help at 3am, and escalation that doesn't punish asking for help.
We used to ship code and turn it on in the same breath, so every deploy was a bet. Feature flags split those two events apart and made rollbacks a config toggle.
Evergreen posts worth revisiting.