Skip to content

RBAC and Service Accounts

RBAC splits every permission decision into “what is allowed” (a Role or ClusterRole) and “who gets it” (a RoleBinding or ClusterRoleBinding), and every Pod acts through a ServiceAccount identity — backed by a short-lived, bound token, not an old-style long-lived Secret — whether you configured one or not.

RBAC (Role-Based Access Control) lives under the rbac.authorization.k8s.io/v1 API group. A Role defines a set of permissions scoped to a single namespace — verbs like get, list, watch, create, update, delete applied to specific resources:

k8s/rbac-and-service-accounts/pod-reader-role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: rbac-demo
name: pod-reader
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]

A ClusterRole is the same idea but cluster-scoped — it either grants access to cluster-scoped resources (like nodes or namespaces), or it grants access to namespaced resources in a way that can be reused across many namespaces via multiple RoleBindings:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: secret-reader
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list"]

Neither of these grants anything on its own — a Role or ClusterRole is just a description of permissions sitting unattached until something binds it to a subject.

RoleBinding and ClusterRoleBinding: who gets it

Section titled “RoleBinding and ClusterRoleBinding: who gets it”

A RoleBinding connects a Role (or a ClusterRole, reused in a single namespace) to a subject — a User, Group, or ServiceAccount — within one namespace:

k8s/rbac-and-service-accounts/pod-reader-binding.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: pod-reader-sa
namespace: rbac-demo
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: pod-reader-binding
namespace: rbac-demo
subjects:
- kind: ServiceAccount
name: pod-reader-sa
namespace: rbac-demo
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io

A RoleBinding can also reference a ClusterRole while staying namespaced — this is the standard way to reuse one cluster-wide permission set (say, a built-in view ClusterRole) in many namespaces without copy-pasting the same Role rules everywhere: the binding, not the role, is what stays namespace-scoped.

A ClusterRoleBinding grants the ClusterRole’s permissions across the entire cluster, not just one namespace:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: read-all-secrets
subjects:
- kind: Group
name: platform-team
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: secret-reader
apiGroup: rbac.authorization.k8s.io

Proving permissions for real, not by reading YAML

Section titled “Proving permissions for real, not by reading YAML”

Two commands answer “who am I” and “what can I actually do,” against the live API server rather than by reading rules on paper:

$ kubectl auth whoami
ATTRIBUTE VALUE
Username kubernetes-admin
Groups [kubeadm:cluster-admins system:authenticated]
$ kubectl auth whoami --as=system:serviceaccount:rbac-demo:pod-reader-sa
ATTRIBUTE VALUE
Username system:serviceaccount:rbac-demo:pod-reader-sa
Groups [system:serviceaccounts system:serviceaccounts:rbac-demo system:authenticated]

kubectl auth whoami calls the SelfSubjectReview API and reports exactly what the API server resolved the caller’s identity and group memberships to be — combined with --as, it also confirms exactly what an impersonated identity resolves to, which is useful when a ServiceAccount’s groups (system:serviceaccounts:<namespace>, in particular) matter for a binding.

$ kubectl auth can-i --list --as=system:serviceaccount:rbac-demo:pod-reader-sa -n rbac-demo
Resources Non-Resource URLs Resource Names Verbs
selfsubjectreviews.authentication.k8s.io [] [] [create]
selfsubjectaccessreviews.authorization.k8s.io [] [] [create]
selfsubjectrulesreviews.authorization.k8s.io [] [] [create]
pods [] [] [get list watch]

--list prints the complete effective rule set for an identity in one shot — pods: [get list watch] here is exactly, and only, the pod-reader Role’s rules, plus a handful of API-server-wide defaults every authenticated identity gets (the selfsubject* APIs kubectl auth whoami/can-i themselves depend on).

$ kubectl delete pod nonexistent -n rbac-demo --as=system:serviceaccount:rbac-demo:pod-reader-sa
Error from server (Forbidden): pods "nonexistent" is forbidden: User "system:serviceaccount:rbac-demo:pod-reader-sa" cannot delete resource "pods" in API group "" in the namespace "rbac-demo"
$ kubectl auth can-i delete pods --as=system:serviceaccount:rbac-demo:pod-reader-sa -n rbac-demo
no

The API server rejects the request with a real 403 Forbidden and names the exact verb, resource, API group, and namespace it checked — pod-reader-sa genuinely cannot delete Pods, because its Role only grants get/list/watch, proven above.

