Lesson 55 of 66 · 18 min read · Updated
Pod Security — securityContext and Pod Security Admission
On this page
In the previous lesson you controlled what a Pod may do to the Kubernetes API. This lesson is about what a container may do to the node it runs on. By default a container in Kubernetes runs as root, keeps a set of Linux capabilities, may gain more privileges through setuid binaries, and has a writable filesystem. None of that is needed by a typical .NET API, and every part of it helps an attacker who finds a way into your process.
Two tools fix this. The securityContext is how a Pod asks for fewer privileges. Pod Security Admission is how a namespace refuses Pods that did not ask.
We will cover the following topics:
- The securityContext fields that matter:
runAsNonRoot,runAsUser,allowPrivilegeEscalation,capabilities,seccompProfile,readOnlyRootFilesystem, andfsGroup - Building .NET images that run as non-root, including chiseled images
- The three Pod Security Standards: privileged, baseline and restricted
- The three Pod Security Admission modes: enforce, audit and warn
- Previewing the effect of a policy with a server-side dry run
- Taking a real Deployment from rejected to fully compliant, one error at a time
- Debugging a hardened container that has no shell
Technical Requirements
- A Kubernetes cluster and
kubectl. The outputs here come from Docker Desktop's Kubernetes with the kind provisioner (one control plane and three workers, Kubernetes v1.36). Pod Security Admission is built into the API server and enabled by default, so any recent cluster works. - Docker and the .NET 10 SDK to build the sample images.
- Images you build locally are visible to Docker Desktop's Kubernetes. With a cluster created by the
kindCLI, runkind load docker-image <image>after each build.
Core Concepts
What a Container Can Do by Default
Containers are processes on the node, isolated with Linux namespaces and cgroups. The isolation is real but thin. Without a securityContext:
| Default | Risk |
|---|---|
| The process runs as the image's user, which is root (UID 0) for most images | A container escape or a writable host mount becomes root on the node |
The process keeps about fourteen Linux capabilities, such as CHOWN, NET_RAW and SETUID | NET_RAW allows packet spoofing; SETUID and SETGID help privilege escalation |
| Privilege escalation through setuid binaries is allowed | A child process can gain more rights than its parent |
seccomp is off (Unconfined) unless the kubelet is configured otherwise | Every system call, including rarely used and dangerous ones, is available |
| The root filesystem is writable | An attacker can drop tools, modify binaries or plant files |
A .NET API needs none of this. It listens on an unprivileged port (8080 since .NET 8), reads its own files, and talks over the network.
The securityContext Fields
A securityContext exists at two levels. The Pod level applies to every container and to volumes. The container level applies to one container and overrides the Pod level where both define a field.
| Field | Level | What it does |
|---|---|---|
runAsNonRoot: true | Pod or container | The kubelet refuses to start the container if it would run as UID 0 |
runAsUser / runAsGroup | Pod or container | Overrides the user and primary group from the image |
fsGroup | Pod | Adds this group to every container and makes supported volumes group-owned and group-writable |
seccompProfile.type: RuntimeDefault | Pod or container | Applies the container runtime's default seccomp filter, which blocks dangerous system calls |
allowPrivilegeEscalation: false | Container | Sets the Linux no_new_privs flag, so setuid binaries cannot raise privileges |
capabilities.drop: ["ALL"] | Container | Removes every Linux capability |
readOnlyRootFilesystem: true | Container | Mounts the image's filesystem read-only; only volumes are writable |
privileged: true | Container | The opposite of all of the above: full access to the node. Avoid. |
How runAsNonRoot Checks the User
runAsNonRoot is checked by the kubelet just before the container starts, and it trips people up in two ways:
- If the image runs as root and you did not set
runAsUser, the container fails withcontainer has runAsNonRoot and image will run as root. - If the image sets a user by name, for example
USER app, the kubelet cannot know that name's UID without reading the image's/etc/passwd, so it refuses withimage has non-numeric user (app), cannot verify user is non-root.
The fix for the second case is a numeric user, either in the image (USER 1654) or in the Pod (runAsUser: 1654).
Non-Root .NET Images
Microsoft's .NET images already contain a non-root user called app with UID 1654, and they expose that number in the APP_UID environment variable. Use the variable in your Dockerfile so the image carries a numeric user:
The chiseled images (mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled) go further. They contain only what the .NET runtime needs: no shell, no package manager, and far fewer packages to patch. They are a natural fit for the restricted level, with one consequence you will meet in the example: you cannot kubectl exec a shell into them.
Pod Security Standards
The Kubernetes project defines three Pod Security Standards, each a fixed list of checks:
| Level | Intended for | What it blocks, in short |
|---|---|---|
privileged | System components such as CNI plugins, storage drivers, log collectors | Nothing |
baseline | Ordinary applications you do not fully control | Known privilege escalations: privileged: true, host namespaces (hostNetwork, hostPID, hostIPC), hostPath volumes, host ports, extra capabilities beyond a safe set, unsafe sysctls |
restricted | Your own applications, hardened | Everything baseline blocks, plus it requires runAsNonRoot: true, allowPrivilegeEscalation: false, capabilities.drop: ["ALL"], a seccomp profile of RuntimeDefault or Localhost, and only safe volume types |
readOnlyRootFilesystem is not required by any level. It is still worth setting, and the example does.
Pod Security Admission
Pod Security Admission (PSA) is the built-in admission controller that applies these standards. It replaced PodSecurityPolicy, which was removed in Kubernetes 1.25. You configure it with labels on the namespace:
There are three modes, and you can use all three at once with different levels:
| Mode | Effect on a violating Pod |
|---|---|
enforce | The Pod is rejected by the API server |
warn | The Pod is accepted, and the client (kubectl) shows a warning |
audit | The Pod is accepted, and an annotation is added to the API server's audit log entry |
Two details are important:
- Enforce applies to Pods, warn and audit also apply to workload templates. If a Deployment violates an enforce policy, the Deployment is accepted; it is the ReplicaSet controller that fails to create Pods, and nobody sees a warning unless they look at events. This is why you set
warnto the same level asenforce. - The version label pins the rules.
latestfollows the API server's version, so an upgrade can tighten the rules underneath you. Pinning to, for example,v1.36keeps the checks stable until you choose to move.
Labelling a namespace never touches running Pods. PSA only checks Pods when they are created or updated.
Hands-On: Kubernetes Commands
See the Pod Security labels of all namespaces
Namespaces without labels are effectively privileged.
Preview a level without applying it
A server-side dry run evaluates existing Pods against the new level and lists violations.
Enforce a level, pinned to a version
Pinning keeps the checks stable across cluster upgrades.
Find Pods the policy refused to create
The Deployment looks fine; the ReplicaSet records the rejection as events.
See the effective securityContext of a running Pod
Shows both the Pod-level and container-level settings.
Check which user an image runs as
An empty value means root.
Debug a container without a shell
Attach an ephemeral container that shares the target's process namespace. The restricted profile makes it pass the namespace's policy.
Step-by-Step Example
The Scenario
The payments team runs PaymentsApi, a .NET 10 API that writes receipt files. The security team wants its namespace at the restricted level. You will deploy the API as it is today, introduce the policy without breaking anything, and then harden the Deployment until it is admitted, watching each error tell you what to fix next.
PaymentsApi has two endpoints. /whoami reads /proc/self/status to report the process's user, group, capabilities, no_new_privs flag and seccomp mode. POST /receipts/{id} writes a file, the way real applications write caches, uploads or temp files.
Step 1 — The Application
Create the project:
Replace Program.cs:
You will build three images from the same code. Version 1.0 is the typical Dockerfile, with no USER line, so it runs as root:
Version 1.1 (Dockerfile.named) uses the chiseled base and the user name app:
Version 2.0 (Dockerfile) is identical except for one line, which uses the numeric UID:
Docker does not support comments at the end of a line, so keep the two variants in separate files rather than commenting one out inline. The build stage is the same in all three files.
Build all three, then check the configured users:
Step 2 — Deploy As It Is Today, with Warnings Only
Never start with enforce. Start with warn and audit, which change nothing but tell you what would break:
The Deployment as the team runs it today:
The Deployment was created and its Pod runs. The warning is the to-do list: four violations, each with the exact field to set. See what the process really has:
UID 0 is root. 00000000a80425fb is the bitmask of the default capability set. noNewPrivileges 0 means setuid escalation works, and seccomp 0 means no filter. The application can write anywhere in its image.
Step 3 — Preview, Then Enforce
Before enforcing, ask the API server what would happen. Compare the two levels:
The application already meets baseline, because it uses nothing privileged. It fails restricted. Enforce restricted anyway, pinned to the cluster's version, to see what the team will experience:
The existing Pod keeps running; labels never evict anything. The trouble starts at the next rollout:
The new ReplicaSet wants one Pod and has zero. The reason is only in the events:
This is the silent failure mode of enforce. The rollout is stuck, the old Pod keeps serving, and kubectl apply printed nothing alarming. In a real migration, keep warn and audit running long enough to fix every workload before you add enforce.
Step 4 — Add the securityContext
Add the four settings the warning asked for. runAsNonRoot and seccompProfile go at the Pod level, so they also cover any container added later. The other two are container-only fields:
Progress: PSA admitted the Pod, because the spec is now compliant. The kubelet then refused to start it:
The manifest promised non-root, but the image still runs as root. The kubelet checks the promise.
Step 5 — Fix the Image User
Try the chiseled image with USER app:
The second trap from Core Concepts: a user name cannot be verified. Use version 2.0, built with USER $APP_UID:
Check the process again:
Every line changed. The process runs as UID 1654, has no capabilities at all, cannot gain privileges, and runs under seccomp filter mode (2). The application did not notice: it still serves requests on port 8080.
If you cannot rebuild an image, setting runAsUser: 1654 in the Pod has the same effect as a numeric USER line.
Step 6 — Make the Root Filesystem Read-Only
Restricted does not require it, but a read-only root filesystem stops an attacker from writing tools or changing your binaries. Add one line to the container's securityContext:
ASP.NET Core starts happily on a read-only filesystem. The receipt endpoint does not:
This is the work readOnlyRootFilesystem creates: find every place the application writes, and give each one a volume.
Step 7 — Give the Application Writable Volumes, and Use fsGroup
The final Deployment mounts an emptyDir for receipts and another, memory-backed, for /tmp, where .NET and many libraries put temporary files. It also sets the user explicitly, adds fsGroup, and turns off the ServiceAccount token, since PaymentsApi never calls the Kubernetes API:
The sizeLimit values matter. A memory-backed emptyDir counts against the container's memory, and an unbounded one lets a bug fill the node's disk. In production, the receipts would go to a PersistentVolumeClaim or object storage, as covered in the storage lessons; the securityContext stays the same.
Step 8 — Look Inside a Container That Has No Shell
The chiseled image has no shell, so kubectl exec has nothing to run:
Attach an ephemeral debug container instead. Without a profile, kubectl debug adds the SYS_PTRACE capability, and the restricted namespace rejects it, which shows that PSA covers ephemeral containers too:
With --profile=restricted the debug container is compliant and runs as the same user as the application. It can see the application's process and, through /proc/1/root, its filesystem:
This listing shows fsGroup at work. Both volumes belong to group 1654 and carry the setgid bit (the s in rws), so every file created in them inherits group 1654. That is what lets a non-root process, or several containers running as different users in the same Pod, share a volume. The read-only root filesystem holds as well, even for the debug container.
Step 9 — Baseline for Workloads You Do Not Control
Restricted is for your own applications. For third-party software you cannot rebuild, baseline is the realistic floor: it allows root but blocks access to the node. Create a second namespace at baseline and try something that genuinely needs the node:
A Pod that mounts the node's whole filesystem as a privileged container is rejected; the root-running PaymentsApi 1.0 is accepted. Workloads that really need host access, such as CNI plugins or log collectors, belong in a dedicated namespace labelled privileged that only administrators can deploy to.
Step 10 — Clean Up
Summary
- By default a container runs as root, keeps Linux capabilities, may escalate privileges, has no seccomp filter and a writable filesystem. A .NET API needs none of it.
- The securityContext removes them:
runAsNonRoot,allowPrivilegeEscalation: false,capabilities.drop: ["ALL"],seccompProfile: RuntimeDefault, and optionallyreadOnlyRootFilesystemwithemptyDirvolumes for the paths you write to. - Build .NET images with
USER $APP_UID(numeric 1654). A user name such asUSER appfailsrunAsNonRoot. Chiseled images make the smallest, shell-less non-root images. fsGroupmakes volumes group-owned and setgid, so non-root processes can share them.- Pod Security Admission applies the privileged, baseline and restricted standards per namespace, through
enforce,warnandauditlabels. Pin versions with-versionlabels. - Roll out with
warnandauditfirst and preview with--dry-run=server. Enforce rejects Pods, not Deployments, so a violating rollout fails silently and only events tell you why. - Use
kubectl debug --profile=restrictedto inspect hardened, shell-less containers.
In the next lesson you will move from the node to the network and decide which Pods may talk to each other, with NetworkPolicies.