Kubernetes securityContext Explained (2026): Every Field That Matters

Nov 10, 2026 · 11 min · Ajay Kumar

Most Kubernetes manifests I review have a securityContext block that was copied from somewhere, and almost nobody on the team can say what each line does. That is how you end up with runAsNonRoot: true on a pod that still has every default capability, or readOnlyRootFilesystem: true on an app that crashes on its first write to /tmp. This post explains the Kubernetes securityContext field by field, as of Kubernetes v1.37: which settings live on the pod and which on the container, what the defaults actually are, what the Pod Security Standards demand at each level, and where user namespaces (GA since v1.36) change the picture. If you are staring at a specific error right now, the companion guide, How to Fix "container has runAsNonRoot and image will run as root", is the practical half.

What is securityContext in Kubernetes?

A securityContext is a set of Linux (and Windows) process settings that the kubelet passes to the container runtime when it creates a container: which UID and GID the process runs as, which kernel capabilities it holds, whether it can gain new privileges, which seccomp and AppArmor profiles filter its syscalls, and whether the root filesystem is writable. Nothing here is Kubernetes magic. Every field maps onto an OCI runtime spec setting that runc or crun applies through ordinary kernel features. Kubernetes' job is to express it in YAML and, through Pod Security Admission, refuse pods that ask for too much.

There are two places to put it. spec.securityContext is a PodSecurityContext and applies to every container in the pod. spec.containers[*].securityContext is a SecurityContext and applies to one container. Where a field exists in both, the container value wins. Some fields only exist at one level, which is the single most common source of "I set it and nothing happened".

Where each securityContext field lives (Kubernetes v1.37) Pod level only spec.securityContext fsGroup fsGroupChangePolicy supplementalGroups supplementalGroupsPolicy (GA 1.35) sysctls seLinuxChangePolicy (GA 1.36) plus spec.hostUsers (GA 1.36) Both levels container value wins runAsUser runAsGroup runAsNonRoot seccompProfile appArmorProfile seLinuxOptions Container level only containers[*].securityContext allowPrivilegeEscalation capabilities (add, drop) privileged readOnlyRootFilesystem procMount (GA 1.36) Setting a container-only field at pod level is a validation error; setting a pod-only field on a container is too. Feature stages from the v1.37.2 feature gate list in kubernetes/kubernetes.
Field placement per the Kubernetes PodSecurityContext and SecurityContext API types; graduation versions from pkg/features/kube_features.go at tag v1.37.2.

runAsUser, runAsGroup and runAsNonRoot: who the process is

If you set nothing, the container runs as whatever the image's USER instruction says, and most base images say nothing, which means UID 0. Root in a container is not quite root on the host (no capabilities beyond the runtime default set, a seccomp filter if you asked for one, namespaces around it), but it is the same UID 0 the kernel sees, and a container escape that starts as UID 0 lands as UID 0 on the node.

runAsUser forces a numeric UID, overriding the image. runAsGroup sets the primary GID; leave it out and the docs are explicit that the primary group stays 0, which surprises people who set only runAsUser. runAsNonRoot: true does not change the user at all. It is an assertion: the kubelet refuses to start the container unless it can prove the UID is not 0.

The proof logic is short and worth knowing, because it explains all three errors you will meet. In security_context_others.go the kubelet checks runAsUser first (0 is rejected, anything else passes). If that is unset, it looks at the image's configured user. A numeric UID of 0 gives "container has runAsNonRoot and image will run as root". A user name such as appuser gives "image has non-numeric user (appuser), cannot verify user is non-root", because the kubelet does not read /etc/passwd inside the image. The fix for both is the same: a numeric USER in the Dockerfile, or an explicit runAsUser. The guide walks through each case.

fsGroup, fsGroupChangePolicy and supplementalGroupsPolicy: who owns the files

Once the process is not root, volumes become the problem. A fresh PersistentVolume is usually owned by root, and UID 1000 cannot write to it. fsGroup fixes this: Kubernetes adds that GID as a supplementary group to every process in the pod and, at mount time, recursively changes group ownership and permissions on supported volumes so the group can write.

Two caveats. Recursive chown on a large volume is slow, which is why fsGroupChangePolicy: OnRootMismatch exists (stable since v1.23): it skips the walk when the volume root already has the right owner and mode. And "supported volumes" is doing a lot of work. For CSI volumes the driver's CSIDriver.spec.fsGroupPolicy decides; the default ReadWriteOnceWithFSType only changes ownership when an fsType is set and the access mode includes ReadWriteOnce, so many NFS-style RWX volumes are never touched. hostPath is not adjusted either; the volumes docs tell you to run as root or fix permissions on the host.

