Security contexts

runAsNonRoot, drop capabilities, no privilege escalation.

Advanced12 min · lesson 13 of 24

Run id inside a typical container and you'll probably see uid=0(root). That is not a cosmetic label. Unless your cluster remaps user namespaces, user ID 0 inside the container is the same UID 0 the host kernel obeys. One kernel bug, or one careless hostPath mount, and root in the pod becomes root on the node, and root over every other pod scheduled there. The securityContext block is where you take that power away before anyone gets the chance to use it.

A securityContext is the set of house rules you hand the kubelet (the Kubernetes agent on each node that actually starts containers) before it lets your container in the door: which user it runs as, whether it can pick up new privileges while running, which Linux capabilities it holds, and whether it can write to its own root filesystem. This is the highest-value block on a pod spec, because it decides how far a compromised process can travel before it ever touches the network or the API server. Get it right and a remote code execution bug (RCE, where an attacker gets to run their own commands inside your app) leaves them stuck in an unprivileged process with nowhere to go. Get it wrong and the same bug is a foothold on the host.

Three settings carry most of the weight. Run as a non-root numeric UID (user ID). Forbid privilege escalation. Drop every Linux capability, then hand back only the ones the app can prove it needs. Capabilities are root's power split into roughly 40 separate keys on a keyring. CAP_NET_BIND_SERVICE is the key that opens ports below 1024. CAP_SYS_ADMIN opens nearly every door in the building, which is why people call it the new root. A stock container image arrives holding keys the app never turns. So you take the whole ring back, then hand over one key, by name, when a real feature depends on it.

pod.yaml
spec:
securityContext: # pod-level: applies to all containers
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile: { type: RuntimeDefault }
containers:
- name: app
image: registry.internal/app:1.4.2
securityContext: # container-level: overrides / adds
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"] # only if it truly binds a port < 1024

Pod-wide defaults, per-container exceptions

Some fields sit at pod level and cover every container in the pod: runAsUser, runAsGroup, runAsNonRoot, fsGroup, seccompProfile. Others are per container: capabilities, allowPrivilegeEscalation, readOnlyRootFilesystem, privileged. Where both exist, the container value beats the pod default. That split is useful. You set one strict baseline for the whole pod, then hand a single container one visible exception instead of loosening everything at once. runAsGroup earns its line here. Leave it out and your process keeps group 0, the root group, even while its UID is unprivileged, so files owned by the root group are still within reach. Because the exception lives on one container, a reviewer can see exactly who asked for more, and go ask why.

Check it before it ships

You should not have to read a manifest line by line to know whether it is hardened. Kubesec scores a pod spec against these exact fields and tells you what each one earned you and what is still missing. Wire it into CI (continuous integration, the pipeline that builds and tests every change) and fail the build below a threshold, so a weak securityContext never reaches a cluster at all. A scanner only reads text, though. It cannot tell you whether the image obeys what the file asked for, and that gap is where hardening quietly leaks away.

terminal
# static score before it ever reaches the cluster
$ kubesec scan pod.yaml
[
{
"object": "Pod/app.default",
"valid": true,
"fileName": "pod.yaml",
"message": "Passed with a score of 6 points",
"score": 6,
"scoring": {
"passed": [
{ "id": "CapDropAll", "selector": "containers[] .securityContext .capabilities .drop | index(\"ALL\")", "reason": "Reduces the attack surface by dropping all capabilities", "points": 1 },
{ "id": "ReadOnlyRootFilesystem", "selector": "containers[] .securityContext .readOnlyRootFilesystem == true", "reason": "An immutable root filesystem stops attackers writing binaries to disk", "points": 1 },
{ "id": "RunAsNonRoot", "selector": ".spec, .spec.containers[] | .securityContext .runAsNonRoot == true", "reason": "Force the image to run as a non-root user", "points": 1 },
{ "id": "RunAsUser", "selector": ".spec, .spec.containers[] | .securityContext .runAsUser -gt 10000", "reason": "Run as a high UID to avoid collisions with host users", "points": 1 },
{ "id": "RunAsGroup", "selector": ".spec, .spec.containers[] | .securityContext .runAsGroup -gt 10000", "reason": "Run as a high GID to avoid collisions with host groups", "points": 1 },
{ "id": "SeccompAny", "selector": ".spec .securityContext .seccompProfile .type", "reason": "Seccomp profiles set minimum privilege and guard against unknown threats", "points": 1 }
],
"advise": [
{ "id": "ApparmorAny", "selector": ".spec .securityContext .appArmorProfile .type", "reason": "Well defined AppArmor policies may provide greater protection from unknown threats", "points": 3 }
]
}
}
]

