Kubernetes RBAC denies everything by default: any action not explicitly permitted by a rule gets rejected by the API server. This deny-by-default model gives a false sense of safety, because it only protects against what was never granted — an overly broad role, once granted, triggers no alert and never breaks anything. It just sits there, silent, until the incident that exposes it.
Role vs ClusterRole: scope before permission
The first decision isn’t which permissions to grant, but at what scope: a Role applies to a single namespace, a ClusterRole to the entire cluster (or to non-namespaced resources, like nodes). A common mistake is reaching for a ClusterRole by reflex, even when a namespaced Role would suffice, because the syntax is nearly identical and the scope difference only becomes visible the moment someone exploits it.
# Role: read-only access to Pods, scoped only
# to the "staging" namespace, never beyond
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: staging
name: pod-reader
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
The wildcard trap
A role using "*" on verbs or resources eliminates the entire value of the model: verbs: ["*"] grants create, delete, patch on top of the simple read access that was probably the original intent. This shortcut, often taken to unblock an incident quickly, then survives indefinitely because nothing forces a revisit once the emergency has passed.
# Avoid: wildcard on verbs grants far more
# than the read access that seemed sufficient at first
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["*"]
The default ServiceAccount, mounted without being asked for
Every pod gets a token from the namespace’s default ServiceAccount mounted automatically, even when the pod has zero need to call the Kubernetes API. That token inherits everything RBAC grants to that ServiceAccount: an overly broad ClusterRole bound to default then exposes its permission to every pod in the namespace, none of which explicitly asked for that access.
# Disables automatic token mounting when the pod
# structurally has no need to call the API
apiVersion: v1
kind: Pod
metadata:
name: worker
spec:
automountServiceAccountToken: false
A dedicated ServiceAccount per application, with its own minimal permissions, rather than the default shared across an entire namespace, shrinks the exposed surface down to what that specific application actually uses.
Auditing what already exists
kubectl auth can-i --list, run as a specific ServiceAccount’s identity, reveals the full set of its effective permissions, aggregated across every RoleBinding and ClusterRoleBinding that applies to it. It’s the only reliable way to answer “what can this account actually do” without manually reconstructing every role binding that touches it.
# Effective permissions of the "ci-deployer" ServiceAccount
# in the "production" namespace, aggregated across all bindings
kubectl auth can-i --list \
--as=system:serviceaccount:production:ci-deployer \
-n production
Regularly auditing every ServiceAccount that matters (anything tied to a CI/CD pipeline, an operator, or any component writing to the cluster) catches what a normal deployment never reveals: excess permissions only show themselves the moment they get exploited.
Takeaway
RBAC denies everything by default, but that model only protects against forgetting to grant a permission, never against granting too much. Choosing the narrowest possible scope (Role over ClusterRole by default), banning wildcards on verbs and resources, disabling automatic token mounting when a pod never calls the API, and regularly auditing via kubectl auth can-i --list: these four habits close the one real blind spot of the deny-by-default model, work that matters especially once a cluster hosts multiple teams, a central concern of a well-run Kubernetes migration.