Guides/KubernetesKubernetes/Kubernetes RBAC and Security

Kubernetes RBAC and Security

How Kubernetes access control works: the RBAC model, ServiceAccounts, least-privilege bindings, the pod securityContext, Pod Security Admission, and Secrets.


By default a fresh Kubernetes cluster is a lot more open than people assume. Anyone with a kubeconfig can be cluster-admin, pods run as root, and a Secret is just base64 text that any workload with the right permissions can read. Locking this down is not one setting - it is a few layers that each answer a different question. RBAC answers "who can do what to the API." ServiceAccounts answer "what identity does this pod have." The securityContext and Pod Security Admission answer "what can the container do to the node it runs on." This guide walks each layer with concrete YAML, and the theme throughout is least privilege: hand out the narrowest permission that still lets the job get done.

The RBAC model: who can do what

Role-Based Access Control (RBAC) is how the API server decides whether a request is allowed. Every request that hits the API server carries an identity, and RBAC checks that identity against a set of rules before letting it read, create, or delete anything. It breaks into three pieces that click together:

  • Subjects - the who. Three kinds: Users (human identities, authenticated by your cluster's setup - certificates, OIDC, a cloud IAM integration), Groups (collections of users, again from your auth provider), and ServiceAccounts (identities for workloads running inside the cluster). A crucial detail: Kubernetes has no User object. Users and groups are not stored in the cluster at all - they come from whatever authenticates the request. Only ServiceAccounts are real cluster objects you create.
  • Roles - the what. A Role is a list of permissions: which verbs (get, list, watch, create, update, delete) are allowed on which resources (pods, deployments, secrets). A Role grants only - there is no "deny" rule in RBAC. If no rule permits an action, it is denied by default.
  • Bindings - the glue. A binding attaches a Role to one or more Subjects. Without a binding, a Role does nothing; it is just a definition sitting there. The binding is what actually grants the permission to a who.

The mental model: a Role is a permission set, a Subject is an identity, and a binding says "this identity gets this permission set." Nothing is granted until a binding exists.

Roles vs ClusterRoles, RoleBindings vs ClusterRoleBindings

There are two flavors of each, and the only difference is scope.

  • A Role is namespaced. Its rules apply only within the one namespace it lives in. A Role in team-a can grant access to pods in team-a and nowhere else.
  • A ClusterRole is cluster-wide. It can grant access to namespaced resources across all namespaces, and it is the only way to grant access to cluster-scoped resources (nodes, namespaces, PersistentVolumes) that do not live in any namespace.

The bindings mirror this:

  • A RoleBinding grants a Role (or a ClusterRole) within a single namespace. This is the important trick: you can define one ClusterRole - say, a generic "view pods and read logs" permission set - and reuse it in many namespaces by referencing it from a RoleBinding in each. The ClusterRole supplies the rules; the RoleBinding confines them to its own namespace.
  • A ClusterRoleBinding grants a ClusterRole across the entire cluster. This is the powerful, dangerous one. A ClusterRoleBinding to the built-in cluster-admin ClusterRole is full god-mode over everything. Reach for ClusterRoleBindings rarely and review them hard.

The combination to remember: RoleBinding + ClusterRole is the least-privilege workhorse (define once, scope per namespace), while ClusterRoleBinding + ClusterRole is broad and should stay reserved for genuinely cluster-wide operators and admins.

A concrete least-privilege example

Say you have a service in the payments namespace that needs to read ConfigMaps and list pods - and nothing else. You never want it able to touch Secrets, delete anything, or see other namespaces. Here is the whole grant.

First, a namespaced Role listing exactly the allowed verbs and resources:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: payments
  name: config-reader
rules:
  - apiGroups: [""]              # "" is the core API group
    resources: ["configmaps"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list"]

Then a ServiceAccount for the workload to run as:

apiVersion: v1
kind: ServiceAccount
metadata:
  namespace: payments
  name: payments-app

Then a RoleBinding that attaches the Role to that ServiceAccount, inside the payments namespace:

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  namespace: payments
  name: payments-app-config-reader
subjects:
  - kind: ServiceAccount
    name: payments-app
    namespace: payments
roleRef:
  kind: Role
  name: config-reader
  apiGroup: rbac.authorization.k8s.io

That is the complete grant. The payments-app identity can read ConfigMaps and list pods in payments, and the RBAC default-deny takes care of everything else - it cannot read a Secret, cannot delete a pod, cannot see the staging namespace. To verify a grant without guessing, use kubectl auth can-i, which asks the API server the exact question RBAC answers:

kubectl auth can-i get configmaps \
  --as=system:serviceaccount:payments:payments-app -n payments   # yes
kubectl auth can-i delete pods \
  --as=system:serviceaccount:payments:payments-app -n payments   # no

Building grants this way - start from zero, add only the verbs and resources the workload provably needs - is what "least privilege" means in practice. The opposite (binding everything to cluster-admin because it makes the errors go away) is how clusters get owned.

ServiceAccounts: identity for pods

Humans authenticate as Users; workloads authenticate as ServiceAccounts. Every pod runs as exactly one ServiceAccount - if you do not name one, it runs as the namespace's default ServiceAccount. That default detail matters: whatever permissions you bind to default are silently handed to every pod in the namespace that did not ask for a specific identity, so never grant permissions to default.

A pod adopts a ServiceAccount by naming it in the spec:

apiVersion: v1
kind: Pod
metadata:
  name: payments-worker
  namespace: payments
spec:
  serviceAccountName: payments-app
  automountServiceAccountToken: true   # set false if the pod never calls the API
  containers:
    - name: worker
      image: payments-worker:2.1.0

Under the hood, the kubelet mounts a short-lived, automatically-rotated token for that ServiceAccount into the pod at /var/run/secrets/kubernetes.io/serviceaccount/token. Any code in the pod that talks to the Kubernetes API presents this token, and RBAC evaluates the request against the ServiceAccount's bindings. If your workload never calls the Kubernetes API - which is most application pods - set automountServiceAccountToken: false so there is no API token sitting in the container for an attacker to steal. Give each meaningful workload its own ServiceAccount with its own narrow Role rather than sharing one broad identity; that way a compromised pod's blast radius is exactly its own permissions and no more.

The securityContext: what the container can do to the node

RBAC controls the API. The securityContext controls something different and just as important: what the container process can do on the node it runs on. By default a container often runs as root (UID 0) with a writable root filesystem and a pile of Linux capabilities - and root in a container is uncomfortably close to root on the host if anything escapes. You tighten this per pod and per container:

apiVersion: v1
kind: Pod
metadata:
  name: hardened
spec:
  securityContext:
    runAsNonRoot: true          # refuse to start if the image runs as root
    runAsUser: 1000             # run as this unprivileged UID
    fsGroup: 2000               # group that owns mounted volumes
  containers:
    - name: app
      image: myapp:1.5.0
      securityContext:
        allowPrivilegeEscalation: false   # process cannot gain more privileges than it started with
        readOnlyRootFilesystem: true      # the container filesystem is immutable
        capabilities:
          drop: ["ALL"]                   # drop every Linux capability, then add back only what is needed

Each of these closes a real door:

  • runAsNonRoot: true makes the kubelet refuse to start the pod if the image would run as UID 0. It is a guardrail against accidentally shipping a root container.
  • runAsUser: 1000 pins the process to a specific unprivileged user. Combine it with runAsNonRoot so a mistake fails loudly instead of silently running as root.
  • readOnlyRootFilesystem: true makes the container's filesystem immutable, so an attacker cannot drop a binary or modify the app in place. If the app needs scratch space, mount an emptyDir volume at just that path.
  • capabilities.drop: ["ALL"] strips every Linux capability and then you add back only the specific ones the app truly needs (rarely any for a normal service). This is the single highest-value line; most containers need zero capabilities.
  • allowPrivilegeEscalation: false stops the process from gaining more privileges than it started with (blocking setuid tricks). There is almost never a reason to leave it on.

Apply these by default and loosen only where a workload demonstrably needs more. A container running as a non-root user, with no capabilities, a read-only root filesystem, and no privilege escalation, is a dramatically smaller target than the default.

Pod Security Admission: enforcing the baseline

Setting a good securityContext on every pod by hand does not scale and depends on everyone remembering. Pod Security Admission (PSA) is the built-in admission controller (stable since 1.25) that enforces security standards at the namespace level, and it is the replacement for the removed PodSecurityPolicy. You do not write policy objects - you label a namespace with one of three predefined levels and PSA rejects (or warns about) pods that violate it.

The levels:

  • privileged - no restrictions. For trusted, infrastructure workloads only.
  • baseline - blocks the known-dangerous stuff: no privileged containers, no host namespaces, no hostPath volumes, a limited set of capabilities. A sane minimum for most namespaces.
  • restricted - the hardened profile. Requires exactly the securityContext hardening from the section above: runAsNonRoot, allowPrivilegeEscalation: false, capabilities dropped to ALL, seccomp set. This is the target for application namespaces.

You opt a namespace in with labels, and each level can run in three modes - enforce (reject violating pods), audit (log them), and warn (show a warning to the user applying them):

apiVersion: v1
kind: Namespace
metadata:
  name: payments
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
    pod-security.kubernetes.io/warn: restricted

A good rollout is to set warn and audit first, watch what would break, fix those workloads, then flip enforce on. With enforce: restricted, a pod that tries to run as root or keep its capabilities is simply refused by the API server - the guardrail is now the cluster's, not each author's discipline.

Securing Secrets: RBAC plus encryption at rest

Secrets deserve their own attention because of a trap covered in the configuration guide: a Secret is only base64-encoded by default, not encrypted. Base64 is trivially reversible - echo <value> | base64 -d reveals it. So "it is in a Secret" buys you separation and access control, not secrecy by itself. Two things make Secrets actually safe:

RBAC controls who can read them. Secret access is just another RBAC verb on the secrets resource, so treat get/list on Secrets as a privileged grant. Be especially wary of list, which returns the Secret contents, and of wildcard rules. The narrow pattern is to grant access to specific named Secrets with resourceNames rather than the whole resource type:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: payments
  name: db-secret-reader
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["get"]
    resourceNames: ["db-credentials"]   # this one Secret, not all of them

Encryption at rest protects them in etcd. Because Secrets are stored in etcd as base64, anyone who can read the etcd data (a stolen backup, direct disk access, a compromised control-plane node) reads every Secret in the cluster. Enable encryption at rest so the API server encrypts Secret data before writing it to etcd, using a key you control (ideally backed by a cloud KMS). Between tight RBAC on the secrets resource and encryption at rest, plus disabling the ServiceAccount token automount on pods that do not need it, you close the common paths to a Secret leaking. For anything high-value, many teams go further and pull secrets from an external manager (Vault, a cloud secrets service) at runtime rather than storing them in the cluster at all.

One more layer: NetworkPolicies

RBAC and the securityContext control the API and the node, but by default every pod in the cluster can reach every other pod over the network - namespaces are not a network boundary. Closing that off is a separate mechanism, NetworkPolicies, which let you declare which pods may talk to which, effectively a per-pod firewall. It is a core part of a least-privilege posture (a compromised pod should not be able to scan the whole cluster), and it is covered in the networking guide. Treat it as the fourth layer alongside RBAC, ServiceAccounts, and pod hardening.

The shape of it

Kubernetes security is layered, and each layer answers a different question. RBAC decides who can do what to the API: Roles and ClusterRoles define permission sets, RoleBindings and ClusterRoleBindings attach them to Subjects, and the default is deny - so you build up from zero with the narrowest grant that works. ServiceAccounts give pods their own identity, so a workload gets exactly its own permissions and no more; never lean on default, and turn off the token mount when a pod never calls the API. The securityContext shrinks what a container can do to its node - non-root, no capabilities, read-only filesystem, no privilege escalation - and Pod Security Admission enforces that baseline across a whole namespace instead of trusting every author. Secrets are only base64 until you add RBAC on the secrets resource and encryption at rest. And NetworkPolicies finish the picture by locking down pod-to-pod traffic. Default-deny everywhere, grant the minimum, and a compromise stays small.