Ask the kernel, not the YAML

A clean scan means the YAML is right. It does not prove the running container matches, because an image can quietly override what you asked for. So apply the pod, then read what the process actually holds in /proc, the kernel's own live view of every running process. The kernel has no reason to flatter you. Look at the Seccomp: 2 line. Seccomp (secure computing mode) is a kernel feature that filters system calls, and a system call is how a program asks the kernel to do something real, like open a file or a raw network socket. The RuntimeDefault profile is an allow-list for those requests, the way you might set a phone to ring only for numbers already in your contacts. The 2 means that filter is loaded and turning away the couple of dozen calls an ordinary app never makes.

terminal
$ kubectl apply -f pod.yaml
pod/app created
$ kubectl exec app -- id
uid=10001 gid=10001 groups=10001 # non-root, as declared
$ kubectl exec app -- grep -E 'CapEff|NoNewPrivs|Seccomp' /proc/1/status
CapEff: 0000000000000400 # only NET_BIND_SERVICE remains
NoNewPrivs: 1 # allowPrivilegeEscalation: false took hold
Seccomp: 2 # RuntimeDefault filter loaded (2 = filter mode)
$ kubectl exec app -- touch /etc/probe
touch: /etc/probe: Read-only file system # readOnlyRootFilesystem: true
command terminated with exit code 1
One RCE, five doors it can't open
Attacker gets code execution inside the container
a single bug in the app
wants root
runAsNonRoot + runAsUser 10001
process is UID 10001, so root-only files and sockets stay shut
wants new privileges
allowPrivilegeEscalation: false
setuid binaries can't raise privileges; NoNewPrivs=1
wants kernel powers
capabilities: drop ALL
no CAP_SYS_ADMIN, no mount, no raw packet sockets
wants to plant a backdoor
readOnlyRootFilesystem: true
can't write a binary or cron entry to disk
wants a risky syscall
seccompProfile: RuntimeDefault
the dangerous syscalls are refused at the kernel
Each control shuts one escalation path. Miss one and the attacker walks through that gap, which is why the baseline is all five together rather than a pick-and-choose.

Where it actually bites

runAsNonRoot is enforcement, not decoration. If an image is built to start as root, the kubelet refuses to run it under that setting and the pod never comes up. Here is the same spec pointed at a legacy image whose Dockerfile ends with USER 0. Watch where it breaks.

terminal
# same securityContext, but the image's Dockerfile ends with USER 0
$ kubectl apply -f legacy-pod.yaml
pod/legacy created
$ kubectl get pod legacy
NAME READY STATUS RESTARTS AGE
legacy 0/1 CreateContainerConfigError 0 6s
$ kubectl describe pod legacy | grep -A1 Warning
Warning Failed kubelet Error: container has runAsNonRoot
and image will run as root
runAsNonRoot fails late, on the node
Set runAsNonRoot: true and forget runAsUser, and a root image sails past your scanner and past admission. The kubelet only notices when it tries to start the container on a node, so the failure lands in the middle of a rollout as CreateContainerConfigError instead of in your pull request. Pin an explicit non-root runAsUser, and test with the real image, because scanning the YAML cannot tell you which UID got baked into that image.

A short list of fields undoes all of this in a single line, so treat each one as an automatic review blocker: privileged: true, hostPID, hostIPC, hostNetwork, and hostPath mounts. privileged is the worst of them. It switches seccomp off, ignores your capability drops, and lifts device restrictions in one go, handing the container the host's devices. When a manifest asks for any of the five, that is the first question you raise in review, and almost never the thing you approve.

When a sidecar genuinely needs something the baseline forbids, keep the loosening as small as you can make it: one container, one field, and a comment naming the feature that depends on it. A logging sidecar that wants somewhere to write is a volume problem, not a reason to drop readOnlyRootFilesystem across the whole pod. Narrow exceptions get approved in minutes. Broad ones sit in the review queue, which is the right outcome.

