Skip to content
network-securityintermediate#kubernetes#container-security#rbac#cloud-security#devsecops

Kubernetes Security Guide: Pod Security Standards, RBAC and Hardening

How to harden Kubernetes workloads: the Pod Security Standards and admission, the securityContext fields that matter, container escape paths, RBAC, NetworkPolicy and secrets.

Kubernetes runs a pod with whatever privileges its manifest requests. A Deployment can ask for root, every Linux capability, the node's process table and a mount of the node's filesystem, and the API server accepts it unless something in the cluster says otherwise. Most Kubernetes compromises that reach the node start from one of those requests, made by a chart nobody read closely.

This guide covers the controls that decide what a workload can do: the Pod Security Standards and the admission controller that enforces them, the securityContext fields behind each rule, the capability and mount paths that lead out of a container, and the cluster-side settings (RBAC, service account tokens, NetworkPolicy, Secrets) that decide how far an attacker gets from inside a pod. It ends with a worked example: an insecure Deployment, what is wrong with it, and the hardened version.

The three Pod Security Standards

The Kubernetes project publishes three cumulative policy levels, the Pod Security Standards (PSS). Each is a list of pod fields and the values allowed in them.

ControlPrivilegedBaselineRestricted
hostNetwork, hostPID, hostIPCAllowedMust be unset or falseMust be unset or false
privileged: trueAllowedForbiddenForbidden
Added capabilitiesAnyOnly a default set of 13 (below)Only NET_BIND_SERVICE
Dropped capabilitiesNo ruleNo ruleMust include ALL
hostPath volumesAllowedForbiddenForbidden
hostPortAllowedUnset or 0Unset or 0
Seccomp profileAnyAnything except UnconfinedMust be RuntimeDefault or Localhost
AppArmor, SELinux type, procMount, unsafe sysctlsAnyRestricted to safe valuesRestricted to safe values
Volume typesAnyAny except hostPathconfigMap, csi, downwardAPI, emptyDir, ephemeral, persistentVolumeClaim, projected, secret
allowPrivilegeEscalationAnyNo ruleMust be false
runAsNonRootAnyNo ruleMust be true
runAsUserAnyNo ruleMust not be 0

Privileged has no restrictions and is meant for system and infrastructure workloads run by trusted users: CNI plugins, storage drivers, some node agents. Baseline blocks the known privilege escalations while still running most ordinary container images unchanged. Restricted applies current pod hardening practice and usually needs changes to the image or the manifest.

The Baseline capability allow-list is AUDIT_WRITE, CHOWN, DAC_OVERRIDE, FOWNER, FSETID, KILL, MKNOD, NET_BIND_SERVICE, SETFCAP, SETGID, SETPCAP, SETUID and SYS_CHROOT. That is Docker's default set of 14 minus NET_RAW. Adding anything outside the list, SYS_ADMIN or NET_ADMIN for example, fails Baseline.

Three things a reader often expects in Restricted are absent from all three levels: readOnlyRootFilesystem, resource requests and limits, and image tag or digest pinning. A pod can pass Restricted with a writable root filesystem, no memory limit and a :latest image. Those need their own checks, covered below.

Pod Security Admission: turning a level on

Pod Security Admission is the built-in admission controller that enforces the PSS. It has been stable since Kubernetes 1.25, the release that removed its predecessor, PodSecurityPolicy. It is configured per namespace with labels:

apiVersion: v1
kind: Namespace
metadata:
  name: shop
  labels:
    pod-security.kubernetes.io/enforce: baseline
    pod-security.kubernetes.io/enforce-version: v1.34
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/audit: restricted

Each label sets a level for one mode:

  • enforce rejects a pod that violates the level.
  • warn accepts it and returns a warning to the client, so kubectl apply prints the violations.
  • audit accepts it and adds an annotation to the audit log event.