ServiceAccounts: bound, short-lived tokens — not old-style Secrets

Section titled “ServiceAccounts: bound, short-lived tokens — not old-style Secrets”

Kubernetes does not natively manage human users — those authenticate through an external identity provider (OIDC, a cloud IAM integration, client certificates, and so on) at the API server’s authentication layer. What Kubernetes does manage natively is the ServiceAccount: the identity used by processes running inside Pods.

Every Pod runs as some ServiceAccount. If you never specify one, it runs as the namespace’s default ServiceAccount. Since Kubernetes 1.24, creating a ServiceAccount no longer auto-creates a matching long-lived kubernetes.io/service-account-token Secret — proof, a freshly created ServiceAccount with zero Secrets attached:

$ kubectl create serviceaccount pod-reader-sa -n rbac-demo
serviceaccount/pod-reader-sa created
$ kubectl get secrets -n rbac-demo
No resources found in rbac-demo namespace.

Instead, every mounted token is issued through the TokenRequest API as a bound service account token: short-lived, audience-scoped, and tied to the specific Pod object it was issued for — the API server can invalidate it the moment that Pod is deleted, which a long-lived Secret-based token never could. kubectl create token calls this same API directly:

$ kubectl create token pod-reader-sa -n rbac-demo --duration=10m --audience=custom-aud
eyJhbGciOiJSUzI1NiIs...
$ # decoding the JWT payload:
{
"aud": ["custom-aud"],
"exp": 1790418726,
"iat": 1790418126,
"kubernetes.io": {
"namespace": "rbac-demo",
"serviceaccount": {"name": "pod-reader-sa", "uid": "d373193f-..."}
},
"sub": "system:serviceaccount:rbac-demo:pod-reader-sa"
}

exp is roughly iat plus the requested --duration (a real expiry, not “forever”), and aud is scoped to exactly the audience requested — a token minted for custom-aud is not valid against an API server (or any other service) expecting a different audience. A Pod’s auto-mounted token (unless automountServiceAccountToken: false disables it, covered in the Pod and Cluster Security lesson) is the same mechanism, projected into the Pod as a volume:

$ kubectl get pod default-mount -n psa-demo -o jsonpath='{.spec.volumes}'
[{"name":"kube-api-access-47t24","projected":{"defaultMode":420,"sources":[{"serviceAccountToken":{"expirationSeconds":3607,"path":"token"}}, ...]}}]

expirationSeconds: 3607 — about an hour — and the kubelet automatically refreshes it as it approaches expiry, all without ever writing a long-lived credential to etcd as a Secret.

apiVersion: v1
kind: Pod
metadata:
name: checkout
namespace: checkout
spec:
serviceAccountName: checkout-app
containers:
- name: checkout
image: checkout:2.3.0

Aggregated ClusterRoles: composing permissions with labels, not copy-paste

Section titled “Aggregated ClusterRoles: composing permissions with labels, not copy-paste”

An aggregationRule lets one ClusterRole automatically absorb the rules of every other ClusterRole matching a label selector — the aggregate role’s own rules: [] starts empty and the control plane fills it in:

k8s/rbac-and-service-accounts/aggregated.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: monitoring-aggregate
aggregationRule:
clusterRoleSelectors:
- matchLabels:
rbac-demo/aggregate-to-monitoring: "true"
rules: []
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: monitoring-pods-part
labels:
rbac-demo/aggregate-to-monitoring: "true"
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: monitoring-nodes-part
labels:
rbac-demo/aggregate-to-monitoring: "true"
rules:
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "list"]
$ kubectl get clusterrole monitoring-aggregate -o jsonpath='{.rules}'
[{"apiGroups":[""],"resources":["nodes"],"verbs":["get","list"]},{"apiGroups":[""],"resources":["pods"],"verbs":["get","list"]}]

Both other ClusterRoles’ rules appear in monitoring-aggregate automatically, purely from the shared label — a third ClusterRole created later with the same label joins the aggregate with no edit to monitoring-aggregate itself. Kubernetes’ own built-in admin/edit/view ClusterRoles are aggregated exactly this way, which is how a CRD’s operator can extend edit/view to cover its own custom resources just by labeling its own ClusterRole.

The impersonate verb lets one identity make a request as another — the mechanism --as itself depends on. Granting it narrowly, scoped by resourceNames to one specific ServiceAccount:

k8s/rbac-and-service-accounts/impersonator.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: sa-impersonator
rules:
- apiGroups: [""]
resources: ["serviceaccounts"]
verbs: ["impersonate"]
resourceNames: ["pod-reader-sa"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: sa-impersonator-binding
subjects:
- kind: ServiceAccount
name: overprivileged-sa
namespace: rbac-demo
roleRef:
kind: ClusterRole
name: sa-impersonator
apiGroup: rbac.authorization.k8s.io

Proof that the scoping by name is real and enforced, not advisory:

$ kubectl auth can-i impersonate serviceaccounts/pod-reader-sa --as=system:serviceaccount:rbac-demo:overprivileged-sa -n rbac-demo
yes
$ kubectl auth can-i impersonate serviceaccounts/some-other-sa --as=system:serviceaccount:rbac-demo:overprivileged-sa -n rbac-demo
no

overprivileged-sa can impersonate exactly the one ServiceAccount named in resourceNames, and is denied for any other — impersonate is a real, independently-authorized verb, not a side effect of any other permission.

cluster-admin: the binding almost nothing legitimately needs

Section titled “cluster-admin: the binding almost nothing legitimately needs”

cluster-admin is a built-in, pre-aggregated ClusterRole granting every verb on every resource, cluster-wide. Binding a workload’s ServiceAccount to it is one of the most common RBAC anti-patterns, because it grants far more than any single workload should ever need:

$ kubectl auth can-i delete secrets --as=system:serviceaccount:rbac-demo:overprivileged-sa -A
yes
$ kubectl auth can-i delete nodes --as=system:serviceaccount:rbac-demo:overprivileged-sa
yes

A ServiceAccount meant to, say, read its own namespace’s ConfigMaps can — once bound to cluster-admin — delete any Secret in any namespace and delete cluster Nodes outright. A single compromised container using that identity has the same reach as a human cluster administrator. The fix is always the same principle proven throughout this lesson: a namespaced Role (or a narrowly-scoped ClusterRole, resourceNames-restricted where possible) plus a binding that grants only the verbs the workload actually calls.

flowchart LR
  pod["Pod\n(identity: ServiceAccount)"] --> req["Request to API server\n(bound token, TokenRequest API)"]
  req --> authz["API server\nauthorization check"]
  sa["ServiceAccount"] --> rb["RoleBinding"]
  role["Role\n(verbs on resources)"] --> rb
  rb --> authz
  authz -- "allowed" --> ok["Request proceeds"]
  authz -- "denied" --> deny["403 Forbidden\n(proven above)"]
A ServiceAccount bound via a RoleBinding to a Role, checked by the API server on every request

RBAC only ever answers one question: given this identity (a User, Group, or ServiceAccount), does some binding attach a Role/ClusterRole to it that grants this exact verb on this exact resource? Everything else in this lesson is a variation on making that lookup either narrower (a Role scoped to one namespace, resourceNames scoped to one object) or easier to compose (aggregationRule, reusing a ClusterRole via a namespaced RoleBinding). ServiceAccounts extend the same question to workloads instead of humans, and since 1.24 the credential backing that identity is a bound, short-lived, audience-scoped token issued on demand by the TokenRequest API — never a static Secret sitting in etcd indefinitely. impersonate is RBAC checking itself: it is just another verb, on another resource type, subject to the exact same Role/RoleBinding machinery as everything else.

ChoiceBenefitCost
Namespaced Role + RoleBindingTightly scoped, proven above with a real 403 for anything outside its verbsMust be repeated per namespace if the same permissions are needed in several
ClusterRole reused via namespaced RoleBindingsProven above: one rule set, bound narrowly per namespace, no duplicationSlightly less obvious at a glance which namespaces actually got it — requires checking bindings, not just the role
ClusterRoleBinding to a ClusterRoleSimple, one object grants cluster-wide accessCluster-wide by definition — proven above with cluster-admin, this is exactly how a single over-broad binding becomes a serious blast radius
Aggregated ClusterRolesProven above: composable, extensible by any component labeling its own ClusterRole, no central edit neededHarder to audit “what does this role actually grant” without also finding every labeled contributor role
Bound SA tokens (TokenRequest, default since 1.24)Proven above: short expiry, audience-scoped, no long-lived Secret to leakAnything reading a token out-of-band (logs, a long build step) must tolerate it expiring, unlike an old-style permanent token
  • “A Role and a ClusterRole are the same thing, just with different names.” Proven above: a Role’s rules only ever apply within its own namespace; a ClusterRole can grant cluster-scoped resources (nodes) that no Role can name at all.
  • “Every ServiceAccount gets a long-lived Secret token automatically, like it used to.” Proven above: a freshly created ServiceAccount has zero Secrets — since Kubernetes 1.24, tokens are only ever bound, short-lived, and requested on demand via TokenRequest/kubectl create token, or auto-mounted the same way into a Pod.
  • “kubectl auth can-i --list only shows what an explicit Role/ClusterRole grants.” Proven above: the output includes a handful of always-on API-server defaults (selfsubjectreviews, selfsubjectaccessreviews) available to every authenticated identity, alongside whatever an actual binding grants.
  • “Binding a workload to cluster-admin is fine as long as the workload itself is trusted.” Proven above: cluster-admin grants deleting Nodes and Secrets in every namespace, far beyond almost any workload’s actual need — the risk is what happens if that workload is ever compromised, not whether it is trusted today.
  • “The impersonate verb just lets kubectl --as work for anyone, once RBAC is otherwise configured.” Proven above with a real can-i check: impersonation is authorized (or denied) per identity and can be scoped with resourceNames to one specific target subject, exactly like any other verb.

When reviewing generated RBAC manifests or ServiceAccount setup, check for:

  • A workload ServiceAccount bound to cluster-admin “to avoid permission errors” — proven above, this grants deleting cluster Nodes and every namespace’s Secrets; the fix is a scoped Role naming only the verbs actually used.
  • A manifest or tutorial creating a kubernetes.io/service-account-token Secret by hand and mounting it — proven above, this is the pre-1.24 pattern; a bound token via the default projected volume (or kubectl create token for out-of-cluster use) is current practice and expires on its own.
  • ClusterRoleBinding used where a namespaced RoleBinding (even one referencing a ClusterRole) would have scoped the grant to one namespace instead of the whole cluster.
  • A Role/ClusterRole granting "*" verbs or "*" resources where only a handful of specific verbs are actually called — broad wildcards defeat the entire point of least privilege, and are much harder to audit than an explicit list.
  • The impersonate verb granted without resourceNames, when only one specific subject actually needs to be impersonated — proven above, scoping by name is possible and enforced; an unscoped grant lets the holder impersonate any identity of that kind.

🐛 Spot the bug

k8s/rbac-and-service-accounts/checkout-binding.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: checkout-app
namespace: checkout
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: checkout-app-binding
subjects:
- kind: ServiceAccount
name: checkout-app
namespace: checkout
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
Show the answer

checkout-app only needs to read its own namespace’s Pods and ConfigMaps, but this binds it to cluster-admin cluster-wide. Proven above with the equivalent binding: kubectl auth can-i delete secrets --as=system:serviceaccount:checkout:checkout-app -A would return yes — this ServiceAccount could delete any Secret in any namespace, or delete Nodes outright, none of which “read my own Pods and ConfigMaps” requires. The fix is a namespaced Role in checkout granting only get/list/watch on pods/configmaps, bound with a RoleBinding, not a ClusterRoleBinding to cluster-admin.

What is the key difference between a Role and a ClusterRole?
Why is this request forbidden even though the Pod named does not exist?
$ kubectl delete pod nonexistent -n rbac-demo --as=system:serviceaccount:rbac-demo:pod-reader-sa
Error from server (Forbidden): pods "nonexistent" is forbidden: User "system:serviceaccount:rbac-demo:pod-reader-sa" cannot delete resource "pods" ...
Since Kubernetes 1.24, what happens when you create a new ServiceAccount?
monitoring-aggregate was created with rules: [] and an aggregationRule label selector. Where did these rules come from?
$ kubectl get clusterrole monitoring-aggregate -o jsonpath='{.rules}'
[{"apiGroups":[""],"resources":["nodes"],...},{"apiGroups":[""],"resources":["pods"],...}]
What does this pair of results prove about the impersonate verb granted to overprivileged-sa?
$ kubectl auth can-i impersonate serviceaccounts/pod-reader-sa --as=system:serviceaccount:rbac-demo:overprivileged-sa -n rbac-demo
yes
$ kubectl auth can-i impersonate serviceaccounts/some-other-sa --as=system:serviceaccount:rbac-demo:overprivileged-sa -n rbac-demo
no
Why is binding a workload ServiceAccount to cluster-admin dangerous, even if the workload itself is currently trusted?