RBAC and Service Accounts
The idea in one sentence
Section titled “The idea in one sentence”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.
Role and ClusterRole: what is allowed
Section titled “Role and ClusterRole: what is allowed”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:
apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata: namespace: rbac-demo name: pod-readerrules: - 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/v1kind: ClusterRolemetadata: name: secret-readerrules: - 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:
apiVersion: v1kind: ServiceAccountmetadata: name: pod-reader-sa namespace: rbac-demo---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata: name: pod-reader-binding namespace: rbac-demosubjects: - kind: ServiceAccount name: pod-reader-sa namespace: rbac-demoroleRef: kind: Role name: pod-reader apiGroup: rbac.authorization.k8s.ioA 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/v1kind: ClusterRoleBindingmetadata: name: read-all-secretssubjects: - kind: Group name: platform-team apiGroup: rbac.authorization.k8s.ioroleRef: kind: ClusterRole name: secret-reader apiGroup: rbac.authorization.k8s.ioProving 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 whoamiATTRIBUTE VALUEUsername kubernetes-adminGroups [kubeadm:cluster-admins system:authenticated]
$ kubectl auth whoami --as=system:serviceaccount:rbac-demo:pod-reader-saATTRIBUTE VALUEUsername system:serviceaccount:rbac-demo:pod-reader-saGroups [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-demoResources Non-Resource URLs Resource Names Verbsselfsubjectreviews.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).
A real 403, not a guess
Section titled “A real 403, not a guess”$ kubectl delete pod nonexistent -n rbac-demo --as=system:serviceaccount:rbac-demo:pod-reader-saError 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-demonoThe 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-demoserviceaccount/pod-reader-sa created
$ kubectl get secrets -n rbac-demoNo 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-audeyJhbGciOiJSUzI1NiIs...
$ # 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: v1kind: Podmetadata: name: checkout namespace: checkoutspec: serviceAccountName: checkout-app containers: - name: checkout image: checkout:2.3.0Aggregated 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:
apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRolemetadata: name: monitoring-aggregateaggregationRule: clusterRoleSelectors: - matchLabels: rbac-demo/aggregate-to-monitoring: "true"rules: []---apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRolemetadata: name: monitoring-pods-part labels: rbac-demo/aggregate-to-monitoring: "true"rules: - apiGroups: [""] resources: ["pods"] verbs: ["get", "list"]---apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRolemetadata: 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
Section titled “The impersonate verb”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:
apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRolemetadata: name: sa-impersonatorrules: - apiGroups: [""] resources: ["serviceaccounts"] verbs: ["impersonate"] resourceNames: ["pod-reader-sa"]---apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRoleBindingmetadata: name: sa-impersonator-bindingsubjects: - kind: ServiceAccount name: overprivileged-sa namespace: rbac-demoroleRef: kind: ClusterRole name: sa-impersonator apiGroup: rbac.authorization.k8s.ioProof 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-demoyes$ kubectl auth can-i impersonate serviceaccounts/some-other-sa --as=system:serviceaccount:rbac-demo:overprivileged-sa -n rbac-demonooverprivileged-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 -Ayes$ kubectl auth can-i delete nodes --as=system:serviceaccount:rbac-demo:overprivileged-sayesA 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)"]
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Namespaced Role + RoleBinding | Tightly scoped, proven above with a real 403 for anything outside its verbs | Must be repeated per namespace if the same permissions are needed in several |
ClusterRole reused via namespaced RoleBindings | Proven above: one rule set, bound narrowly per namespace, no duplication | Slightly less obvious at a glance which namespaces actually got it — requires checking bindings, not just the role |
ClusterRoleBinding to a ClusterRole | Simple, one object grants cluster-wide access | Cluster-wide by definition — proven above with cluster-admin, this is exactly how a single over-broad binding becomes a serious blast radius |
Aggregated ClusterRoles | Proven above: composable, extensible by any component labeling its own ClusterRole, no central edit needed | Harder 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 leak | Anything reading a token out-of-band (logs, a long build step) must tolerate it expiring, unlike an old-style permanent token |
Common Misconceptions
Section titled “Common Misconceptions”- “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; aClusterRolecan grant cluster-scoped resources (nodes) that noRolecan 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 --listonly 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-adminis fine as long as the workload itself is trusted.” Proven above:cluster-admingrants 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
impersonateverb just letskubectl --aswork for anyone, once RBAC is otherwise configured.” Proven above with a realcan-icheck: impersonation is authorized (or denied) per identity and can be scoped withresourceNamesto one specific target subject, exactly like any other verb.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated RBAC manifests or ServiceAccount setup, check for:
- A workload
ServiceAccountbound tocluster-admin“to avoid permission errors” — proven above, this grants deleting cluster Nodes and every namespace’s Secrets; the fix is a scopedRolenaming only the verbs actually used. - A manifest or tutorial creating a
kubernetes.io/service-account-tokenSecret by hand and mounting it — proven above, this is the pre-1.24 pattern; a bound token via the default projected volume (orkubectl create tokenfor out-of-cluster use) is current practice and expires on its own. ClusterRoleBindingused where a namespacedRoleBinding(even one referencing aClusterRole) would have scoped the grant to one namespace instead of the whole cluster.- A
Role/ClusterRolegranting"*"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
impersonateverb granted withoutresourceNames, 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
apiVersion: v1kind: ServiceAccountmetadata:name: checkout-appnamespace: checkout---apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRoleBindingmetadata:name: checkout-app-bindingsubjects:- kind: ServiceAccount name: checkout-app namespace: checkoutroleRef:kind: ClusterRolename: cluster-adminapiGroup: rbac.authorization.k8s.ioShow 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.