The -version labels pin the rules to a Kubernetes minor release, so an upgrade that tightens a control does not start rejecting pods in that namespace overnight. Leaving the version unset means latest.

The pattern above (enforce Baseline, warn and audit at Restricted) is the usual starting point: nothing that escapes the container can run, and every deploy reports what stands between it and Restricted.

Two behaviours catch people out. First, enforce applies to pods only. A Deployment that violates the level is accepted, and then its ReplicaSet fails to create any pods; the reason appears in kubectl describe replicaset events. The warn and audit modes do check workload resources, which is another reason to set warn. Second, a namespace without labels gets the cluster default from the admission configuration, and the shipped default is privileged for all three modes.

Before enforcing on a live namespace, test it with a server-side dry run. The admission plugin evaluates the existing pods and returns the violations as warnings without changing the label:

kubectl label --dry-run=server --overwrite ns --all \
  pod-security.kubernetes.io/enforce=baseline

The securityContext fields that matter

Security settings live in two places: spec.securityContext on the pod and securityContext on each container. Some fields exist only at one level, which is a common source of manifests that look right and do nothing.

FieldLevelWhat it does
runAsNonRoot: truePod or containerThe kubelet refuses to start a container whose user resolves to UID 0
runAsUser, runAsGroupPod or containerSets the numeric UID and primary GID of the process
fsGroupPod onlyGroup ownership applied to supported volumes, so a non-root user can write to them
seccompProfile.type: RuntimeDefaultPod or containerApplies the container runtime's default syscall filter
allowPrivilegeEscalation: falseContainer onlySets no_new_privs, so setuid binaries and file capabilities cannot raise privileges
capabilities.drop: ["ALL"]Container onlyRemoves every Linux capability from the container
readOnlyRootFilesystem: trueContainer onlyMounts the container's root filesystem read-only
privilegedContainer onlyGives the container nearly all host device access and capabilities
hostNetwork, hostPID, hostIPCPod spec (outside securityContext)Shares the node's network, process or IPC namespace

Container values override pod values where both exist.

A few details from the Kubernetes documentation are worth knowing:

  • allowPrivilegeEscalation defaults to true when unset. It cannot be set to false on a container that is privileged or has CAP_SYS_ADMIN, and is treated as true for those containers, so dropping escalation and keeping SYS_ADMIN is impossible.
  • runAsNonRoot is checked against the numeric UID. If the image declares USER app by name and the manifest sets no runAsUser, the kubelet cannot verify it and the container fails to start. Set a numeric runAsUser or use a numeric USER in the Dockerfile.
  • readOnlyRootFilesystem usually breaks applications that write to /tmp or a cache directory. Mount an emptyDir at those paths, and give it a sizeLimit so one pod cannot fill the node's disk.

Capabilities and container escape paths

Linux splits root's power into 41 capabilities, numbered 0 to 40. A container process running as root gets only the capabilities in its bounding set, which is why most containers cannot load kernel modules or mount filesystems even as UID 0. Container escapes on Kubernetes almost always come from widening that set or from sharing something with the host.

privileged: true grants every capability, access to host devices, and disables most other isolation. The Trail of Bits analysis of the 2019 cgroup release_agent escape (published as a one-line proof of concept by Felix Wilhelm) used a privileged container, and showed that the technique needs less: root in the container, CAP_SYS_ADMIN, no AppArmor profile that blocks mount, and a cgroup v1 filesystem mounted read-write. The attacker writes a command path into release_agent, and the kernel runs it on the host when the cgroup empties.

CAP_SYS_ADMIN is the broadest single capability. The man page lists mount(2), umount(2), pivot_root(2) and many other operations, and notes the capability is overloaded. With it, a container can mount host block devices or filesystems it should not see, unless an AppArmor profile denies mount. The container runtime's default profile does, which is why the release_agent escape needs it absent or relaxed.

CAP_SYS_MODULE loads kernel modules. A module runs in the kernel, so this is full control of the node.

