Kubernetes RBAC least privilege: from admin to scoped roles

Replace blanket cluster-admin with namespaced Roles, audit who can do what, and test access with kubectl auth can-i.

Apr 14, 2026·Updated ·6 min readIntermediate·By SecOpsLog · command-tested
bash — representative: who holds cluster-admin right now (a cluster that has accumulated bindings; the same jq on the fresh fixture cluster is further down)
kubectl get clusterrolebindings -o json | jq -r '.items[] | select(.roleRef.name=="cluster-admin") | .metadata.name + " " + ([.subjects[]? | .kind + ":" + (.namespace // "") + "/" + .name] | join(", "))'
cluster-admin Group:/system:masters
jenkins-deployer ServiceAccount:ci/jenkins
dashboard-admin ServiceAccount:kube-system/dashboard
platform-oncall Group:/platform-team
four subjects, each a full-cluster takeover if its credential leaks; only the first is expected

Clusters accumulate cluster-admin bindings the way a garage accumulates boxes: a CI account that needed to create a namespace once, a dashboard from a tutorial, an on-call group that was going to be narrowed later. Least privilege does not mean zero cluster-admin bindings, since someone needs break-glass access; it means every binding is intentional, scoped to what its subject does, and provable with kubectl auth can-i. Getting there is a loop, and the first pass through it is the audit above.

Identity and permission are separate objects

The token proves which service account a request comes from; RBAC decides what that account may do. A leaked token is exactly as dangerous as the bindings behind it, so the bindings are what this audit tightens.

authenticated request allow deny kubelet TokenRequest projection issues + rotates the token Pod workload container SA token · JWT short-lived · audience-bound kube-apiserver request pipeline · two gates 1 · Authenticate verify signature + expiry → system:serviceaccount:ns:name 2 · Authorize · RBAC check (Cluster)RoleBinding for that ServiceAccount ✓ etcd / resource ALLOWED · request proceeds ✕ 403 Forbidden DENIED · no binding grants it Identity = the signed token · Permissions = RBAC. A leaked long-lived token is dangerous, so tokens are short-lived + audience-scoped.
authenticated request allowed denied token issued / rotated

See the cluster through the subject’s eyes

kubectl auth can-i --list with --as prints every verb and resource a subject is allowed, resolved through all of its bindings. It is the view to take before writing any Role, because it shows the gap between what the subject is granted and what its workload actually calls. A in the verbs column is a full grant on that resource; a on both columns is cluster-admin under another name. Every authenticated subject also carries a handful of discovery rules (selfsubjectaccessreviews, the /api and /healthz non-resource URLs), which is why a service account with no bindings at all still lists something. kubectl auth whoami shows what the current credentials resolve to, which matters when the surprise is which identity a pipeline is using.

bash — representative: a service account’s effective permissions (an over-granted CI account next to a scoped reader)
kubectl auth can-i --list --as=system:serviceaccount:ci:jenkins
Resources Non-Resource URLs Resource Names Verbs
*.* [] [] [*]
everything, everywhere: the jenkins-deployer binding
kubectl auth can-i --list --as=system:serviceaccount:shop:web -n shop
configmaps [] [] [get]
pods [] [] [get list watch]
a reader, in one namespace: what the workload needs and nothing else

Namespaced Role, bound to one subject

A Role exists in one namespace; a ClusterRole applies everywhere it is bound. Default to a Role unless the subject needs cluster-scoped resources such as Nodes, PersistentVolumes or CustomResourceDefinitions. The verbs come from what the workload does, not from what might be convenient: a deployer needs create, patch and get on Deployments in its namespace, and does not need delete on Secrets to do that. Write the verbs out; a wildcard is a decision not to decide.

ci-deployer.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: shop
name: deployer
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "patch", "create"]
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "create", "patch"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"] # to watch a rollout; not delete, not exec
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
namespace: shop
name: jenkins-deploys-shop
subjects:
- kind: ServiceAccount
name: jenkins
namespace: ci
roleRef:
kind: Role
name: deployer
apiGroup: rbac.authorization.k8s.io