readOnlyRootFilesystem breaks real applications that expect to scribble somewhere. Anything that unpacks a template, writes a lock file, or caches to /tmp falls over on the first request. Give it an emptyDir mounted at the paths the app actually writes to, marked noexec where your runtime allows it, so the app can write data but never a binary it can then run.

Expect your first drop ALL rollout to break something, and read the error before you widen anything. A process that cannot bind port 80 wants NET_BIND_SERVICE, or better, a port above 1024 and no capability at all. A failure to change file ownership points at CHOWN. Add back the one named capability the error implicates, then run it again. Reaching for privileged because a container refuses to start turns a five-minute lookup into an open door that outlives the debugging session.

Test the bad pod, not only the good one, and test it on every cluster you run: lab, staging, and production. A Gatekeeper constraint (Gatekeeper is the admission controller that rejects manifests breaking your rules) that exists only in production teaches developers the wrong defaults all week, then ambushes them on Friday afternoon. Trying to deploy a pod with privileged: true and watching it get refused is the only proof the control is switched on.

Keep the evidence beside the control. In the same pull request that adds the securityContext, paste the id output and the CapEff, NoNewPrivs and Seccomp lines you read from /proc/1/status on a real run, so the next person on call can tell pass from fail without guessing. Then read them again a month later, while something is broken at 2am, because a baseline that only holds up when you are calm and reading carefully will not survive an incident.

Try this

Deploy a pod that runs as a non-root user, cannot escalate, and cannot write to its root filesystem. Then prove both claims from inside the container instead of trusting the manifest.

terminal
$ kubectl -n payments apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata: { name: locked }
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile: { type: RuntimeDefault }
containers:
- name: c
image: busybox:1.36
command: ["sleep","3600"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
volumeMounts:
- { name: tmp, mountPath: /tmp }
volumes:
- { name: tmp, emptyDir: {} }
EOF
pod/locked created
$ kubectl -n payments exec locked -- id
uid=10001 gid=0 groups=0
$ kubectl -n payments exec locked -- sh -c 'echo x > /bin/busybox' || echo RO_OK
RO_OK

Takeaway

A pod worth trusting is one where id returns a high UID, CapEff comes back nearly empty, and a write to /etc fails. Set the fields, read /proc to confirm the node agreed with you, and put an admission rule behind it so nobody has to remember.

Quick check
01A pod already sets runAsNonRoot, drops ALL capabilities, mounts a read-only root filesystem, and runs under the RuntimeDefault seccomp profile. Which single extra field cancels all four at once?
Incorrect — This lets the container run as root, bad enough on its own, but the capability drop, the read-only filesystem and the seccomp filter all still apply.
Correct — Privileged mode switches seccomp off, ignores the capability drops and lifts device restrictions in one line, so the other four settings stop meaning anything.
Incorrect — This reopens setuid-style escalation, but capabilities, the read-only filesystem and seccomp are all still enforced.
Incorrect — Sharing the host network namespace is real exposure, but it leaves seccomp, the capability drops and the read-only root filesystem exactly where they were.
02The lesson tells you to set runAsGroup even after you have pinned a non-root runAsUser. What does skipping runAsGroup actually cost you?
Incorrect — No. runAsGroup is optional, so the pod starts fine. It keeps a group you did not want.
Correct — Without runAsGroup the container's primary group stays 0, so files owned by the root group are still in reach of a non-root UID.
Incorrect — No. runAsUser still fixes the UID. Only the group falls back to 0.
Incorrect — No. fsGroup is a separate field and leaving runAsGroup out does not touch it.
03You set runAsNonRoot: true, leave runAsUser out, and the image's Dockerfile ends with USER 0. The manifest passes your Kubesec scan and clears admission. What happens next?
Incorrect — No. Admission never checks it, which is exactly why the manifest cleared both CI and admission.
Incorrect — No. It never starts at all, because the kubelet refuses to run a root image under runAsNonRoot.
Correct — runAsNonRoot is enforced when the container starts on a node, so a root image surfaces the failure partway through the rollout.
Incorrect — No. It is enforced at runtime rather than being a soft warning, and the container never comes up.

Related