CAP_DAC_READ_SEARCH bypasses file read and directory search checks and allows open_by_handle_at(2). That call was the basis of Sebastian Krahmer's 2014 "shocker" exploit, which read the host's /etc/shadow from a Docker 0.11 container. Docker 0.12 and 1.0 moved to an allow-list of capabilities that leaves it out.

CAP_SYS_PTRACE with hostPID: true lets the container attach to processes on the node and inject code into them. The runtime's default AppArmor profile only permits ptrace of processes under the same profile, so this route is open on nodes without AppArmor (SELinux-based distributions, for example) or where the profile is set to Unconfined.

A hostPath mount of /var/run/docker.sock (or the containerd socket) gives the container the runtime's API. Anyone who can talk to the Docker daemon can start a new privileged container with the host root filesystem mounted, which is root on the node. Writable hostPath mounts of /, /etc or /var/lib/kubelet lead to the same place by different routes, including reading other pods' Secrets and the kubelet's credentials.

hostNetwork: true puts the pod on the node's network stack. It can reach services bound to the node's localhost and sees the node's interfaces. The Kubernetes docs call NetworkPolicy behaviour for host-network pods undefined, and in the most common implementation their traffic is treated as the node's own traffic.

Baseline forbids every item on that list. That is the practical case for enforcing Baseline cluster-wide with exceptions only for named infrastructure namespaces.

To see what a running container actually holds, read its capability sets:

kubectl exec -n shop deploy/api -- grep Cap /proc/1/status
# CapEff, CapPrm and CapBnd are hex masks
# 00000000a80425fb = Docker's 14 default capabilities
# 000001ffffffffff = all 41 capabilities (privileged)
capsh --decode=00000000a80425fb

For a process running as a non-root UID, CapEff is normally zero even when the bounding set is wide. CapBnd shows what the process could regain through a setuid binary or file capability, which is what allowPrivilegeEscalation: false and drop: ["ALL"] close off.

Worked example: an insecure Deployment and the fix

This Deployment is a composite of patterns that show up in real charts: a log-forwarding sidecar given the Docker socket "to read container metadata", a debug capability someone added during an incident, host networking for a port clash, and a database password pasted into the manifest.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: shop
spec:
  replicas: 2
  selector:
    matchLabels: { app: api }
  template:
    metadata:
      labels: { app: api }
    spec:
      hostNetwork: true
      hostPID: true
      containers:
        - name: api
          image: registry.example.com/shop/api:latest
          ports:
            - containerPort: 8080
              hostPort: 8080
          env:
            - name: DB_PASSWORD
              value: "s3cr3t-prod-pass"
          securityContext:
            capabilities:
              add: ["SYS_ADMIN", "SYS_PTRACE"]
        - name: log-forwarder
          image: registry.example.com/ops/forwarder:2.1
          securityContext:
            privileged: true
          volumeMounts:
            - name: docker-sock
              mountPath: /var/run/docker.sock
      volumes:
        - name: docker-sock
          hostPath:
            path: /var/run/docker.sock

What is wrong, and which level each issue fails:

FindingFailsConsequence
hostNetwork: true, hostPID: trueBaselinePod shares the node's network stack and process table
hostPort: 8080BaselineBinds a port on every node the pod lands on
add: ["SYS_ADMIN", "SYS_PTRACE"]BaselineMount and ptrace inside a pod that already sees host processes
privileged: trueBaselineFull device access, all capabilities
hostPath to docker.sockBaselineContainer runtime API, so root on the node
No runAsNonRoot, no runAsUserRestrictedRuns as whatever the image sets, often root
allowPrivilegeEscalation unset (defaults to true)Restrictedsetuid binaries can raise privileges
No capabilities.drop: ["ALL"]RestrictedKeeps the runtime's default capabilities
No seccomp profileRestrictedNo syscall filter unless the kubelet default is set
Writable root filesystemOutside PSSAttacker can drop tools and modify binaries
No resource requests or limitsOutside PSSOne pod can starve the node
:latest imageOutside PSSRunning code changes without a manifest change
Password as a literal env valueOutside PSSSecret in Git, in kubectl get -o yaml output and in every child process
Default service account, token auto-mountedOutside PSSAPI credentials available to anyone who gets code execution

