# Kubernetes Workload Identity — Projected Tokens and OIDC to Cloud IAM

URL: https://www.devopsness.com/blog/kubernetes-workload-identity-projected-tokens-oidc
Author: Kiril Urbonas
Published: 2026-07-10
Updated: 2026-07-10
Categories: DevOps
Tags: Kubernetes, Security, Cloud, OIDC, Identity

Summary: How pods can talk to AWS, GCP, and Azure with no static keys — using audience-bound projected ServiceAccount tokens and the cluster OIDC issuer.

---
# Kubernetes Workload Identity — Projected Tokens and OIDC to Cloud IAM

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](/blog/keyless-cloud-authentication-ending-static-credentials): 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.

## Projected ServiceAccount tokens

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:

```yaml
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.

## The cluster OIDC issuer

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:

```bash
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.

## Federating the ServiceAccount to each cloud

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:

```yaml
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:

```bash
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]"
```

```yaml
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:

```yaml
metadata:
  annotations:
    azure.workload.identity/client-id: 00000000-0000-0000-0000-000000000000
```

## A full manifest

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:

```yaml
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
```

## Gotchas that will cost you an afternoon

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](/blog/zero-trust-service-auth-spiffe-spire) instead — it's the right tool for in-mesh mTLS.

## The call we'd make

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.

