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.
| Control | Privileged | Baseline | Restricted |
|---|---|---|---|
hostNetwork, hostPID, hostIPC | Allowed | Must be unset or false | Must be unset or false |
privileged: true | Allowed | Forbidden | Forbidden |
| Added capabilities | Any | Only a default set of 13 (below) | Only NET_BIND_SERVICE |
| Dropped capabilities | No rule | No rule | Must include ALL |
hostPath volumes | Allowed | Forbidden | Forbidden |
hostPort | Allowed | Unset or 0 | Unset or 0 |
| Seccomp profile | Any | Anything except Unconfined | Must be RuntimeDefault or Localhost |
AppArmor, SELinux type, procMount, unsafe sysctls | Any | Restricted to safe values | Restricted to safe values |
| Volume types | Any | Any except hostPath | configMap, csi, downwardAPI, emptyDir, ephemeral, persistentVolumeClaim, projected, secret |
allowPrivilegeEscalation | Any | No rule | Must be false |
runAsNonRoot | Any | No rule | Must be true |
runAsUser | Any | No rule | Must 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: restrictedEach 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 applyprints 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=baselineThe 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.
| Field | Level | What it does |
|---|---|---|
runAsNonRoot: true | Pod or container | The kubelet refuses to start a container whose user resolves to UID 0 |
runAsUser, runAsGroup | Pod or container | Sets the numeric UID and primary GID of the process |
fsGroup | Pod only | Group ownership applied to supported volumes, so a non-root user can write to them |
seccompProfile.type: RuntimeDefault | Pod or container | Applies the container runtime's default syscall filter |
allowPrivilegeEscalation: false | Container only | Sets no_new_privs, so setuid binaries and file capabilities cannot raise privileges |
capabilities.drop: ["ALL"] | Container only | Removes every Linux capability from the container |
readOnlyRootFilesystem: true | Container only | Mounts the container's root filesystem read-only |
privileged | Container only | Gives the container nearly all host device access and capabilities |
hostNetwork, hostPID, hostIPC | Pod 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:
allowPrivilegeEscalationdefaults totruewhen unset. It cannot be set tofalseon a container that is privileged or hasCAP_SYS_ADMIN, and is treated astruefor those containers, so dropping escalation and keepingSYS_ADMINis impossible.runAsNonRootis checked against the numeric UID. If the image declaresUSER appby name and the manifest sets norunAsUser, the kubelet cannot verify it and the container fails to start. Set a numericrunAsUseror use a numericUSERin the Dockerfile.readOnlyRootFilesystemusually breaks applications that write to/tmpor a cache directory. Mount anemptyDirat those paths, and give it asizeLimitso 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=00000000a80425fbFor 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.sockWhat is wrong, and which level each issue fails:
| Finding | Fails | Consequence |
|---|---|---|
hostNetwork: true, hostPID: true | Baseline | Pod shares the node's network stack and process table |
hostPort: 8080 | Baseline | Binds a port on every node the pod lands on |
add: ["SYS_ADMIN", "SYS_PTRACE"] | Baseline | Mount and ptrace inside a pod that already sees host processes |
privileged: true | Baseline | Full device access, all capabilities |
hostPath to docker.sock | Baseline | Container runtime API, so root on the node |
No runAsNonRoot, no runAsUser | Restricted | Runs as whatever the image sets, often root |
allowPrivilegeEscalation unset (defaults to true) | Restricted | setuid binaries can raise privileges |
No capabilities.drop: ["ALL"] | Restricted | Keeps the runtime's default capabilities |
| No seccomp profile | Restricted | No syscall filter unless the kubelet default is set |
| Writable root filesystem | Outside PSS | Attacker can drop tools and modify binaries |
| No resource requests or limits | Outside PSS | One pod can starve the node |
:latest image | Outside PSS | Running code changes without a manifest change |
Password as a literal env value | Outside PSS | Secret in Git, in kubectl get -o yaml output and in every child process |
| Default service account, token auto-mounted | Outside PSS | API 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.

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-dbWhat 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
RuntimeDefaultseccomp, and the container drops every capability. Port 8080 is above 1024, soNET_BIND_SERVICEis not needed. - The root filesystem is read-only, with a size-limited
emptyDirfor/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: trueon this pod and bind theapiservice account to a Role that lists only the verbs it uses. - The image is pinned to a version and digest.
<digest>stands for the realsha256value 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: ["*"]orresources: ["*"]grants every current resource type and every type added later by a CRD. Name the resources and verbs. - Secrets.
geton Secrets reads them. So dolistandwatch: a list response includes the full contents of every Secret it returns, sokubectl get secrets -A -o yamlwith a cluster-widelistalone 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,bindandimpersonate. RBAC normally stops a user from creating a role or binding with more rights than they hold.escalateandbindare the exceptions, andimpersonatelets the subject act as another user, group or service account.pods/execandpods/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 thatgetonnodes/proxyis not read-only, because the exec endpoints accept websocket requests authorized withget.serviceaccounts/tokencreate. Issues tokens for existing service accounts.- Webhook configurations. Write access to
validatingwebhookconfigurationsormutatingwebhookconfigurationslets 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:apiService 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:
- Set
automountServiceAccountToken: falseon thedefaultservice account in every namespace, or on each pod that does not call the API. The pod setting overrides the service account setting. - Give each workload that does call the API its own service account, bound to a Role scoped to its namespace and verbs.
- Never bind roles to the
defaultservice account, since every pod without an explicitserviceAccountNameruns 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
- EgressDenying 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
EncryptionConfigurationon 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
listandwatchreveal 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
subPathmounts); environment variables are fixed at container start and inherited by every child process. - Keep Secrets out of manifests and Git. A literal
envvalue 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.
- Before merge. Render the chart (
helm templateorkustomize build) and check the output, since chart values only become real fields after rendering. Our Kubernetes hardening checker reads rendered YAML,kubectl get -o jsonoutput, 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/proccapability masks. The CLI scanners Trivy, Checkov and kube-score cover similar ground in CI. - 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.
- In the running cluster.
kubectl auth can-i --listfor each service account,grep Cap /proc/1/statusin 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,
:latestimages, 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/logfor 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 thatvalues.yamlnever 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
- Pod Security Standards (Kubernetes)
- Pod Security Admission (Kubernetes)
- Enforce Pod Security Standards with Namespace Labels (Kubernetes)
- Configure a Security Context for a Pod or Container (Kubernetes)
- Role Based Access Control Good Practices (Kubernetes)
- Service Accounts (Kubernetes)
- Network Policies (Kubernetes)
- Good practices for Kubernetes Secrets (Kubernetes)
- capabilities(7), Linux manual page (man7.org)
- Understanding Docker container escapes (Trail of Bits)
Related guides
- Terraform Security Checklist: 12 Settings Behind Cloud Breaches
The Terraform settings that recur in AWS and GCP breach reports, the HCL that fixes each one, how to keep secrets out of state, and where scanning fits.
- Cloud Misconfigurations: The Settings That Leak Data
The cloud settings that actually cause breaches: public object storage, over-broad IAM, exposed metadata services, open security groups and public snapshots, with the console and CLI checks for each.
- Cloud Security Fundamentals: Protecting Your Infrastructure
The cloud doesn't secure your data for you. The shared responsibility model, the most common cloud misconfigurations, and how to lock down AWS, GCP and Azure.