In a namespace enforcing Baseline, the API server accepts this Deployment and rejects every pod it tries to create. As shipped, the privileged sidecar holds the Docker socket, which is root on the node for anyone who gets code execution in that container. An attacker who lands in the api container through an application bug has routes of its own on any node without AppArmor: SYS_PTRACE with hostPID permits injection into node processes, and SYS_ADMIN permits mounts.

Illustrated cybersecurity scene for Kubernetes Security Guide: Pod Security Standards, RBAC and Hardening
Illustration for Kubernetes Security Guide: Pod Security Standards, RBAC and Hardening.

The hardened version:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: shop
spec:
  replicas: 2
  selector:
    matchLabels: { app: api }
  template:
    metadata:
      labels: { app: api }
    spec:
      serviceAccountName: api
      automountServiceAccountToken: false
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: api
          image: registry.example.com/shop/api:1.8.3@sha256:<digest>
          ports:
            - containerPort: 8080
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          resources:
            requests: { cpu: 250m, memory: 256Mi }
            limits: { memory: 512Mi }
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: db-credentials
              mountPath: /var/run/secrets/db
              readOnly: true
      volumes:
        - name: tmp
          emptyDir:
            sizeLimit: 64Mi
        - name: db-credentials
          secret:
            secretName: api-db

What changed:

  • The sidecar is gone. Logs written to stdout are collected by a node-level agent, which belongs in its own namespace with its own, narrower exception.
  • Host namespaces and the host port are removed. The port is exposed through a Service.
  • The pod runs as UID 10001 with RuntimeDefault seccomp, and the container drops every capability. Port 8080 is above 1024, so NET_BIND_SERVICE is not needed.
  • The root filesystem is read-only, with a size-limited emptyDir for /tmp.
  • The password lives in a Secret named api-db, mounted as a file into the one container that reads it.
  • The pod uses a dedicated service account and does not mount an API token. If the application does need the API, set automountServiceAccountToken: true on this pod and bind the api service account to a Role that lists only the verbs it uses.
  • The image is pinned to a version and digest. <digest> stands for the real sha256 value from your registry.
  • A memory limit caps the container, and a CPU request reserves scheduling capacity. Many teams leave the CPU limit off to avoid throttling; that is a performance choice, while the memory limit protects the node.

This manifest passes Restricted. Run it in a namespace labelled pod-security.kubernetes.io/enforce: restricted and any later change that reintroduces a root user or a capability is rejected at admission.

RBAC: what a stolen credential can do

