Build an MCP Server in Go (Part 3): How IRSA and EKS Pod Identity Actually Work A developer's blog post explains the differences between IAM Roles for Service Accounts (IRSA) and EKS Pod Identity in Kubernetes, detailing how each mechanism extends a pod's ServiceAccount identity to AWS for scoped permissions. The post clarifies that a ServiceAccount is Kubernetes' native identity concept, and both IRSA and Pod Identity build on it to grant pods distinct AWS roles, avoiding the security issue of inheriting the node's IAM role. Everything the MCP server could check through Part 2 https://dev.to/ferztyle/build-an-mcp-server-in-go-part-2-building-the-mcp-tool-layer-4l3m lives inside the cluster. But "my pod can't reach S3" is very often not a Kubernetes problem at all, it's a workload identity problem, and that identity lives partly on the Kubernetes side the ServiceAccount and partly on the AWS side IAM, and now EKS Pod Identity . Before building a tool that diagnoses it, it's worth actually understanding how it works; most of the confusion people run into comes from treating IRSA and Pod Identity as interchangeable, when they solve the same problem through genuinely different mechanisms. Before touching the diagnostic tool, it's worth stopping and explaining this properly, because "how does a pod get AWS permissions" trips up almost everyone the first time, and the two mechanisms work in genuinely different ways under the hood. An EC2 instance gets AWS credentials through an instance profile, no secrets anywhere, the instance just asks a local metadata endpoint IMDS and gets temporary credentials back, automatically. A pod is not an instance. Dozens of pods can run on the same EC2 node, each one potentially needing different AWS permissions; the checkout-service pod should be able to write to one S3 bucket, the image-resizer pod to a different one, and neither should be able to touch what the other can. If every pod on a node just inherited the node's IAM role, that isolation is gone; any pod could do anything the node itself could do. Before: Both IRSA and Pod Identity exist to solve exactly this: giving individual pods their own, scoped-down AWS identity, distinct from the node they happen to be running on and from every other pod sharing that node. Now: Both mechanisms above lean on something Kubernetes already has, so it's worth pinning down before going further: a ServiceAccount is Kubernetes's own concept of "an identity a pod runs as," entirely separate from AWS and predating both IRSA and Pod Identity by years. It has nothing to do with AWS by default, its original job is purely internal to the cluster. Every pod runs as some ServiceAccount, whether you set one explicitly or not, if you don't specify one, the pod gets the default ServiceAccount for its namespace automatically. A ServiceAccount is a real Kubernetes object, small and unremarkable on its own: apiVersion: v1 kind: ServiceAccount metadata: name: checkout-service namespace: production Its native purpose is to authenticate to the Kubernetes API server itself, not AWS. Kubernetes automatically mounts a token for the pod's ServiceAccount into the container filesystem, and that token is what lets, say, a controller running inside the cluster call kubectl -equivalent API operations as "the checkout-service identity", with whatever RBAC permissions Role / RoleBinding or ClusterRole / ClusterRoleBinding have been granted to that ServiceAccount. This is the exact same object check endpoints and describe pod implicitly rely on: the RBAC section back in Part 2 https://dev.to/ferztyle/build-an-mcp-server-in-go-part-2-building-the-mcp-tool-layer-4l3m that scoped this MCP server's own ServiceAccount to get / list / watch ; that's this same mechanism, just describing what the server itself is allowed to do inside the cluster. What both IRSA and Pod Identity do is extend that existing identity outward, to AWS, instead of inventing a new one. That's why IRSA works by annotating a ServiceAccount rather than creating some new AWS-specific object, and why Pod Identity's association is keyed on a namespace/ServiceAccount pair rather than anything else. A ServiceAccount was already sitting there as "the identity of this pod" in Kubernetes' own model, and both mechanisms are answers to the same follow-up question: how do I get thatidentity recognized by AWS too? A ServiceAccount by itself is just an identity, a name Kubernetes can authenticate. It comes with zero permissions attached. What decides what that identity is actually allowed to do inside the cluster is a separate system: RBAC , Role-Based Access Control. RBAC works through four object types, and the relationship between them is the whole model: Role or ClusterRole , the cluster-wide version , a set of permissions, expressed as verbs on resources. Something like "can get and list pods in the production namespace." A Role on its own grants nothing to anyone, it's a description of a permission set, sitting unused until something is bound to it. RoleBinding or ClusterRoleBinding , the thing that actually connects a Role to an identity. This is where a ServiceAccount or a user, or a group gets named and paired with a Role , and only once that binding exists does the permission become real. Put together:a ServiceAccount iswho, a Role iswhat's allowed, and a RoleBinding is thebecause I said soconnecting the two. Kubernetes checks all three together on every single API request, "is the ServiceAccount making this call bound, via some RoleBinding, to a Role that permits this specific verb on this specific resource", and denies anything that doesn't match. This is exactly what Part 2's https://dev.to/ferztyle/build-an-mcp-server-in-go-part-2-building-the-mcp-tool-layer-4l3m "RBAC scope the service account, not just the interface" practice was describing, now with the actual mechanism behind it named: this MCP server runs under its own ServiceAccount, which needs a ClusterRole granting only get / list / watch never create / update / delete / patch , connected by a ClusterRoleBinding . The read-only guarantee built into KubeClient 's Go interface in Part 1 https://dev.to/ferztyle/build-an-mcp-server-in-go-part-1-designing-a-diagnostic-grade-kubernetes-client-49a2 and the read-only guarantee enforced by RBAC here are two independent layers checking the same thing from different directions; the Go code structurally can't call a write operation because the interface has none, and even if it somehow did, RBAC would reject the request at the API server before it touched anything. Neither one depends on the other holding; both have to fail at once for something to go wrong. It's also worth being precise about where RBAC's authority ends, since that boundary is exactly why this post exists: RBAC only governs the Kubernetes API. It has no opinion at all about what a pod is allowed to do once it starts making calls to AWS instead of to kube-apiserver , a ServiceAccount can be tightly scoped under RBAC and still hold an overly broad IAM role through IRSA or Pod Identity, because those are a completely separate permission system, checked by AWS, not by Kubernetes. That gap, RBAC stopping exactly at the cluster's edge, is the reason diagnose workload identity has to exist as its own tool rather than just being another RBAC check. One ServiceAccount , two independent extensions. RBAC left is entirely a Kubernetes-side concern, it never leaves the cluster. IRSA and Pod Identity right both start from that same ServiceAccount but end up at AWS through completely different trust mechanisms, which is exactly why diagnosing one has nothing in common with diagnosing the other. The two sections below walk through each AWS-side path in detail. IRSA IAM Roles for Service Accounts works by turning a Kubernetes ServiceAccount into something AWS can verify cryptographically, using OpenID Connect OIDC . OIDC OpenID Connect itself is worth grounding first, since everything else in this section builds on it. It's an identity protocol built on top of OAuth 2.0, and it solves a specific problem: how does party B trust a claim about identity made by party A, without B having to call A up and ask "is this real?" every single time? OIDC's answer is the JWT JSON Web Token , a compact, signed piece of text containing claims like "this is ServiceAccount X , in namespace Y , issued at time Z ". The token is signed with a private key only the issuer holds, and anyone who has the issuer's corresponding public key can verify that signature offline, no network call back to the issuer required, no shared secret sitting in a config file waiting to leak. That's the whole trick: proving something is genuine by checking a signature, not by trusting whoever happens to be presenting it. It's the same reason OIDC has become the backbone of "log in with X" buttons everywhere, the token itself carries proof of who issued it. Before the rest of the flow makes sense, it's also worth being clear on what an OIDC issuer actually is, and, just as importantly, where it actually runs, since "the cluster's own issuer" is easy to misread as something running alongside your workloads. In OIDC terms, the issuer is the party that signs and stands behind tokens; Google, in the "Sign in with Google" example, or in this case, EKS itself. Every EKS cluster gets its own OIDC issuer: a public HTTPS endpoint, unique to that cluster, that serves the OIDC discovery document and the JWKS the public keys needed to verify token signatures . That endpoint is part of the EKS control plane , hosted and managed by AWS, the same way kube-apiserver itself is, not a pod running on your nodes. The actual signing of ServiceAccount tokens happens inside kube-apiserver using a private key the control plane holds; the issuer endpoint is just where the corresponding public key gets published, so AWS STS can fetch it and verify signatures later without ever calling back into your cluster. Registering that issuer with AWS IAM step 1 below is what lets IAM trust tokens signed by your cluster specifically; an OIDC provider registered in one AWS account only vouches for tokens from the one EKS cluster it points at, which is exactly why the trust policy's Federated principal has to reference this cluster's specific issuer URL, not some generic "any EKS cluster" identity. Here's the flow, step by step: When you set up IRSA, your EKS cluster's OIDC issuer gets registered with AWS IAM as a trusted identity provider. This is a one-time step per cluster. You annotate a ServiceAccount with eks.amazonaws.com/role-arn: arn:aws:iam::...:role/my-role . An annotation here just means ordinary Kubernetes metadata, a key-value pair stored under metadata.annotations on the object, the same mechanism used for all sorts of unrelated things across the ecosystem. Unlike labels, which Kubernetes uses to select and group objects, annotations aren't used for selection; they're just data a controller or webhook can read to change its behavior. In this case: yaml apiVersion: v1 kind: ServiceAccount metadata: name: checkout-service namespace: production annotations: eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/checkout-s3-reader That specific eks.amazonaws.com/role-arn key is what the mutating webhook in the next step is watching for; its presence is the trigger, and its value is the role the webhook tells the pod to assume. 3. When a pod using that ServiceAccount starts, a mutating webhook installed as part of the EKS setup injects a JWT, a signed token proving "this pod belongs to ServiceAccount X in namespace Y , in this specific cluster", into the pod as a file, along with environment variables AWS ROLE ARN , AWS WEB IDENTITY TOKEN FILE telling the AWS SDK where to find it. A mutating webhook is part of Kubernetes' admission control pipeline, the sequence of checks every API request passes through between "the request is authenticated" and "the object actually gets written to etcd". Here's the full sequence for something like kubectl apply -f pod.yaml : Authentication : who is making this request? Authorization : RBAC check: is this identity allowed to do this? Mutating admission webhooks : a chance for registered external services to modify the object before it's persisted. Object schema validation : is the possibly now-modified object well-formed? Validating admission webhooks : a chance for external services to approve or reject the final object, no more modifications allowed. Persisted to etcd : the object is now real. A mutating webhook is registered via a MutatingWebhookConfiguration pointing at an HTTPS endpoint, usually a small service running as its own Deployment in the cluster. When a matching request comes in here, Pod CREATE , kube-apiserver pauses, sends the object to that endpoint, and applies whatever JSON patch comes back before continuing. For IRSA, this is the Amazon EKS Pod Identity Webhook an IRSA-era name that predates EKS Pod Identity the newer mechanism, which is a frequent source of confusion . It looks up the pod's spec.serviceAccountName , checks that ServiceAccount for the eks.amazonaws.com/role-arn annotation from step 2, and if present, patches in the projected token volume and the two environment variables above. None of this appears anywhere in the pod spec as written, it's injected transparently at admission time, which is exactly why IRSA "just works" once the annotation is set, with nothing else to configure on the pod itself. 4. When your Go code makes its first AWS API call, the SDK automatically reads that token and calls sts:AssumeRoleWithWebIdentity , presenting the JWT directly to AWS Security Token Service. 5. STS verifies the JWT's signature against the cluster's registered OIDC provider, checks the IAM role's trust policy for a matching condition the specific sub claim, system:serviceaccount: