When everything has cluster-admin, nothing is secure.

Kubernetes RBAC (Role-Based Access Control) exists to answer one question: who can do what to which resources? Most clusters answer incorrectly: “everyone can do everything.”

This isn’t just a security problem — it’s a resilience problem. When a service account gets compromised, how much damage can it do? When someone runs the wrong command, what’s the blast radius?

Least privilege limits that radius.

RBAC Building Blocks

Four resources define Kubernetes RBAC:

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

The Problem: Default Permissions

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 often has accumulated permissions over time. The auto-mounted token is available to every process in the container.

If an attacker compromises your application:

  1. They find the token at /var/run/secrets/kubernetes.io/serviceaccount/token
  2. They query the API server: what can I do?
  3. They pivot through whatever permissions exist

Principle 1: Dedicated Service Accounts

Never use the default service account for workloads:

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 are granted to that specific account, not shared.

Principle 2: Disable Token Auto-Mount

Most applications don’t need Kubernetes API access:

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

If compromised, this workload can’t query the Kubernetes API at all — no token exists.

Principle 3: Namespace Isolation

Roles are namespace-scoped by default. Use this:

# 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 affect staging or other namespaces.

Avoid ClusterRoles unless genuinely needed. Most applications only need access to their own namespace.

Principle 4: Specific Resources

Grant access to specific 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 limits access to specific named resources.

Principle 5: Minimal Verbs

Grant only needed verbs:

VerbMeaning
getRead single resource
listRead multiple resources
watchStream changes
createCreate new resources
updateReplace existing
patchModify existing
deleteRemove resources
deletecollectionRemove multiple

Read-only applications need only get, list, watch. Most don’t need delete or deletecollection.

# Monitoring only needs read access
rules:
  - apiGroups: [""]
    resources: ["pods", "services", "endpoints"]
    verbs: ["get", "list", "watch"]

Common Patterns

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 ServiceAccount is in ci-cd namespace, but the RoleBinding grants access to production namespace.

Auditing Current Permissions

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

Enforcing Least Privilege with Kyverno

Use Kyverno to enforce RBAC best practices:

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

Aggregated ClusterRoles

Kubernetes supports role aggregation for modular permissions:

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

This lets you build roles from composable pieces.

Human Access Patterns

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

Combine with audit logging and alerting when this binding is used.

My Production RBAC Strategy

# 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 explicitly granted
  • Namespace isolation — Applications can’t access other namespaces
  • Named resources — Secrets access limited to specific secrets
  • No wildcards — Every permission is explicit

Testing RBAC Changes

Before applying to production:

# 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 is an attack surface. Every over-privileged account is a potential blast radius.

When you implement least privilege:

  • Compromised applications can only damage what they’re allowed to touch
  • Misconfigurations have bounded impact
  • Audit logs are meaningful (not everything allowed)
  • You understand what each component actually needs

This is resilience through access control. Not preventing compromise, but ensuring it doesn’t cascade.


The question isn’t “what permissions might this application need?” It’s “what’s the minimum required for this application to function?” Start with nothing, add only what’s proven necessary.