Workload hardening limits what code inside a pod can do to the node. RBAC limits what a credential can do to the cluster, whether that credential is a pod's service account token or a developer's kubeconfig. The Kubernetes RBAC good practices document lists most of the permissions below as routes to wider control. The pattern is least privilege applied to API verbs.

  • Wildcards. verbs: ["*"] or resources: ["*"] grants every current resource type and every type added later by a CRD. Name the resources and verbs.
  • Secrets. get on Secrets reads them. So do list and watch: a list response includes the full contents of every Secret it returns, so kubectl get secrets -A -o yaml with a cluster-wide list alone dumps them all.
  • Creating workloads. Permission to create pods, or any controller that creates pods (Deployments, Jobs, CronJobs, DaemonSets), implicitly grants access to every Secret, ConfigMap and PersistentVolume the pod can mount, and to every service account in the namespace, since a pod can name any of them.
  • escalate, bind and impersonate. RBAC normally stops a user from creating a role or binding with more rights than they hold. escalate and bind are the exceptions, and impersonate lets the subject act as another user, group or service account.
  • pods/exec and pods/attach. Exec runs commands in an existing pod and attach connects to its running process. Either gives the pod's service account, its mounted Secrets and its network position.
  • nodes/proxy. This reaches the kubelet API, which can execute commands in any pod on the node and bypasses audit logging and admission control. The documentation notes that get on nodes/proxy is not read-only, because the exec endpoints accept websocket requests authorized with get.
  • serviceaccounts/token create. Issues tokens for existing service accounts.
  • Webhook configurations. Write access to validatingwebhookconfigurations or mutatingwebhookconfigurations lets the subject see, and for mutating webhooks change, every object admitted to the cluster.
  • Namespace patch. Changing labels on a namespace can change its Pod Security level. Treat it as a security permission.
  • PersistentVolume create. A PersistentVolume can be a hostPath, which gives pods using it the node's filesystem.
  • system:masters. Members bypass RBAC entirely, and removing bindings does not revoke it. Keep it to break-glass certificates.

Check what a subject can actually do with kubectl auth can-i:

kubectl auth can-i --list --as=system:serviceaccount:shop:api -n shop
kubectl auth can-i get secrets -A --as=system:serviceaccount:shop:api

Service account tokens

Every namespace gets a default service account, and by default Kubernetes mounts a token for the pod's service account at /var/run/secrets/kubernetes.io/serviceaccount/token. Since Kubernetes 1.22 that token is a projected, time-bound token issued through the TokenRequest API, which the kubelet rotates before it expires. Older clusters mounted long-lived tokens from Secrets, and Secret-based tokens created by hand still never expire.

A short lifetime limits a stolen token's value, and an unneeded token is still a free credential for anyone with code execution in the pod. Three settings cover most clusters:

  1. Set automountServiceAccountToken: false on the default service account in every namespace, or on each pod that does not call the API. The pod setting overrides the service account setting.
  2. Give each workload that does call the API its own service account, bound to a Role scoped to its namespace and verbs.
  3. Never bind roles to the default service account, since every pod without an explicit serviceAccountName runs as it.

NetworkPolicy: default deny

By default every pod accepts connections from every other pod in the cluster and can connect anywhere. A pod becomes isolated for a direction (ingress or egress) once any NetworkPolicy that selects it lists that direction in policyTypes. Policies are additive: once isolated, the pod accepts only what some policy allows.

A default deny for a namespace selects every pod and allows nothing:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny
  namespace: shop
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress

Denying egress also blocks DNS, so add a policy that allows it to the cluster DNS pods. The labels below match CoreDNS on kubeadm-built clusters; check yours with kubectl get pods -n kube-system --show-labels:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: shop
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - { protocol: UDP, port: 53 }
        - { protocol: TCP, port: 53 }

Then add one policy per real flow: ingress controller to api, api to the database. Egress deny is what stops a compromised pod from reaching the cloud metadata endpoint, other namespaces, or the internet, which is most of what lateral movement inside a cluster needs. The same principle applies at the VPC level, covered in our network segmentation guide.

NetworkPolicy is implemented by the network plugin. On a plugin that does not support it, the objects are accepted and have no effect, so test a denied connection after applying them.

Resource requests and limits

Requests tell the scheduler how much CPU and memory to reserve; limits cap what the container can use. A container that exceeds its memory limit is OOM-killed, and a container that exceeds its CPU limit is throttled. Without a memory limit, one leaking or attacked pod can push the node into memory pressure and get its neighbours evicted. Set a memory request and limit on every container, and use a LimitRange to apply defaults in namespaces where charts omit them. A ResourceQuota caps the namespace total.

emptyDir volumes and container writable layers consume node disk too. A sizeLimit on each emptyDir stops one pod filling the node's disk.

Secrets handling