A subject that deploys to five namespaces gets five RoleBindings to the same Role definition (or one ClusterRole bound into each namespace with a RoleBinding, which keeps the rules in one place while the grant stays namespaced). What it does not get is a ClusterRoleBinding, because that would make the same verbs valid in kube-system.

The verbs that are more than they look

Grants that quietly equal more than the resource they name

GrantWhy it escalatesGive it to
create on pods (or pods/exec)a pod can mount any Secret in the namespace and run as any service account therecontrollers that must create pods; never a human role that only reads
get on secretsevery token and credential in the namespace, including other service accounts’ secretsthe specific workload, on named resources where possible (resourceNames)
bind, escalate on roleslets the subject grant itself permissions it does not holdnobody, outside break-glass
impersonate on users, groups, serviceaccountsbecome anyone the API server knowsthe identity gateway, if you run one
edit or admin ClusterRoles bound to automationaggregated roles grow as CRDs are added; automation ends up with verbs nobody reviewedhuman developers in their own namespace; controllers get an explicit Role

The built-in view, edit and admin ClusterRoles are aggregated: any ClusterRole labelled for aggregation, including ones an operator installs, is folded into them. That makes them reasonable for people working in a namespace and wrong for a controller, whose permission set should not change because someone installed a CRD.

Tokens that are never used should not exist

Every pod gets its service account's token mounted at /var/run/secrets/kubernetes.io/serviceaccount/token unless told otherwise, and most application pods never call the API. A compromised pod with a mounted token and a permissive binding is the standard path from RCE to cluster. automountServiceAccountToken: false on the ServiceAccount (or the Pod) removes the token from pods that do not need it; the ones that do get bound, time-limited tokens by default on current versions, which expire and are tied to the pod that received them.

serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: web
namespace: shop
automountServiceAccountToken: false # the web pods never call the API server

Prove the negative, then keep proving it

The check that matters is the one that should fail. After tightening, ask can-i for the dangerous verbs, not the granted ones, and put the same questions into CI so a later binding cannot quietly widen the account. The audit log is the other half: a create on a ClusterRoleBinding to cluster-admin is the event that undoes this work, and it should page someone.

bash — observed: the Role above on Kubernetes 1.35.0, asked with can-i and then asked for realobserved
kubectl get clusterrolebindings -o json | jq -r '.items[] | select(.roleRef.name=="cluster-admin") | .metadata.name + " " + ([.subjects[]? | .kind + ":" + (.namespace // "") + "/" + .name] | join(", "))'
cluster-admin Group:/system:masters
kubeadm:cluster-admins Group:/kubeadm:cluster-admins
a fresh kubeadm-built cluster: two groups, both expected. Anything else in this list is what the audit is for
kubectl auth can-i --list --as=system:serviceaccount:ci:jenkins -n shop | grep -vE "selfsubject|well-known|/api|/healthz|/livez|/readyz|/openapi|/version|Non-Resource"
configmaps [] [] [get list create patch]
deployments.apps [] [] [get list patch create]
pods [] [] [get list]
for v in "delete pods" "get secrets" "create clusterrolebindings" "create pods --subresource=exec"; do printf "%-32s " "$v"; kubectl auth can-i $v --as=system:serviceaccount:ci:jenkins -n shop; done
delete pods no
get secrets no
create clusterrolebindings Warning: resource 'clusterrolebindings' is not namespace scoped in group 'rbac.authorization.k8s.io' no
create pods --subresource=exec no
kubectl auth can-i patch deployments --as=system:serviceaccount:ci:jenkins -n shop
yes
four expected noes and one expected yes: the Role does what the workload needs and nothing an attacker would want. can-i exits 1 on a no, so the loop can fail a pipeline
TOKEN=$(kubectl -n ci create token jenkins --duration=10m) && kubectl config set-credentials jenkins --token="$TOKEN" && kubectl config set-context jenkins --cluster=kind-secopslog-p2c-rbac --user=jenkins
kubectl --context=jenkins auth whoami | grep Username
Username system:serviceaccount:ci:jenkins
kubectl --context=jenkins -n shop get pods -o name
pod/web
kubectl --context=jenkins -n shop get secrets
Error from server (Forbidden): secrets is forbidden: User "system:serviceaccount:ci:jenkins" cannot list resource "secrets" in API group "" in the namespace "shop"
kubectl --context=jenkins -n shop exec web -- id
Error from server (Forbidden): pods "web" is forbidden: User "system:serviceaccount:ci:jenkins" cannot create resource "pods/exec" in API group "" in the namespace "shop"
kubectl --context=jenkins get nodes
Error from server (Forbidden): nodes is forbidden: User "system:serviceaccount:ci:jenkins" cannot list resource "nodes" in API group "" at the cluster scope
the same answers from the authorizer itself, with a real token in a context that carries nothing else: delete, another namespace and the cluster scope were refused the same way (exit 1 each). get pods does not include exec: pods/exec is its own resource
bash — observed: a binding widened under pressure, and its reversalobserved
kubectl create clusterrolebinding jenkins-deployer --clusterrole=cluster-admin --serviceaccount=ci:jenkins
kubectl auth can-i get secrets --as=system:serviceaccount:ci:jenkins -n shop; kubectl auth can-i delete pods --as=system:serviceaccount:ci:jenkins -n kube-system
yes
yes
kubectl get clusterrolebindings -o json | jq -r '…' | grep jenkins # the audit from the top of the page
jenkins-deployer ServiceAccount:ci/jenkins
kubectl delete clusterrolebinding jenkins-deployer
clusterrolebinding.rbac.authorization.k8s.io "jenkins-deployer" deleted
kubectl --context=jenkins -n shop get pods -o name >/dev/null && kubectl --context=jenkins -n shop get secrets 2>&1 | grep -o "secrets is forbidden"
secrets is forbidden
one object to delete, and the same token immediately answers the way the Role says: the reversal is verified with the request that should fail, not the one that should work

