Kubernetes RBAC: A Practical Guide to Least-Privilege Access Control
The Breach That Starts With cluster-admin
In 2023, a compromised CI/CD service account with cluster-admin privileges led to a full cluster takeover at a mid-sized fintech company. The attacker didn't exploit a zero-day. They didn't bypass a firewall. They simply used the permissions they were given — permissions that were far too broad for the workload.
I've audited enough clusters to know this isn't the exception. It's the norm. Most Kubernetes environments hand out cluster-admin like candy, and the teams running them don't realize the blast radius until it's too late.
RBAC isn't optional. It's your primary access control layer inside the cluster. If you're not implementing least-privilege, you're one compromised pod away from a full breach.
Understanding the RBAC Model
Kubernetes RBAC operates on four core objects:
| Object | Scope | Purpose |
|---|---|---|
Role | Namespace | Defines permissions within a single namespace |
ClusterRole | Cluster-wide | Defines permissions across the entire cluster |
RoleBinding | Namespace | Binds a Role/ClusterRole to subjects in a namespace |
ClusterRoleBinding | Cluster-wide | Binds a ClusterRole to subjects cluster-wide |
The key principle: always prefer Role + RoleBinding over ClusterRole + ClusterRoleBinding. Namespace-scoped permissions contain the blast radius of a compromised identity.
Audit Your Current RBAC State First
Before you lock anything down, you need to understand what exists. Run this to find every ClusterRoleBinding that grants cluster-admin:
kubectl get clusterrolebindings -o json | \
jq -r '.items[] | select(.roleRef.name=="cluster-admin") |
{name: .metadata.name, subjects: .subjects}'
Now check for overly permissive roles using wildcard verbs or resources:
kubectl get clusterroles -o json | \
jq -r '.items[] | select(.rules[]? |
(.verbs[]? == "*") or (.resources[]? == "*")) |
.metadata.name'
If the output of either command is longer than three or four lines, you have a problem. Every entry in that list is a potential escalation path.
Designing Least-Privilege Roles
Here is a Role that gives a deployment pipeline exactly what it needs to deploy — and nothing more:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ci-deployer
namespace: production
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "patch", "update"]
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
Notice what's absent: no create on pods (the Deployment controller handles that), no access to secrets, no delete permissions. Your CI pipeline doesn't need to delete Deployments — it needs to update them.
Bind it to a ServiceAccount, not a user:
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ci-deployer-binding
namespace: production
subjects:
- kind: ServiceAccount
name: ci-deployer
namespace: ci-cd
roleRef:
kind: Role
name: ci-deployer
apiGroup: rbac.authorization.k8s.io
Threat Model: Compromised ServiceAccount Token
Assume an attacker gains access to a ServiceAccount token — through a leaked kubeconfig, a compromised pod, or a misconfigured CI secret. Here's the threat model:
Without RBAC scoping: The attacker has cluster-admin. They can read all secrets (including TLS certs, database credentials, API keys), deploy cryptominers, pivot to cloud provider APIs via IMDS, and exfiltrate data from every namespace.
With proper RBAC: The attacker can list deployments in one namespace. That's it. The blast radius shrinks from "entire infrastructure" to "one namespace's deployment metadata."
Blocking Privilege Escalation
Kubernetes has a critical subtlety: anyone who can create or update a RoleBinding can grant themselves arbitrary permissions up to and including cluster-admin. You must explicitly prevent this.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: restricted-admin
rules:
- apiGroups: ["apps", "batch"]
resources: ["deployments", "statefulsets", "jobs", "cronjobs"]
verbs: ["*"]
- apiGroups: [""]
resources: ["services", "configmaps", "pods", "pods/log"]
verbs: ["*"]
# Explicitly NO access to:
# - rbac.authorization.k8s.io (Roles, RoleBindings)
# - "" secrets
# - nodes
# - persistentvolumes (cluster-scoped)
Also deny the escalate and bind verbs. If a user can bind a ClusterRole, they can attach cluster-admin to their own ServiceAccount:
# This is what you do NOT want to see:
rules:
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["clusterroles"]
verbs: ["bind", "escalate"] # NEVER grant these
Integrate RBAC Validation Into Your Pipeline
Manual RBAC reviews don't scale. Put validation in CI. Here's a GitHub Actions step using conftest with OPA policies:
# .github/workflows/rbac-check.yml
name: RBAC Policy Check
on:
pull_request:
paths: ["k8s/rbac/**"]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install conftest
run: |
wget -q https://github.com/open-policy-agent/conftest/releases/download/v0.50.0/conftest_0.50.0_linux_amd64.tar.gz
tar xzf conftest_0.50.0_linux_amd64.tar.gz
sudo mv conftest /usr/local/bin/
- name: Validate RBAC manifests
run: conftest test k8s/rbac/ -p policy/rbac/
And the OPA policy to catch wildcard permissions:
# policy/rbac/deny_wildcards.rego
package rbac
deny[msg] {
input.kind == "ClusterRole"
rule := input.rules[_]
rule.verbs[_] == "*"
msg := sprintf("ClusterRole '%s' uses wildcard verbs — enumerate explicit permissions instead", [input.metadata.name])
}
deny[msg] {
input.kind == "ClusterRole"
rule := input.rules[_]
rule.resources[_] == "*"
msg := sprintf("ClusterRole '%s' uses wildcard resources — this violates least-privilege", [input.metadata.name])
}
deny[msg] {
input.kind == "ClusterRoleBinding"
input.roleRef.name == "cluster-admin"
msg := sprintf("ClusterRoleBinding '%s' grants cluster-admin — this requires security team approval", [input.metadata.name])
}
Monitoring RBAC With Audit Logs
Enable the Kubernetes audit policy to log all RBAC-related actions:
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
- level: RequestResponse
resources:
- group: "rbac.authorization.k8s.io"
resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]
- level: Metadata
verbs: ["create", "delete", "patch"]
resources:
- group: ""
resources: ["secrets", "serviceaccounts"]
Forward these audit logs to your SIEM. Alert on any ClusterRoleBinding creation or modification. In a mature environment, those events should be rare — and when they happen, someone should be paged.
Quick Wins You Can Ship Today
- Find and remove all unnecessary
cluster-adminbindings. Run the audit command above. Challenge every single one. - Create namespace-scoped Roles for every CI/CD pipeline. No pipeline needs cluster-wide access.
- Block ServiceAccount token automounting on pods that don't need API access:
apiVersion: v1
kind: ServiceAccount
metadata:
name: app-service-account
automountServiceAccountToken: false
- Set up short-lived credentials. Use TokenRequest API for time-bound tokens instead of long-lived secrets:
kubectl create token ci-deployer \
--namespace ci-cd \
--duration 15m
- Run
kubectl auth can-i --listagainst every ServiceAccount in your cluster. Automate this as a weekly job and review the output.
Debugging RBAC Denials
When pods or users start getting Forbidden errors after you tighten RBAC, you need a systematic way to diagnose what's missing. Start with kubectl auth can-i:
# Check if a specific ServiceAccount can perform an action
kubectl auth can-i get deployments \
--as=system:serviceaccount:ci-cd:ci-deployer \
--namespace=production
# yes
kubectl auth can-i delete deployments \
--as=system:serviceaccount:ci-cd:ci-deployer \
--namespace=production
# no
# List everything a ServiceAccount can do in a namespace
kubectl auth can-i --list \
--as=system:serviceaccount:ci-cd:ci-deployer \
--namespace=production
When the output doesn't match what you expect, trace the binding chain. This script finds all Roles and ClusterRoles bound to a specific ServiceAccount:
#!/bin/bash
# scripts/trace-rbac.sh — Trace all RBAC bindings for a ServiceAccount
SA_NAME="${1:?Usage: trace-rbac.sh <sa-name> <sa-namespace>}"
SA_NAMESPACE="${2:?Usage: trace-rbac.sh <sa-name> <sa-namespace>}"
echo "=== RoleBindings in $SA_NAMESPACE ==="
kubectl get rolebindings -n "$SA_NAMESPACE" -o json | \
jq -r --arg sa "$SA_NAME" --arg ns "$SA_NAMESPACE" \
'.items[] | select(.subjects[]? |
.kind == "ServiceAccount" and .name == $sa and .namespace == $ns) |
" \(.metadata.name) -> \(.roleRef.kind)/\(.roleRef.name)"'
echo ""
echo "=== ClusterRoleBindings ==="
kubectl get clusterrolebindings -o json | \
jq -r --arg sa "$SA_NAME" --arg ns "$SA_NAMESPACE" \
'.items[] | select(.subjects[]? |
.kind == "ServiceAccount" and .name == $sa and .namespace == $ns) |
" \(.metadata.name) -> \(.roleRef.kind)/\(.roleRef.name)"'
Run it against your CI deployer:
./scripts/trace-rbac.sh ci-deployer ci-cd
# === RoleBindings in ci-cd ===
# ci-deployer-binding -> Role/ci-deployer
# === ClusterRoleBindings ===
# (none — this is what you want)
If you see unexpected ClusterRoleBindings, that's a red flag. A namespace-scoped ServiceAccount bound to a ClusterRole has broader access than the team probably intended.
Automating RBAC Audits With CronJobs
One-time audits are a start. Continuous auditing is what actually keeps your cluster secure. Deploy a CronJob that runs weekly, generates a report of all overly permissive bindings, and sends it to Slack or your ticketing system:
apiVersion: batch/v1
kind: CronJob
metadata:
name: rbac-audit
namespace: security-tools
spec:
schedule: "0 9 * * 1" # Every Monday at 9 AM
jobTemplate:
spec:
template:
spec:
serviceAccountName: rbac-auditor
containers:
- name: auditor
image: bitnami/kubectl:1.30
command: ["/bin/sh", "-c"]
args:
- |
echo "=== RBAC Audit Report $(date -I) ==="
echo ""
echo "--- cluster-admin bindings ---"
kubectl get clusterrolebindings -o json | \
jq -r '.items[] | select(.roleRef.name=="cluster-admin") |
"\(.metadata.name): \(.subjects)"'
echo ""
echo "--- Wildcard ClusterRoles ---"
kubectl get clusterroles -o json | \
jq -r '.items[] | select(.rules[]? |
(.verbs[]? == "*") or (.resources[]? == "*")) |
.metadata.name'
echo ""
echo "--- ServiceAccounts with automountToken enabled ---"
kubectl get serviceaccounts --all-namespaces -o json | \
jq -r '.items[] | select(.automountServiceAccountToken != false) |
"\(.metadata.namespace)/\(.metadata.name)"'
restartPolicy: Never
The ServiceAccount running this CronJob needs read-only access to RBAC resources and ServiceAccounts cluster-wide. Create a dedicated ClusterRole for it:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: rbac-auditor
rules:
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["serviceaccounts"]
verbs: ["get", "list"]
Pipe the output to your alerting system. Any new cluster-admin binding that appears between runs should be investigated immediately.
The Bottom Line
Every RBAC permission you grant is an assumption that the identity holding it won't be compromised. The question isn't whether a ServiceAccount will be compromised — it's when. Least-privilege RBAC ensures that when that day comes, the attacker gets access to a narrow slice of your cluster instead of the entire thing.
Stop treating RBAC as a checkbox. Treat it as your most important security control inside the cluster perimeter. Because it is.
Related Articles
DevSecOps Lead
Security-first mindset in everything I ship. From zero-trust architectures to supply chain security, I make sure your pipeline doesn't become your weakest link.
Related Articles
Kubernetes Security Hardening for Production: The Complete Guide
Harden Kubernetes clusters for production with RBAC, network policies, pod security standards, secrets management, and admission controllers.
Security Headers & Configs: Cheat Sheet
Security headers and configuration reference — copy-paste snippets for Nginx, Kubernetes Ingress, Cloudflare, and Helmet.js.
OPA Gatekeeper: Enforcing Kubernetes Admission Control Policies That Actually Stop Misconfigurations
Deploy OPA Gatekeeper to enforce Kubernetes admission policies — block privileged containers, enforce labels, and prevent misconfigurations.