K8s Advanced #2: RBAC / ServiceAccount in Depth — Aggregated ClusterRole / Impersonation / IRSA / Workload Identity
The second post in the K8s Advanced series. Intermediate #7 covered the four RBAC objects (Role, ClusterRole, RoleBinding, ClusterRoleBinding) and the three subjects receiving permission (User, Group, ServiceAccount). The post wrapped up with the principle of “least privilege” and the pattern of using standard ClusterRoles via namespace-scoped RoleBindings. This post covers four topics one layer deeper — Aggregated ClusterRole (extending permission bundles via labels), Impersonation (calling temporarily with another subject’s permission), ServiceAccount token lifecycle changes (K8s 1.22’s projected token default and 1.24’s stop of legacy secret auto-creation), and integration with external IAM (EKS’s IRSA, GKE’s Workload Identity).
This series is K8s Advanced, 6 posts.
- #1 CNI in depth — Calico / Cilium / eBPF
- #2 RBAC / ServiceAccount in depth — Aggregated ClusterRole / Impersonation / IRSA / Workload Identity ← this post
- #3 Admission Controller — OPA Gatekeeper / Kyverno
- #4 CRD and the Operator pattern — controller-runtime
- #5 Observability — Prometheus / Grafana / Loki / OpenTelemetry
- #6 GitOps — ArgoCD / Flux
Aggregated ClusterRole — permission bundle composed via labels #
In an operational cluster, there are times when you want to extend permissions without directly touching standard ClusterRoles. For example, the view ClusterRole that K8s ships with bundles read permissions on all standard resources. When a team adds a new resource kind via CRD (CustomResourceDefinition), it should be possible to express “anyone with view permission should automatically be able to read this new resource” without modifying the view ClusterRole directly.
The object filling this gap is Aggregated ClusterRole. The model is simple — instead of a ClusterRole writing its own permission list directly, writing aggregationRule with a label selector lets K8s gather the rules of other ClusterRoles matching that selector and compose them.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: view
aggregationRule:
clusterRoleSelectors:
- matchLabels:
rbac.authorization.k8s.io/aggregate-to-view: "true"
rules: [] # empty — controller fills automaticallyThe fact that rules is empty is the key. K8s’s RBAC controller sweeps every ClusterRole in the cluster, gathering the rules of those labeled rbac.authorization.k8s.io/aggregate-to-view: "true" and filling them into the rules field of the ClusterRole above. To add new permissions, just create one new ClusterRole with that label.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: my-crd-view
labels:
rbac.authorization.k8s.io/aggregate-to-view: "true"
rules:
- apiGroups: ["myteam.example.com"]
resources: ["widgets"]
verbs: ["get", "list", "watch"]Applying this single manifest, from that moment the standard view ClusterRole automatically composes in the read permission for the widgets resource. Every user receiving view via RoleBinding gets read permission on the new resource without changing a line of code.
The standard labels are three — aggregate-to-view, aggregate-to-edit, aggregate-to-admin. The view / edit / admin ClusterRoles K8s pre-creates each gather permissions via their respective labels. For an operational team that has adopted CRDs, this model is the cleanest path to extending custom RBAC. The source of truth for permissions is dispersed, but the benefit of naturally extending the meaning of standard ClusterRoles is large.
Impersonation — calling with another subject’s permission #
During operations, there are times when you want to verify in advance what actions a particular user or ServiceAccount can perform. Common cases include checking what a new RoleBinding allows before granting it to a human user, reproducing a reported permission issue, or auditing the permission scope of a ServiceAccount.
K8s’s Impersonation feature fills this need. When the caller calls the API with their own credentials and sends a header saying “act as user X for this call,” the API server performs permission checks with X’s permissions. Since X’s permissions apply rather than the caller’s own, the caller must first have impersonate permission.
kubectl --as=alice@example.com get pods -n payments
kubectl --as=system:serviceaccount:default:my-sa get secretsThe --as option is the caller-side expression of impersonation. The first line above is “query Pods in the payments namespace with the permission of alice@example.com,” and the second is “query Secrets with the permission of the my-sa ServiceAccount in the default namespace.”
For this call to work, the caller’s RBAC must have the following permission.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: user-impersonator
rules:
- apiGroups: [""]
resources: ["users", "groups", "serviceaccounts"]
verbs: ["impersonate"]The impersonate verb is the key. A user with this permission can call beyond their own permissions with another user’s permissions, so it’s effectively a permission that can bypass the entire cluster’s permission model. Because impersonate permission itself is very sensitive, in operational environments it’s only granted to a small number of SRE or security team members.
The standard tool for permission checks — kubectl auth can-i #
Daily use of impersonation is usually paired with the kubectl auth can-i command.
kubectl auth can-i create secrets --as=alice@example.com -n paymentsyesThis call doesn’t actually create a Secret. It only runs the API server’s permission decision logic and returns yes/no. It’s the first tool to reach for when verifying that a new RoleBinding behaves as intended before applying it, or when pinpointing where a reported permission issue is blocked.
ServiceAccount token — from legacy secret to projected token #
Intermediate #7 noted that a ServiceAccount’s token is automatically mounted at /var/run/secrets/kubernetes.io/serviceaccount/token inside Pods. This behavior shifted in two stages. K8s 1.22 made projected tokens the default, and K8s 1.24 stopped automatic creation of legacy secrets. Since this is a change frequently encountered in operational clusters, we walk through it in order.
The old model — perpetual tokens in auto-created Secret objects #
Through K8s 1.21, the default behavior was as follows. Creating a ServiceAccount caused K8s to automatically create a Secret object with the same name, and that Secret held the ServiceAccount’s JWT token. This token was a perpetual token without expiry. When a Pod uses that ServiceAccount, kubelet mounts that Secret to put the token inside the container.
The problems with this model were two:
- No expiry — once a token leaks externally, it’s perpetually valid until the ServiceAccount is deleted or the token is rotated.
- Pod and token are decoupled — the token in the Secret is independent of the Pod’s lifecycle. There’s also no audience information (who the token is for).
The new model — Projected Token (Bound ServiceAccount Token) #
From K8s 1.22, projected token became the default behavior. Creating a ServiceAccount no longer auto-creates a legacy Secret object. Instead, when a Pod comes up, kubelet issues a Pod-specific short-lived JWT token and mounts it directly into the Pod’s filesystem. The token has three characteristics:
- Has expiry — default 1 hour, adjustable shorter or longer via options. Before expiry, kubelet automatically issues a new token and remounts (rotate).
- Audience is specified — which API server’s call this token is valid for is written inside the token.
- Bound to the Pod — the token is bound to the Pod’s UID, so when the Pod disappears, the token is no longer valid.
spec:
containers:
- name: app
volumeMounts:
- name: kube-api-access
mountPath: /var/run/secrets/kubernetes.io/serviceaccount
readOnly: true
volumes:
- name: kube-api-access
projected:
sources:
- serviceAccountToken:
path: token
expirationSeconds: 3607
audience: ""
- configMap:
name: kube-root-ca.crt
items:
- key: ca.crt
path: ca.crt
- downwardAPI:
items:
- path: namespace
fieldRef:
fieldPath: metadata.namespaceWhen kubelet creates a Pod, it automatically adds this projected volume. It’s the default behavior that goes into every Pod even without writing it directly in the manifest. The path inside the container (/var/run/secrets/...) is the same as the old model, so the code inside the container doesn’t need to worry about the token model change.
When the old way is needed — explicit Secret #
There are remaining cases that require a token without expiry — for example, when a CI pipeline’s external tool calls the K8s API, or when an IDE’s K8s plugin needs a long-lived token. In such cases, an explicit Secret must now be created.
apiVersion: v1
kind: Secret
metadata:
name: my-sa-token
annotations:
kubernetes.io/service-account.name: my-sa
type: kubernetes.io/service-account-tokentype and the annotation are the keys. Creating this Secret causes K8s to fill it with my-sa’s perpetual token. Because it’s a token without expiry, the impact when externally exposed is large — in operations, using projected tokens whenever possible and creating explicit Secrets only when truly needed (with a token rotation policy in place) is safer.
Connecting with external IAM — IRSA and Workload Identity #
The story so far has been about permissions to the K8s API itself. But operational cluster workloads also call other cloud services — S3 buckets, RDS databases, KMS keys, Secrets Manager secrets. Permissions for those calls are evaluated outside K8s’s RBAC, in the realm of the cloud’s IAM.
The traditional method was to put cloud credentials (access key + secret key) into K8s Secrets and mount them on Pods. The problems with this method are clear — once credentials enter as Secrets they have no expiry, rotation is hard, and once they leak the impact is large.
EKS’s IRSA (IAM Roles for Service Accounts) and GKE’s Workload Identity solve this problem the same way — link the K8s ServiceAccount with a cloud IAM Role and exchange projected tokens for IAM temporary credentials. The model exchanges short-lived tokens at call time rather than storing static credentials.
IRSA flow — EKS’s ServiceAccount + IAM Role #
IRSA setup is three steps.
1. Activate OIDC provider on the EKS cluster
→ set up so that AWS IAM trusts EKS's ServiceAccount JWT tokens
2. Create an AWS IAM Role and write the OIDC provider above + specific ServiceAccount in trust policy
→ "only tokens issued by ns/my-app's SA = app-sa can take this Role"
3. Attach Role ARN annotation on the K8s ServiceAccount
→ eks.amazonaws.com/role-arn: arn:aws:iam::ACCOUNT:role/my-app-roleIn a cluster with this setup complete, when a Pod calls the AWS API, the following flow runs automatically.
1. Read the projected token mounted on the Pod
2. Send that token to AWS STS's AssumeRoleWithWebIdentity API
3. STS verifies the token with EKS OIDC provider
4. Verify it matches the ServiceAccount written in trust policy
5. If matched, return IAM Role temporary credentials (15 min ~ 12 hours)
6. AWS SDK calls S3 with those credentialsBecause all these steps happen automatically inside the AWS SDK, the application code doesn’t directly handle credentials. The code just calls boto3.client('s3') and that’s it; the SDK’s internal credential chain follows the flow above.
apiVersion: v1
kind: ServiceAccount
metadata:
name: app-sa
namespace: my-app
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/my-app-s3-role
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
namespace: my-app
spec:
replicas: 1
selector:
matchLabels:
app: app
template:
metadata:
labels:
app: app
spec:
serviceAccountName: app-sa # ← uses the SA created above
containers:
- name: app
image: my-app:latestOne ServiceAccount and one IAM Role are tied 1:1, and every Pod using that ServiceAccount has the IAM Role’s permissions. The need to hold static credentials in K8s Secrets disappears.
GKE Workload Identity — same model, different name #
GKE’s Workload Identity is essentially the same model. The flow ties K8s ServiceAccount with GCP IAM’s Service Account and exchanges projected tokens via GCP STS for temporary credentials.
apiVersion: v1
kind: ServiceAccount
metadata:
name: app-sa
namespace: my-app
annotations:
iam.gke.io/gcp-service-account: my-app-sa@PROJECT.iam.gserviceaccount.comOnly the annotation key and value format differ. AKS also adopted the same model with Azure Workload Identity, with cloud-vendor-specific adapters sitting on top of the same OIDC + STS-based token exchange structure.
Operational principles to lock in at adoption #
IRSA and Workload Identity are nearly standard from a security perspective, but a few operational principles to flag at the setup stage:
- 1 ServiceAccount = 1 cloud IAM Role 1:1 mapping — don’t pile too many permissions onto one SA; split ServiceAccounts per workload and attach minimum-permission IAM Roles to each SA.
- Specify both namespace and SA name in trust policy — specifying only namespace lets other SAs in that namespace take the same Role, breaking isolation.
- Verify token audience — when STS verifies the token, it checks the audience (
sts.amazonaws.com, etc.). The ServiceAccount’s projected token’s audience must match what STS expects. - Credential lifetime — IAM Role temporary credentials are issued at STS call time and usually valid for 1 hour. AWS SDK auto-refreshes, so application-side handling isn’t needed.
Closing #
The deeper layer of the K8s permission model has been organized. The pattern of extending standard permission bundles via labels with Aggregated ClusterRole, the flow of calling with another subject’s permission via Impersonation and verifying permission intent via kubectl auth can-i, the change of ServiceAccount tokens from perpetual Secrets to projected tokens with expiry/rotation/audience, and the model of tying K8s ServiceAccounts with cloud IAM via IRSA / Workload Identity to remove static credentials from outside the cluster — this was the territory of this post. The next post covers the K8s API server’s admission stage — OPA Gatekeeper and Kyverno, the tools that inspect and mutate manifests right before they are stored in etcd.