The first cluster I ever ran in anger had exactly one permission model: everything 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 great right up until I started thinking about what happens when one of those pods gets popped.
Kubernetes RBAC (Role-Based Access Control) answers a single question: who can do what to which resources? The default answer on most clusters is “everyone can do everything,” and that answer quietly becomes your biggest liability.
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 exactly how far the damage can spread before it spreads. When I fat-finger a command at 11pm, I want the blast radius to be small enough that I can sleep. Least privilege is how you put a ceiling on both.
This post builds up from the four RBAC primitives to the actual strategy I run in production. You can stop reading at any layer and still walk away with something useful.
The Building Blocks
Four resources define Kubernetes RBAC. Two describe permissions, two hand those permissions to someone.
Role - Defines permissions within a namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: production
name: pod-reader
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
ClusterRole - Defines permissions cluster-wide
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: secret-reader
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list"]
RoleBinding - Grants Role permissions to subjects in a namespace
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
ClusterRoleBinding - Grants ClusterRole permissions cluster-wide
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. Role plus Binding, scoped to a namespace or to the whole cluster. Everything else in this post is just discipline about how you use those four.
The Problem: Why the Defaults Bite
Before the discipline, it helps to see why the out-of-the-box behaviour is the thing biting you. Kubernetes defaults are permissive:
# 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, usually because someone bound a ClusterRole to it once to make a thing work and never walked it back. That auto-mounted token 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 find the token at
/var/run/secrets/kubernetes.io/serviceaccount/token - They ask the API server what they can do
- They pivot through whatever permissions happen to exist
The fix is layered. Each of the next five principles is a stopping point. Apply one and you are better off than the defaults. Apply all five and a compromised pod is barely worth the attacker’s time.
Layer 1: Dedicated Service Accounts
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 service account. Permissions land on that specific account, never shared with the rest of the namespace. The moment you do this, your audit logs start telling you who actually did what instead of a wall of default.
Layer 2: Disable Token Auto-Mount
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 need to 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 is nothing to steal. No token on disk means no API access, full stop. This single setting kills the most common pivot path I described above, and it costs you nothing for the apps that genuinely don’t need cluster access.
Layer 3: Namespace Isolation
For the workloads that do need API access, 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 blast radius stops at the namespace boundary, which is exactly the kind of graceful containment I want when something fails.
Reach for ClusterRoles only when you genuinely need cluster-wide reach. Most apps live and die inside one namespace, so keep them there.
Layer 4: Specific Resources
Namespace scope caps how wide the access goes. This layer caps how deep. Grant access to named resources, not entire 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 underused hero here. It pins access down to specific named objects, so an app that needs one ConfigMap can read that one ConfigMap and nothing else.
Layer 5: Minimal Verbs
Last layer, and the cheapest win. You’ve decided which resources an account can touch. Now decide what it’s allowed to do to them. Grant only the verbs you actually use:
| 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 |
Read-only applications need get, list, 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"]
Putting the Layers Together: Real Patterns
Five principles are easy to nod along to and hard to apply under deadline. So here are the patterns I copy-paste most often, each one already wearing all five layers.
Application That Reads ConfigMaps
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
Operator That Manages 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
CI/CD Pipeline Service Account
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, but the RoleBinding sits in production and grants it just enough to deploy there. The pipeline can ship to prod without holding admin anywhere.
Auditing What You Already Have
Patterns help on new workloads. The harder question is what’s already running with too much power. You can’t trust a permission model you can’t inspect, so make the inspection routine.
Check What a ServiceAccount Can 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]
Find Over-Privileged Accounts
# 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(", "))
'
List All RoleBindings in a Namespace
kubectl get rolebindings -n production -o yaml
Making the Rules Stick 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 you need the rules enforced at admission time, not in a wiki nobody reads. I use Kyverno to turn these principles into policy:
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
Now a deployment that forgets these settings gets rejected before it ever schedules. The policy carries the institutional memory so I don’t have to.
Going Further: Aggregated ClusterRoles
Once the basics hold, RBAC has some sharper tools worth knowing. Role aggregation lets you compose 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 automatically. Handy when several teams each own a slice of a shared role.
Human Access Patterns
Everything so far has been about machines. Humans need RBAC too, and they tend to ask for more than they need. The built-in view and edit ClusterRoles cover most of it.
Group-Based Access
# 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
Emergency 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 in an emergency, the whole team knows. Break-glass access you can’t see is just a backdoor.
The Full Picture: My Production Strategy
Pull it all together and here’s the shape of what I actually run. Remember the cluster I opened with, the one where everything was admin? This is the opposite of that, layer for layer.
# 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
Key decisions:
- Default deny - Nothing has access until I explicitly grant it
- Namespace isolation - Applications can’t reach into other namespaces
- Named resources - Secret access pinned to specific secrets
- No wildcards - Every permission is spelled out
Testing Before You Trust It
RBAC changes are easy to get subtly wrong, and a broken binding fails closed in ways that page you at the worst time. 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 This Matters
Every permission you hand out is attack surface you now have to think about. Every over-privileged account is a blast radius waiting for a bad day.
When least privilege is in place, that bad day stays small. A compromised app can only touch what you let it touch. A misconfiguration has a bounded impact instead of a cluster-wide one. Your audit logs mean something, because not everything was allowed in the first place. And you end up understanding what each component genuinely needs, which is its own quiet reward.
This is resilience through access control. You aren’t trying to stop every compromise, you’re making sure one doesn’t cascade into all of them.
The trade-off is real, and I won’t pretend otherwise. Least privilege is more upfront work than a single cluster-admin binding, and you will 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 a little convenience for a lot of containment.
One habit makes the rest fall into place: start every new account with zero permissions and add only what you can prove it uses. Begin from nothing, and least privilege stops being a project and becomes the default.
