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:

  1. They find the token at /var/run/secrets/kubernetes.io/serviceaccount/token
  2. They ask the API server what they can do
  3. 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:

VerbMeaning
getRead single resource
listRead multiple resources
watchStream changes
createCreate new resources
updateReplace existing
patchModify existing
deleteRemove resources
deletecollectionRemove 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.