When a binding is wrong: recovery in order of preference

SymptomDiagnosisReversal and verification
the cluster-admin audit shows a subject nobody expectedkubectl get clusterrolebinding <name> -o yaml for who created it and when (the audit log has the request)kubectl delete clusterrolebinding <name>; verify with auth can-i get secrets --as=<subject> -n <ns> returning no and, if the subject is a service account, a real request with its token (executed above)
a pipeline stopped working after a Role was tightenedkubectl auth can-i --list --as=<subject> -n <ns> shows the verb it now lacks; the job log has the 403 with the exact resourceadd the one verb the workload calls to the Role (never a wildcard), kubectl apply, re-run the job; the negative loop must still print four noes (documentation-backed)
a token from a mounted secret leakedthe binding behind it is the blast radius: auth can-i --list as that service accountdelete the binding first, then rotate: kubectl delete secret <token-secret> for legacy token secrets, or a new bound token is minted on the next Pod start; set automountServiceAccountToken: false where the pod never calls the API (the unmounted case was observed; rotation is documentation-backed)
What was run for this article
Kubernetes 1.35.0 on a single-node kind v0.31.0 cluster created and deleted by the fixture (kindest/node:v1.35.0 by digest, kubectl v1.35.0, Docker Engine 28.5.2, linux/arm64). The Role and RoleBinding printed above were applied for a ci/jenkins service account and checked twice: with kubectl auth can-i --as, and with a 10-minute TokenRequest token in a kubeconfig context of its own, so the 403s come from the API server. The terminal blocks marked observed are copied from that run; twenty exit codes are asserted, including the unmounted token in a pod whose service account sets automountServiceAccountToken: false, and a cluster-admin binding created and deleted. The four-binding audit and the over-granted CI account are representative; the escalating verbs table, aggregated roles and the audit log are documentation-backed.
Break-glass access has an expiry
Keep one documented way to obtain cluster-admin: a group that is empty until an incident, a binding created by a runbook and removed by it, or a short-lived credential from your identity provider. A standing cluster-admin binding for "the platform team" is the finding auditors and attackers agree on.

RBAC decides what a subject may ask the API server. It does not decide what a pod may connect to (NetworkPolicy) or what it may do on the node (Pod Security). The three are usually tightened in that order, and the audit loop here is the one that gets repeated most often, because bindings are what people add under pressure.

Related posts

Quick reference