Kubernetes pods as cloud identities: EKS IRSA vs. GKE Workload Identity Federation
How a Kubernetes ServiceAccount becomes a real cloud identity on AWS and Google Cloud - the OIDC federation trust chain behind EKS IRSA and GKE Workload Identity Federation, mapped one-to-one, with the wrinkles that break your muscle memory.
Both AWS and Google Cloud solve the same problem the same way: a pod authenticates to cloud APIs as itself, with a short-lived token and no stored secret, by turning its Kubernetes ServiceAccount into a federated cloud identity. On AWS the feature is IRSA (IAM Roles for Service Accounts); on Google Cloud it's Workload Identity Federation for GKE (renamed in 2024 from plain "Workload Identity"). Under the hood both are the same OIDC federation trick - but GKE hides much more of the plumbing, and that changes how you wire it, debug it, and reason about it.
The short version, if you only read one paragraph: the cluster signs a short-lived token for each pod's
ServiceAccount, and the cloud exchanges that token for real credentials scoped to one identity. On AWS you register an
IAM OIDC provider per cluster, create an IAM role, and gate it with a trust policy; the SDK inside the pod calls
AssumeRoleWithWebIdentity. On GKE you register nothing - every project has one permanent, Google-managed workload
identity pool named PROJECT_ID.svc.id.goog, every ServiceAccount is automatically a principal in it, and a node-local
metadata server does the token exchange so your app just uses Application Default Credentials as if it were on a plain
VM. Three things differ enough to bite you: GKE needs two enablement toggles (cluster and node pool), GKE can grant a
Kubernetes ServiceAccount cloud permissions with no cloud identity object at all, and - as always - pulling your
container image is a different identity from your workload identity.
The problem both solve
Before federation, giving a pod cloud permissions meant bad options: bake a static key into the image or a Secret (leaks, rotation pain), or grant the permission to the whole node so every pod on it inherits it (no isolation, wildly over-scoped). The AWS-side stopgaps were node-IMDS interceptors like kube2iam and kiam; the GCP-side one was mounting a node service-account key into pods. All of them were fiddly, over-broad, and race-prone.
Federation replaces that with cryptographic trust. The cluster projects a signed, short-lived OIDC token scoped to the pod's ServiceAccount. The cloud verifies that token against the cluster's published keys and, if the token's identity matches a rule you configured, hands back credentials for exactly one cloud identity. No secret is stored anywhere; the token auto-rotates (default lifetime about an hour) and is useless outside the cluster.
The shared mechanism: OIDC federation
Every workload-identity setup, on either cloud, is the same trust triangle:
- The cluster is an OIDC issuer. It exposes a discovery document and a JWKS endpoint with the public keys it signs ServiceAccount tokens with. That issuer is the trust anchor.
- The cloud trusts that issuer for a specific ServiceAccount identity. AWS expresses the trust per role; GCP expresses it once per project, permanently, and Google manages it for you.
- The workload exchanges the projected token for cloud credentials and receives short-lived credentials for a single cloud identity.
Everything else is naming - and how much of step 2 you have to build yourself. That's where the two clouds diverge most.
The one-to-one map
- The Kubernetes side. On AWS you annotate the ServiceAccount with
eks.amazonaws.com/role-arn: <role arn>. On GKE, in the modern direct model, you annotate nothing on the ServiceAccount at all - you just reference it as an IAM principal. (The older impersonation model does use an annotation; more below.) - The "OIDC provider." On AWS this is the EKS cluster's OIDC issuer, which you register once in IAM as an OIDC
identity provider - per cluster. On GKE it is the project's workload identity pool
PROJECT_ID.svc.id.goog, created automatically and managed by Google; you never register it, and every ServiceAccount in every cluster in the project is already a principal inside it. - The identity. AWS: an IAM role. GKE direct model: no dedicated identity object - the Kubernetes ServiceAccount itself is the principal. GKE impersonation model: a Google service account the KSA impersonates.
- The trust rule. AWS: the IAM role's trust policy pins
sub = system:serviceaccount:<ns>:<sa>andaud = sts.amazonaws.com. GKE direct: there is no separate trust document - you grant a role directly to a principal identifier that encodes the namespace and ServiceAccount. GKE impersonation: you grant the KSA theroles/iam.workloadIdentityUserrole on the Google service account. - The permissions. AWS: an IAM policy attached to the role. GKE: IAM allow-policy role bindings on the
target resource (or project) -
roles/storage.objectViewer,roles/secretmanager.secretAccessor, and so on. This is the deeper split: AWS staples a policy to the identity; GCP grants a role at the resource's scope. - The exchange. AWS: the SDK inside the pod calls
sts:AssumeRoleWithWebIdentitywith the projected token. GKE: the node's metadata server does the exchange transparently and your app uses Application Default Credentials with no exchange code at all.
The rest of this post walks each cloud end to end, then dwells on the differences that actually trip people up.
Walk-through: AWS EKS IRSA, end to end
The pieces, in the order the trust flows:
1. The cluster OIDC issuer, at a URL like https://oidc.eks.<region>.amazonaws.com/id/<hash>. You register it once
in IAM as an OIDC identity provider so IAM will trust tokens it signs.
2. The IAM role and its trust policy. The trust policy makes the role assumable by one ServiceAccount and nothing else:
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::<account>:oidc-provider/oidc.eks.<region>.amazonaws.com/id/<hash>" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.<region>.amazonaws.com/id/<hash>:sub": "system:serviceaccount:apps:checkout",
"oidc.eks.<region>.amazonaws.com/id/<hash>:aud": "sts.amazonaws.com"
}
}
}
Pin both :sub and :aud - pinning only the issuer would let any ServiceAccount in the cluster assume the role.
3. The IAM policy attached to the role grants what the app needs (s3:GetObject on one bucket, and so on).
4. The ServiceAccount carries one annotation:
apiVersion: v1
kind: ServiceAccount
metadata:
name: checkout
namespace: apps
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::<account>:role/checkout
5. The projection. EKS ships the Pod Identity Webhook built into the cluster. When a pod uses the annotated
ServiceAccount, the webhook injects AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE and mounts a projected token
(audience sts.amazonaws.com, auto-rotated).
6. The exchange. The AWS SDK notices AWS_WEB_IDENTITY_TOKEN_FILE, calls sts:AssumeRoleWithWebIdentity, and
caches the returned short-lived credentials. Your code is just boto3.client("s3").
Newer alternative: EKS Pod Identity (2023) does the same job through an on-cluster agent and an association API instead of a per-cluster IAM OIDC provider and per-role trust policy - which, notably, makes it feel a lot more like GKE's managed model. Both IRSA and Pod Identity coexist in production; IRSA is the one that maps cleanly to the OIDC-federation framing, so it's the one to hold in your head here.
Walk-through: GKE Workload Identity Federation, end to end
Same trust flow, far less to build:
1. Two enablement toggles. Enable the workload identity pool on the cluster and the metadata server on the node pool:
gcloud container clusters update <cluster> --workload-pool=<project-id>.svc.id.goog
gcloud container node-pools update <pool> --cluster=<cluster> --workload-metadata=GKE_METADATA
Both are required. The cluster flag opts the cluster into the project's pool; the node-pool flag deploys the GKE
metadata server (a DaemonSet on every node) that will intercept credential requests. A pod on a node pool without
GKE_METADATA gets the node's identity, not its own - a silent, over-broad fallback.
2. The pool already exists. <project-id>.svc.id.goog is created automatically for the project; you never register
an OIDC provider, and there is no per-account provider limit to hit. Every ServiceAccount in the project's clusters is
already a principal.
3a. Direct access (the modern default). Grant a role straight to the ServiceAccount, addressed as an IAM principal. No Google service account, no annotation:
gcloud storage buckets add-iam-policy-binding gs://<bucket> \
--role=roles/storage.objectViewer \
--member="principal://iam.googleapis.com/projects/<project-number>/locations/global/workloadIdentityPools/<project-id>.svc.id.goog/subject/ns/apps/sa/checkout"
The principal path encodes ns/apps/sa/checkout - the namespace and ServiceAccount are the trust rule; there is no
separate trust document to write.
3b. Impersonation (the older model, still needed for a few services). Create a Google service account, let the KSA impersonate it, and annotate the KSA:
gcloud iam service-accounts add-iam-policy-binding checkout@<project-id>.iam.gserviceaccount.com \
--role=roles/iam.workloadIdentityUser \
--member="serviceAccount:<project-id>.svc.id.goog[apps/checkout]"
apiVersion: v1
kind: ServiceAccount
metadata:
name: checkout
namespace: apps
annotations:
iam.gke.io/gcp-service-account: checkout@<project-id>.iam.gserviceaccount.com
Here the Google service account holds the permissions (via normal role bindings), and the KSA borrows them.
4. The exchange. When your app asks for credentials, the GKE metadata server intercepts the request to
http://metadata.google.internal, fetches a ServiceAccount JWT from the Kubernetes API for that pod, exchanges it via
the Security Token Service for a short-lived federated access token (default 1 hour, refreshed proactively before
expiry), and returns it. Your app uses Application Default Credentials and the standard Google Cloud client
libraries - it behaves exactly as if it were running on a GCE VM. There is no webhook injecting env vars, no
AZURE_*-style variables, and no exchange call in your code: a Cloud Storage read is just storage.Client() in Python.
The subject is the linchpin (on both clouds)
The value that must match exactly, on both clouds, is the ServiceAccount identity:
- AWS pins the token subject
system:serviceaccount:<namespace>:<serviceaccount>in the role's trust policy. - GKE direct encodes the same pairing in the principal path
.../subject/ns/<namespace>/sa/<serviceaccount>. - GKE impersonation encodes it in the member
serviceAccount:<project-id>.svc.id.goog[<namespace>/<serviceaccount>].
The most common "it authenticates as nobody" failure on either cloud is a mismatch here: the chart deployed into a
different namespace, the ServiceAccount got the release name instead of the app name, or the pod fell back to the
default ServiceAccount. When federation silently fails, check the namespace and ServiceAccount name first.
Differences that will actually trip you up
1. GKE registers the "OIDC provider" for you - once, per project, forever. On EKS you register an IAM OIDC provider
per cluster, and at fleet scale you can hit the soft limit of 100 OIDC providers per AWS account and have to work around
it. On GKE there is exactly one pool per project (PROJECT_ID.svc.id.goog), created and managed by Google, shared by
every cluster in the project. Nothing to register, nothing to cap. The flip side: the trust is project-wide by default,
so you scope with IAM bindings (which namespace and ServiceAccount you grant), not by carving up providers.
2. GKE exchanges the token at a node-local metadata server, not with an in-pod SDK call. IRSA mutates the pod (a
webhook injects env vars and a projected token) and the SDK calls STS. GKE intercepts metadata.google.internal on the
node and hands back credentials transparently, so the app uses Application Default Credentials as if on a plain VM. Two
consequences: your app needs no cloud-specific credential code, and the node pool must have the metadata server
enabled (GKE_METADATA) or the pod silently gets the node's identity instead of its own.
3. GKE can grant a ServiceAccount cloud permissions with no cloud identity object. In IRSA there is always an IAM
role. In GKE's direct model there is nothing to create on the cloud side except the role binding itself - the
Kubernetes ServiceAccount is the principal (principal://.../subject/ns/NS/sa/SA). The older impersonation model does
introduce a Google service account and the iam.gke.io/gcp-service-account annotation, and you still need it for the
handful of services that don't accept a federated principal directly - but reach for direct binding first.
4. GKE needs two toggles, like Azure does. Cluster (--workload-pool) plus node pool
(--workload-metadata=GKE_METADATA). Miss the node-pool half and there's no failure, just the wrong (node) identity.
IRSA folds the projection into EKS itself, so there's no equivalent second switch.
5. Pulling the image is a different identity from the workload identity - on both clouds. Image pull is the
node identity's job: on GKE the node pool's Google service account needs roles/artifactregistry.reader to pull
from Artifact Registry; on EKS the node group's instance role needs AmazonEC2ContainerRegistryReadOnly (or you use
pull secrets). Workload identity is only for when the app code calls a cloud API. A pure HTTP service that never
talks to a cloud SDK needs no workload identity at all - and granting a workload identity artifactregistry.reader
does nothing for image pull, because the pull happens before the app (and its federated token) ever runs.
6. Permissions attach differently. AWS staples an IAM policy to the role - the permission travels with the identity. GCP grants an IAM role binding at the target resource's scope (or the project) - the grant lives on the thing being accessed, not on the identity. Same end state, different place to audit it: on AWS read the role's attached policies; on GCP list the resource's IAM policy (or use Policy Analyzer for what a principal can reach).
What the app code does
Both clouds end at "no secret, no credential code," but they get there differently:
- AWS: the webhook sets
AWS_ROLE_ARNandAWS_WEB_IDENTITY_TOKEN_FILE; the SDK's default chain callsAssumeRoleWithWebIdentity. Your code:boto3.client("s3"). - GKE: nothing is injected into the pod; the SDK's Application Default Credentials chain hits the node metadata
server, which does the exchange. Your code:
storage.Client().
In both cases credentials are short-lived and refreshed transparently. There is nothing to rotate, nothing to store, and nothing to leak.
A debugging checklist that works on both clouds
When a pod "can't authenticate," walk the trust chain in order:
- Cluster/project federation on? EKS: the IAM OIDC provider is registered. GKE: the cluster has
--workload-pooland the node pool hasGKE_METADATA. On GKE, a missing node-pool toggle is the classic silent failure - the pod gets the node identity, not its own. - Right ServiceAccount? The pod actually uses the ServiceAccount you think it does (not
default). Exec in and check. - Identity match? Compare the trust rule to the real namespace and ServiceAccount:
system:serviceaccount:<ns>:<sa>on AWS,.../subject/ns/<ns>/sa/<sa>(or[ns/sa]) on GKE. This is the most frequent failure on both. - Exchange working? EKS: the pod has
AWS_WEB_IDENTITY_TOKEN_FILE. GKE:curl -H "Metadata-Flavor: Google" metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/emailfrom the pod returns the expected identity. - Permissions? Only after the identity resolves: the IAM policy on the role (AWS) or the role binding on the target resource (GCP). An empty result here is an authorization failure, not an authentication one - which tells you federation itself is working.
Which certifications drill this
Workload identity sits right where Kubernetes, cloud IAM, and OIDC federation meet, so it shows up across three exam tracks. If you already hold one cloud's credential, the same concept on the other cloud is a short hop.
On the AWS side:
- AWS Certified Solutions Architect - Associate (SAA-C03) - IAM roles, EKS, and how identities get cloud permissions.
- AWS Certified Security - Specialty (SCS-C03) - IAM trust policies, OIDC federation, and least-privilege scoping (exactly the IRSA trust chain).
On the Google Cloud side:
- Google Cloud Associate Cloud Engineer - GKE, IAM, service accounts, and role bindings.
- Google Cloud Professional Cloud Security Engineer - workload identity, IAM depth, service-account security, and least privilege.
On the Kubernetes side:
- CNCF Certified Kubernetes Administrator (CKA) - ServiceAccounts, projected tokens, and pod specs.
- CNCF Certified Kubernetes Security Specialist (CKS) - ServiceAccount token security, least privilege, and reducing static-secret exposure.
The bottom line
IRSA and GKE Workload Identity Federation are the same idea wearing different amounts of plumbing: the cluster signs a
short-lived OIDC token for a ServiceAccount, and the cloud exchanges it for credentials scoped to a single identity.
AWS makes you assemble the trust yourself - an OIDC provider per cluster, an IAM role, a trust policy, a projected token
the SDK exchanges. GKE hands you a managed, project-wide pool, lets you bind a role straight to the Kubernetes
ServiceAccount with no cloud identity at all, and does the exchange at a node metadata server so your app never sees a
credential. Learn the trust triangle once and the translation is mechanical: role becomes principal binding (or a
service account you impersonate), trust policy becomes an IAM member string, attached policy becomes a role binding on
the resource, and AssumeRoleWithWebIdentity becomes an invisible metadata-server exchange. Keep three things front of
mind - GKE's two toggles, its no-identity-object direct model, and the fact that image pull is a different identity
entirely - and the denials that used to look random start reading as a coherent, deliberate design.