TL;DR Kubernetes ships with a permission model that hands every pod a token and lets the default service account collect rights nobody remembers granting. Least privilege means a service account per workload, no token unless the app talks to the API, and roles that name the namespace, the resource and the verb. Below are the four RBAC resources, the five settings that cap the blast radius, the bindings I copy most often, and how I check and enforce all of it.
The first cluster I ever ran in anger had exactly one permission model, and it was cluster-admin. My CI pipeline, my monitoring stack, the little webhook receiver I threw together one afternoon. All of it could read every secret, delete every deployment and touch every namespace. It worked fine, right up until I started thinking about what happens when one of those pods gets popped.
Kubernetes RBAC (Role-Based Access Control) answers one question. Who can do what to which resources? On most clusters the answer is “everyone can do everything”, and nobody decided that. It’s what you get when you never write anything down.
I care about this for the same reason I care about understanding the rest of my stack. When a service account gets compromised, I want to know how far the damage reaches before it gets there. When I fat-finger a kubectl command, I want the damage small enough that I can fix it myself before anyone notices. Least privilege is the ceiling on both.
This post goes from the four RBAC resources to the bindings I run in production, and ends with how I check that a binding does what I think it does.
The four resources
Four resources make up Kubernetes RBAC. Two describe permissions, two hand those permissions to someone.
A Role describes permissions inside one namespace. This one lets its holder read pods in production and nothing else.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: production
name: pod-reader
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
A ClusterRole describes permissions across the cluster. Same shape, no namespace in the metadata.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: secret-reader
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list"]
A RoleBinding hands a Role to a subject, in one namespace. Here the monitoring service account gets the pod-reader Role from above.
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: read-pods
namespace: production
subjects:
- kind: ServiceAccount
name: monitoring
namespace: monitoring
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io
A ClusterRoleBinding does the same with a ClusterRole, cluster-wide. This one gives everyone in the security team read access to every secret there is.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: cluster-secret-reader
subjects:
- kind: Group
name: security-team
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: secret-reader
apiGroup: rbac.authorization.k8s.io
That’s the whole vocabulary. A Role and a Binding, scoped to a namespace or to the cluster. Everything else in this post is discipline about how you use those four.
What you get out of the box
Before the discipline, it helps to see why the defaults are the thing biting you. A deployment that doesn’t say otherwise looks like this:
# What many deployments look like
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
# Default service account with broad permissions
serviceAccountName: default
# Token auto-mounted
automountServiceAccountToken: true
The default service account collects permissions over time. Someone bound a ClusterRole to it once to make a thing work and never walked it back. I’ve done that. The auto-mounted token then sits inside every process in the container, waiting.
So when an attacker gets a shell in your app, the chain is short and boring. They read the token at /var/run/secrets/kubernetes.io/serviceaccount/token, ask the API server what it’s allowed to do, and walk through whatever happens to be there.
The next five settings each close part of that chain. Apply one and you’re better off than the defaults. Apply all five and a compromised pod is barely worth the attacker’s time.
A service account per workload
Start here. Never run a workload as the default service account.
apiVersion: v1
kind: ServiceAccount
metadata:
name: api-server
namespace: production
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
spec:
template:
spec:
serviceAccountName: api-server
automountServiceAccountToken: false # Don't mount unless needed
Each application gets its own account, and permissions land on that account and nowhere else. The moment you do this, your audit log starts naming who did what instead of showing a wall of default. That alone made me wish I’d done it a year earlier.
No token unless the app needs one
Here’s the part most people skip. The bulk of my workloads never talk to the Kubernetes API at all. A web frontend doesn’t list pods. So why hand it a token?
apiVersion: v1
kind: ServiceAccount
metadata:
name: web-frontend
automountServiceAccountToken: false
---
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
serviceAccountName: web-frontend
automountServiceAccountToken: false # Belt and suspenders
Get a shell in this workload and there’s nothing to steal. No token on disk, no API access, done. This one setting kills the pivot I described above, and it costs nothing for the apps that never needed the API in the first place.
Keep roles inside the namespace
For the workloads that do need the API, the next question is how far that access reaches. Roles are namespace-scoped by default, which is a gift. Take it.
# Role only works in production namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: production
name: deployment-manager
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
A compromised service in production can’t touch staging or anything else. The damage stops at the namespace boundary, which is exactly where I want it to stop.
Reach for a ClusterRole only when you need more than one namespace. Most apps live and die inside one namespace, so keep them there.
Name the resources
Namespace scope caps how wide the access goes. This caps how deep. Grant access to named resources, not whole API groups.
# Bad: access to all core resources
rules:
- apiGroups: [""]
resources: ["*"]
verbs: ["*"]
# Good: only specific resources needed
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get"]
resourceNames: ["app-config"] # Even more specific
The resourceNames field is the one I see used least and like most. It pins access to specific named objects, so an app that needs one ConfigMap can read that one ConfigMap and nothing else.
Only the verbs it uses
Last one, and the cheapest. You’ve decided which resources an account can touch. Now decide what it may do to them.
| Verb | Meaning |
|---|---|
| get | Read single resource |
| list | Read multiple resources |
| watch | Stream changes |
| create | Create new resources |
| update | Replace existing |
| patch | Modify existing |
| delete | Remove resources |
| deletecollection | Remove multiple |
A read-only application needs get, list and watch, and nothing more. A monitoring agent has no business holding delete or deletecollection, so don’t give it the option.
# Monitoring only needs read access
rules:
- apiGroups: [""]
resources: ["pods", "services", "endpoints"]
verbs: ["get", "list", "watch"]
The bindings I copy most often
Five rules are easy to nod along to and hard to apply under deadline. So here are the ones I copy-paste, each already wearing all five.
An application that reads a ConfigMap
apiVersion: v1
kind: ServiceAccount
metadata:
name: config-reader
namespace: myapp
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: config-reader
namespace: myapp
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "watch"]
resourceNames: ["app-settings"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: config-reader
namespace: myapp
subjects:
- kind: ServiceAccount
name: config-reader
namespace: myapp
roleRef:
kind: Role
name: config-reader
apiGroup: rbac.authorization.k8s.io
An operator that manages its own custom resources
apiVersion: v1
kind: ServiceAccount
metadata:
name: myapp-operator
namespace: myapp-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: myapp-operator
rules:
# Own CRDs
- apiGroups: ["myapp.example.com"]
resources: ["myapps", "myapps/status"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Resources it creates
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["services", "configmaps"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: myapp-operator
subjects:
- kind: ServiceAccount
name: myapp-operator
namespace: myapp-system
roleRef:
kind: ClusterRole
name: myapp-operator
apiGroup: rbac.authorization.k8s.io
A CI pipeline that deploys to production
apiVersion: v1
kind: ServiceAccount
metadata:
name: gitlab-deployer
namespace: ci-cd
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: deployer
namespace: production
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: [""]
resources: ["configmaps", "secrets"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: gitlab-deployer
namespace: production
subjects:
- kind: ServiceAccount
name: gitlab-deployer
namespace: ci-cd
roleRef:
kind: Role
name: deployer
apiGroup: rbac.authorization.k8s.io
Note the cross-namespace trick. The ServiceAccount lives in ci-cd, the RoleBinding sits in production, and the binding grants just enough to deploy there. The pipeline can ship to prod without holding admin anywhere.
Auditing what’s already running
Patterns help on new workloads. The harder question is what’s already running with too much power. I can’t trust a permission model I can’t inspect, so the inspection is routine.
What one service account may do
kubectl auth can-i --list --as=system:serviceaccount:production:api-server
# Output:
# Resources Non-Resource URLs Verbs
# configmaps [] [get watch]
# pods [] [get list watch]
Who holds cluster-admin
# Find all ClusterRoleBindings to cluster-admin
kubectl get clusterrolebindings -o json | jq -r '
.items[] |
select(.roleRef.name == "cluster-admin") |
.metadata.name + ": " + (.subjects // [] | map(.name) | join(", "))
'
The first time I ran that on a cluster I’d inherited, the list was longer than I’d like to write down.
Every binding in a namespace
kubectl get rolebindings -n production -o yaml
Enforcing it with Kyverno
Auditing tells you where you stand today. It does nothing to stop the next person from shipping a pod on the default account next week. For that the rules have to be checked at admission, not written in a wiki nobody reads. I use Kyverno for that.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-dedicated-service-account
spec:
validationFailureAction: Enforce
rules:
- name: no-default-serviceaccount
match:
any:
- resources:
kinds:
- Pod
validate:
message: "Using 'default' service account is not allowed"
pattern:
spec:
serviceAccountName: "!default"
---
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: disable-automount-by-default
spec:
validationFailureAction: Enforce
rules:
- name: disable-automount
match:
any:
- resources:
kinds:
- Pod
validate:
message: "automountServiceAccountToken must be explicitly set to false unless needed"
pattern:
spec:
automountServiceAccountToken: false
A deployment that forgets these settings now gets rejected before it schedules. I don’t have to remember the rule, and neither does the next person.
Aggregated ClusterRoles
Once the basics hold, RBAC has a sharper tool worth knowing. Role aggregation composes permissions from labelled pieces.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: monitoring-base
labels:
rbac.example.com/aggregate-to-monitoring: "true"
rules:
- apiGroups: [""]
resources: ["pods", "services"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: monitoring-extended
labels:
rbac.example.com/aggregate-to-monitoring: "true"
rules:
- apiGroups: ["apps"]
resources: ["deployments", "replicasets"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: monitoring
aggregationRule:
clusterRoleSelectors:
- matchLabels:
rbac.example.com/aggregate-to-monitoring: "true"
rules: [] # Rules are automatically filled by aggregation
Drop the right label on a new ClusterRole and it folds into the aggregate on its own. Handy when several teams each own a slice of one shared role.
Humans
Everything so far has been about machines. People need RBAC too, and in my experience they ask for more than they need. The built-in view and edit ClusterRoles cover most of it.
Bind groups, not users
# Developers can view most things in their namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: developers-view
namespace: development
subjects:
- kind: Group
name: developers
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: view # Built-in read-only role
apiGroup: rbac.authorization.k8s.io
---
# Platform team has edit in all namespaces
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: platform-team-edit
subjects:
- kind: Group
name: platform-team
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: edit # Built-in edit role
apiGroup: rbac.authorization.k8s.io
Break-glass
# Emergency access that's audited heavily
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: emergency-admin
annotations:
description: "Emergency access - all usage is audited and reviewed"
subjects:
- kind: Group
name: emergency-responders
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
Wire this binding to audit logging and an alert, so the moment someone reaches for cluster-admin the team knows. Without the alert it’s a backdoor that happens to have a YAML file.
What I run
Put it together and this is what my clusters look like. The one I opened with had one binding, and it was cluster-admin. This is what replaced it.
# 1. Disable default service account tokens cluster-wide
apiVersion: v1
kind: ServiceAccount
metadata:
name: default
namespace: production
automountServiceAccountToken: false
# 2. Base read-only role for all applications
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: app-base
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "watch"]
# 3. Application-specific additions
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: api-server
namespace: production
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get"]
resourceNames: ["api-credentials", "db-credentials"]
# 4. Combined binding
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: api-server
namespace: production
subjects:
- kind: ServiceAccount
name: api-server
namespace: production
roleRef:
kind: Role
name: api-server
apiGroup: rbac.authorization.k8s.io
Nothing has access until I grant it. Applications stay inside their namespace. Secret access is pinned to named secrets, and no rule carries a wildcard.
Test it before you trust it
RBAC changes are easy to get subtly wrong, and a broken binding fails closed at whatever moment is worst for you. So I never apply a role to production without checking what it grants first.
# Dry-run: what would this service account be able to do?
kubectl auth can-i --list \
--as=system:serviceaccount:production:api-server
# Test specific permission
kubectl auth can-i get secrets \
--as=system:serviceaccount:production:api-server \
-n production
# Test with impersonation
kubectl get pods -n production \
--as=system:serviceaccount:production:api-server
Why I bother
Every permission you hand out is something you now have to think about. Every over-privileged account is a bigger blast radius than the job needs.
With least privilege in place, that bad day stays small. A compromised app can only touch what you let it touch. A misconfiguration breaks one namespace instead of the cluster. The audit log means something, because not everything was allowed to begin with. And you end up knowing what each component needs, which I’ve come to like on its own.
That’s what I mean by resilience through access control. One compromise doesn’t have to become all of them.
The trade-off is real. Least privilege is more upfront work than a single cluster-admin binding, and you’ll hit a few “why can’t this pod do X” moments before the model settles. I think it’s worth it every time, but go in knowing you’re trading some convenience for a lot of containment.
Every new service account on my clusters starts with zero permissions and gets a rule only when I can point at the call that needs it. I still trip over a missing verb now and then. I’ll take that over the cluster I started with, where everything could delete everything.