supplementalGroupsPolicy went GA in v1.35 and closes a quieter gap. By default (Merge) the container also gets any groups that /etc/group in the image assigns to the user, which means an image can grant itself group memberships your manifest never mentioned. Strict uses only runAsGroup, fsGroup and supplementalGroups. Since the beta in v1.33, a kubelet whose runtime cannot honour Strict rejects the pod with SupplementalGroupsPolicyNotSupported rather than silently ignoring it.

readOnlyRootFilesystem, allowPrivilegeEscalation and capabilities

These three are container-only, and together they do more than any UID setting.

readOnlyRootFilesystem: true mounts the image layers read-only. An attacker who gets code execution cannot drop a binary into /usr/local/bin or edit config. The cost is that your app must write only to volumes, so you mount an emptyDir at /tmp, at the cache directory, and anywhere else it insists on writing. v1.37 added two alpha knobs worth watching here: emptyDir.mode (for example 01777 for a sticky-bit /tmp) and volumeMounts[*].bindMountOptions for noexec and nosuid. The v1.37 storage hardening post makes the point bluntly: without noexec, a read-only root filesystem still lets a compromised process download and run a binary from a writable volume. Both are behind the EmptyDirVolumeMode and VolumeBindMountOptions feature gates for now.

allowPrivilegeEscalation: false sets the kernel's no_new_privs flag, so setuid binaries such as sudo or ping (on older images) cannot raise privileges. Unset, it is effectively true, and it is always true for privileged containers or containers with CAP_SYS_ADMIN.

capabilities slices root's power into about forty pieces. The runtime grants a default bounding set to every container: containerd's default spec lists 14, including CAP_NET_RAW and CAP_SETUID. drop: ["ALL"] removes them; add puts specific ones back. The awkward one is NET_BIND_SERVICE for ports below 1024. Adding it to a non-root container often does nothing, because Kubernetes does not set ambient capabilities, a gap tracked in kubernetes/kubernetes#56374 and still open. On containerd 2.x it is mostly moot: the CRI config sets enable_unprivileged_ports = true by default, which sets net.ipv4.ip_unprivileged_port_start=0 for non-host-network pods. Before containerd 2.0 that defaulted to false, and CRI-O does not set it unless you configure default_sysctls.

# A container securityContext that passes Pod Security "restricted" on v1.37.
# Written against the PSA checks in k8s.io/pod-security-admission v1.37.2 (not run on a cluster here).
apiVersion: v1
kind: Pod
metadata:
  name: api
spec:
  securityContext:
    runAsNonRoot: true          # kubelet refuses UID 0
    runAsUser: 10001            # numeric, so the check can be proven
    runAsGroup: 10001           # otherwise the primary GID stays 0
    fsGroup: 10001              # group-writable volumes
    fsGroupChangePolicy: OnRootMismatch
    supplementalGroupsPolicy: Strict   # ignore groups from the image's /etc/group
    seccompProfile:
      type: RuntimeDefault      # restricted requires RuntimeDefault or Localhost
  containers:
    - name: api
      image: ghcr.io/example/api:1.4.2
      ports:
        - containerPort: 8080   # high port: no capability needed
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
      volumeMounts:
        - name: tmp
          mountPath: /tmp
  volumes:
    - name: tmp
      emptyDir:
        sizeLimit: 64Mi

Seccomp and AppArmor: filtering syscalls

seccompProfile.type: RuntimeDefault applies the runtime's default seccomp filter, which blocks dangerous syscalls such as kexec_load and kernel module loading. The surprise for people coming from Docker is that Kubernetes does not apply it by default. Unless the kubelet runs with seccompDefault: true (the --seccomp-default flag, stable since v1.27), a pod with no profile runs Unconfined. That is why the Restricted standard requires the field to be set explicitly, and why I turn on seccompDefault on every node pool I control. Localhost profiles exist for teams that want tighter filters, though in practice they are hard to maintain.

AppArmor moved from annotations to a real field, appArmorProfile, in v1.30. Baseline allows RuntimeDefault and Localhost and forbids Unconfined. On SELinux distributions the equivalent is seLinuxOptions; v1.36 made seLinuxChangePolicy GA and v1.37 graduated the SELinuxMount feature, so volumes get their label via a mount option instead of a slow recursive relabel unless you opt out with Recursive.

User namespaces: hostUsers false in v1.36 and later

Everything above tries to stop the process from being root. User namespaces take the opposite route: let it be root inside the container, but map that UID to an unprivileged, unique range on the host. You opt in with spec.hostUsers: false, which sits on the pod spec rather than inside securityContext. The feature went GA in v1.36 after first landing as alpha in v1.25.

The user namespaces docs list the requirements: Linux 6.3 or later (tmpfs gained idmap mount support then, and Secrets and service account tokens live on tmpfs), containerd 2.0+ or CRI-O 1.25+, and runc 1.2+ or crun 1.9+. Capabilities granted inside the pod are scoped to its namespace, so CAP_SYS_MODULE has no effect and CAP_SYS_ADMIN is void outside the pod. File ownership on volumes is unchanged, because runAsUser and fsGroup always refer to IDs inside the container. You cannot combine it with hostNetwork, hostPID, hostIPC or raw block volumeDevices.

The Pod Security interaction is the interesting part. For pods with hostUsers: false, Pod Security Admission stops checking runAsNonRoot and runAsUser even under Restricted, because root in that pod never maps to root on the node. Baseline also relaxes procMount, which is how procMount: Unmasked (GA in v1.36) becomes usable for nested container builds. This is the cleanest answer I know to "this image only works as root".

What do Pod Security Standards Baseline and Restricted require?

The Pod Security Standards define three levels. Privileged checks nothing. Baseline blocks known escalations while accepting a default pod spec. Restricted adds current hardening practice and rejects any chart that has never set a securityContext. You enforce them with namespace labels such as pod-security.kubernetes.io/enforce: restricted, or with a policy engine, as in my Kyverno Pod Security Standards guide.

Field Baseline Restricted Source
privileged must be false or unset same PSS
capabilities.add only 13 listed caps (no NET_RAW, no SYS_ADMIN) only NET_BIND_SERVICE PSS
capabilities.drop not checked must include ALL PSS
allowPrivilegeEscalation not checked must be false PSS
runAsNonRoot not checked must be true (skipped with hostUsers: false) User namespaces
runAsUser not checked must not be 0 (skipped with hostUsers: false) User namespaces
seccompProfile.type must not be Unconfined must be RuntimeDefault or Localhost PSS
procMount Default only (relaxed with userns) Default only PSS
Volume types no hostPath only configMap, csi, downwardAPI, emptyDir, ephemeral, PVC, projected, secret PSS
sysctls safe list only (v1.37 added two TCP sysctls) same PSS

Note what Restricted does not require: readOnlyRootFilesystem. It is good practice, and most scanners flag it, but no built-in standard enforces it. If you want it, write the policy yourself.

Why this still matters when agents write the YAML

More of the manifests I see now were generated, by a coding agent or a chart someone asked a model to write. Generated YAML is very good at including a securityContext block and fairly bad at making it coherent: runAsNonRoot with a named USER, fsGroup placed on the container, NET_BIND_SERVICE added to a non-root process that will never receive it. Apart from the misplaced fsGroup, which strict field validation rejects as an unknown field, none of these fail kubectl apply --dry-run=client. They fail at admission, at container start or at the first bind, and the person on call has to know what the fields mean. That is the case for enforcing Restricted at the namespace level: it turns a review skill into a mechanical check. For the wider picture of what attackers actually do once a pod is reachable, see Kubernetes security in 2026.

FAQ

What is the difference between pod and container securityContext?

The pod-level spec.securityContext applies to all containers and holds pod-only fields such as fsGroup, supplementalGroups and sysctls. The container-level one applies to a single container and holds container-only fields such as capabilities, allowPrivilegeEscalation and readOnlyRootFilesystem. Fields present in both, such as runAsUser and seccompProfile, take the container value when both are set.

Does runAsNonRoot change the user a container runs as?

No. runAsNonRoot: true is only a check. The kubelet refuses to start the container if runAsUser is 0, or if runAsUser is unset and the image's user is UID 0 or a non-numeric name. To actually change the user, set runAsUser or a numeric USER in the image.

Is seccomp RuntimeDefault enabled by default in Kubernetes?

Not unless the kubelet is configured with seccompDefault: true or --seccomp-default. Without that, pods without a profile run Unconfined. Pod Security Restricted requires RuntimeDefault or Localhost to be set explicitly on the pod or every container.

Should I use hostUsers false instead of runAsNonRoot?

Use both where you can. hostUsers: false (GA since v1.36) maps container root to an unprivileged host UID and is the best option for images that genuinely need root inside the container. It needs Linux 6.3+, containerd 2.0+ or CRI-O 1.25+, and cannot be combined with host namespaces.

This quarter I would do three things on any cluster I was responsible for: turn on seccompDefault in the kubelet config, label application namespaces pod-security.kubernetes.io/warn: restricted to see what breaks before enforcing it, and pilot hostUsers: false on the stateless workloads where the nodes already run a 6.x kernel and containerd 2. The fields are boring. The breaches they prevent are not.

Related: Kyverno vs Gatekeeper vs ValidatingAdmissionPolicy in 2026, Hardened Container Images in 2026: DHI Free, Bitnami Gone, 515 CVEs to 0 and Kubernetes Security in 2026: First Attack in 18 Minutes, and What Stops It.

Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related