A Kubernetes Secret is base64-encoded and, by default, stored unencrypted in etcd. Anyone with access to etcd or its backups reads every Secret in the cluster. The Kubernetes good-practice list:

  • Enable encryption at rest for Secrets with an EncryptionConfiguration on the API server, ideally with a KMS provider so the key lives outside the cluster. Managed services (EKS, GKE, AKS) offer this as a setting.
  • Limit Secret access in RBAC as above, remembering that list and watch reveal contents.
  • Mount Secrets only into the containers that need them. In a multi-container pod, the other containers do not have to see them.
  • Prefer mounted files over environment variables. Mounted Secret volumes update when the Secret changes (except subPath mounts); environment variables are fixed at container start and inherited by every child process.
  • Keep Secrets out of manifests and Git. A literal env value is visible to anyone who can read the Deployment. Use a Secret, or an external store (Vault, a cloud secrets manager) through the Secrets Store CSI driver or an operator. If a value has already been committed, rotate it; our guide to leaked API keys covers what happens next.

How to check a manifest

Checks work best at three points.

  1. Before merge. Render the chart (helm template or kustomize build) and check the output, since chart values only become real fields after rendering. Our Kubernetes hardening checker reads rendered YAML, kubectl get -o json output, a Helm values file or a docker-compose file in the browser, tests each workload against Baseline and Restricted, reviews Roles and bindings against the RBAC good practices, and decodes /proc capability masks. The CLI scanners Trivy, Checkov and kube-score cover similar ground in CI.
  2. At admission. Pod Security Admission labels on every namespace: enforce Baseline everywhere except named infrastructure namespaces, warn and audit at Restricted, and move application namespaces to enforce Restricted once the warnings stop. For controls outside the PSS (read-only root filesystem, required limits, allowed registries, signed images), add a policy engine or a ValidatingAdmissionPolicy.
  3. In the running cluster. kubectl auth can-i --list for each service account, grep Cap /proc/1/status in sample pods, and the audit log annotations from audit mode. The same infrastructure-as-code discipline from our Terraform security checklist applies to the cluster itself: a private API endpoint is one of its twelve items.

Failure modes

  • Labels on some namespaces. A new namespace has no labels and runs as Privileged. Set the cluster default in the admission configuration, or have namespace creation go through a template that applies the labels.
  • Exempting a controller. Pod Security Admission exemptions by username apply to direct pod creation. Exempting a controller service account such as the ReplicaSet controller exempts every user who can create a Deployment.
  • "Restricted" assumed to mean hardened. It does not cover a writable root filesystem, missing limits, :latest images, literal secrets or RBAC. A Restricted pod whose service account can list Secrets cluster-wide still gives an attacker every credential.
  • Infrastructure exceptions that grow. Node agents such as log shippers and metrics exporters legitimately need some host access. Each needs only part of it (read-only /var/log for a log shipper, for example). Grant exactly that, in its own namespace, and keep application workloads out of it.
  • Chart defaults. Many public charts ship without a securityContext. Check the rendered output, since templates can add or override fields that values.yaml never shows.

Our guide to cloud misconfigurations covers the cloud-account side, where a leaked node role or a public API endpoint reaches the cluster from outside.

Verdict

Enforce Baseline on every namespace that is not explicitly infrastructure. It costs almost nothing for ordinary applications and blocks every route out of the container described above: privileged mode, added capabilities, host mounts including the Docker socket, and host namespaces. Run warn and audit at Restricted, fix what they report (non-root UID, drop: ["ALL"], allowPrivilegeEscalation: false, RuntimeDefault seccomp), and move application namespaces to enforce Restricted. Then add the controls the PSS leaves out: a read-only root filesystem, memory limits, pinned images, Secrets in volumes with encryption at rest, no auto-mounted tokens, RBAC without wildcards or Secret listing, and a default-deny NetworkPolicy with explicit flows. Those together mean a bug in one application stays inside one pod.

Sources & further reading

Related guides