Zeeshan Adil
← All writing

Kubernetes User Namespaces: Root in the Container, Nobody on the Node

by Zeeshan Adil·in Kubernetes30 min read

What hostUsers: false actually changes, why root inside a container stops meaning root on the node, and the specific list of things user namespaces do not protect you from.

  • #kubernetes
  • #security
  • #containers
  • #isolation
  • #capabilities
Share

Two clusters, the same vulnerable container image, the same working exploit. On one of them the attacker gets a shell on the node and can read every secret mounted on it. On the other they get a shell that cannot read /etc/shadow, cannot write to the container runtime socket, and owns nothing outside a directory that was created for it.

The difference is one boolean in the pod spec.

That boolean is worth understanding properly rather than pasting, because it changes what an identity means on the node, and because it has a specific and fairly short list of things it does not protect you from. Vendors tend to skip that second list.

What the kernel actually stores

Usernames are a convenience for humans. The kernel does not know that you called someone nginx or deploy. /etc/passwd is a lookup table that userspace tools read so they can print something friendlier than a number.

What the kernel stores is a set of numeric credentials attached to every process:

process credentials
+---------------------------------------------+
| real uid        who started this             |
| effective uid   what it is checked against   |
| saved uid       what it may switch back to   |
| filesystem uid  used for file access checks  |
| real/effective/saved gid                     |
| supplementary groups                         |
| capability sets                              |
+---------------------------------------------+

Every permission decision the kernel makes comes out of that structure. When a process opens a file, the kernel compares the process credentials against the owner and mode recorded in the inode. Both sides of that comparison are integers.

This matters for containers because those integers are the only thing being compared. There is no second field recording which container a process belongs to, and no flag that says this uid is only pretending.

The part everyone simplifies

The usual explanation is that uid 0 bypasses permission checks. That was true a long time ago and it is not how the kernel has worked since Linux 2.2.

So the accurate version is narrower and more useful. A process with uid 0 and a full capability set can ignore file permission bits, bind low ports, change any file's owner, kill any process, and load kernel modules. Strip the capabilities and uid 0 can do very little. Keep the capabilities but confine them to a namespace and uid 0 can do a great deal inside that namespace and nothing outside it.

A container is not a small virtual machine

This is the model that has to change before the rest makes sense.

A virtual machine runs its own kernel. The guest kernel manages its own page tables, its own process table, its own view of devices, and the hypervisor mediates between that and the hardware. Two virtual machines on one host share silicon and a hypervisor, and nothing else.

A container is a process. It runs on the host kernel, scheduled by the host scheduler, making host system calls. What makes it feel separate is a set of namespaces that restrict what it can see, and cgroups that restrict how much it can consume.

                        one kernel, shared
+-------------------------------------------------------------+
|                         host kernel                          |
+-------------------------------------------------------------+
        ^                                    ^
        | syscalls                           | syscalls
+---------------+                    +---------------------+
| host process  |                    | container process   |
| uid 0         |                    | uid 0               |
| pid 1412      |                    | pid 1 (in its pidns)|
+---------------+                    +---------------------+

     without a user namespace, the kernel sees one uid 0 twice

Namespaces are per-resource rather than global. There is a mount namespace so the container sees its own filesystem tree, a PID namespace so it sees its own process numbering, a network namespace for its own interfaces and ports, a UTS namespace for its own hostname. Each one virtualizes a different global resource.

For a long time the user namespace was the one nobody turned on. Without it, the container's idea of who it is and the host's idea of who it is are the same number. A process running as uid 0 inside a container is uid 0 to the kernel, which is the same uid 0 that owns the node.

That is fine right up until the boundary leaks.

What a leak actually costs

The boundary is a set of restrictions the kernel enforces. Restrictions have bugs.

Most container escapes fall into one of three shapes. A bug in the runtime that lets a container influence something outside its own namespaces. A misconfiguration that hands the container something it should not have had, such as a mounted docker socket, a host path mount of /, or privileged: true. Or a kernel bug reachable through a syscall the container is allowed to make.

Whatever the path, the outcome without a user namespace is the same. The process is already uid 0 with capabilities. It arrives on the host as uid 0 with capabilities. There is no second gate. Everything on that node is now readable: every secret projected into every pod on it, the kubelet credentials, the container runtime socket, which is itself a route to starting a new privileged container.

On a shared cluster that is not one tenant's problem. It is everyone who happened to be scheduled on that node.

The mapping

A user namespace gives a process its own translation table between the uids it sees and the uids the kernel records.

   inside the namespace              what the kernel stores
   +---------------------+          +----------------------+
   | uid 0     (root)    |  ----->  | uid 100000           |
   | uid 1     (daemon)  |  ----->  | uid 100001           |
   | uid 33    (www-data)|  ----->  | uid 100033           |
   |   ...               |          |   ...                |
   | uid 65535           |  ----->  | uid 165535           |
   +---------------------+          +----------------------+

   the process believes the left column
   every permission check uses the right column

Inside, the process genuinely is root. Not a simulation and not a permission-denied wrapper. It can bind port 80, write to /etc, chown files in its own filesystem, install packages. Every one of those operations is checked against a capability that it really holds, in a namespace where holding it is meaningful.

Outside, the kernel has recorded a different number. When that process touches anything the namespace does not cover, the check runs against the mapped uid. A file on the node owned by uid 0 is not owned by this process. A capability held inside the namespace is not held against resources outside it.

The mapping is not something a process can grant itself arbitrarily. Writing a mapping requires privilege over the parent namespace, which is why in Kubernetes the kubelet and the container runtime set it up rather than the workload.

The field, and the double negative in it

In Kubernetes the switch is one boolean on the pod spec.

apiVersion: v1
kind: Pod
metadata:
  name: secure-workload
spec:
  hostUsers: false
  containers:
    - name: application
      image: alpine:latest
      command: ["sleep", "infinite"]

Read the name carefully, because it is the opposite way round from what people assume. hostUsers means use the host's user namespace. The API reference is explicit that it defaults to true, so every pod you have ever run without thinking about it shares the host's user namespace. Setting it to false is what creates a separate one.

So false is the hardened setting, and a reviewer skimming a diff for true is looking for the wrong value.

Where the feature actually is

This has moved a lot, and most of what is written about it online is pinned to whichever release the author was using.

ReleaseDateState
1.25Aug 2022Alpha, stateless pods only, gate named UserNamespacesStatelessPodsSupport
1.28Aug 2023Alpha, any volume type, gate renamed to UserNamespacesSupport
1.30Apr 2024Beta, but off by default
1.33Apr 2025Beta, on by default
1.36Apr 2026Stable

The current release as of writing is 1.37, from August 2026, and the documentation notes that the gate is now locked: setting UserNamespacesSupport explicitly is ignored rather than rejected.

Two things are worth pulling out of that table. The gate was renamed rather than removed, so a runbook from 2023 that tells you to enable UserNamespacesStatelessPodsSupport is pointing at a gate that no longer exists. And beta happened twice: available-but-disabled in 1.30, then on by default three years after the first alpha. If someone tells you this has been beta since 1.30 and therefore on, check the version.

What the kubelet does with it

When a pod asks for its own user namespace, the kubelet picks a contiguous block of host ids and maps the container's 0 through 65535 onto it.

The block size defaults to 65536 and, since 1.33, is configurable through userNamespaces.idsPerPod in the kubelet configuration, in multiples of 65536. Before 1.33 it was hard coded.

The kubelet guarantees no two pods on a node share a mapping. It also keeps pod ranges above the host's own 0 through 65535, which is deliberate: the documentation cites CVE-2021-25741, a symlink bug that let a pod read arbitrary host files, and notes that non-overlapping ranges limit what such a bug can reach.

The arithmetic gives you a ceiling. A 32-bit id space divided by 65536 is 65536 pods per node, which is far above any real maxPods, so in practice the limit that binds is the one you already have. Raising idsPerPod lowers that ceiling proportionally, which is worth knowing before you raise it.

What your cluster needs before this works

This is the part to check before you write the field into a manifest, because several of the failure modes are quiet.

ComponentMinimumWhy
Kubernetes1.25 alpha, 1.30 beta, 1.33 on by default, 1.36 stableFeature availability
containerd2.0Idmapped mounts without copying the image
CRI-O1.25Support landed with the original alpha
runc1.2Low level runtime support
crun1.9, 1.13 or later preferredAlternative low level runtime
cri-dockerdnot supportedNo implementation
Linux kernel6.3 in practicetmpfs idmapped mount support

The kernel row is the one that needs explaining, because two different numbers circulate and both are right about different things.

Idmapped mounts, the kernel feature that makes all of this work for files, landed in Linux 5.12. Support then arrived filesystem by filesystem over several releases: xfs and ext4 in 5.12, btrfs in 5.15, overlayfs in 5.19, and tmpfs only in 6.3.

That last one is why the practical answer is 6.3 rather than 5.12. Kubernetes mounts a service account token into essentially every pod, and secrets, configmaps, projected and downwardAPI volumes are all tmpfs. Without tmpfs idmap support, the volumes every pod already has are the ones that fail. Kubernetes' own guidance is that 5.19 can be made to work if you avoid tmpfs entirely, which is hard, and that anything below that is not recommended.

One failure mode deserves naming on its own. containerd's documentation warns that on containerd 1.6 or older, hostUsers: false in your pod spec is silently ignored. The pod starts, the field is accepted, nothing logs an error, and you have none of the protection you think you have. If you are rolling this out across a fleet, verify the runtime version on the nodes rather than the field in the manifest.

Where the cost went

You will read that this adds a slight scheduling overhead while the id ranges are provisioned. That is worth correcting in two directions, because the truth is both better and worse than the claim.

It is not the scheduler. The kubelet picks the range, after scheduling, on the node.

And on containerd 2.0 and later there is effectively no cost. The remap is an id translation applied at mount time, described in the GA announcement as an O(1) operation, and containerd's documentation states plainly that it uses no extra storage and adds no startup latency.

The overhead that claim half remembers was real, and it was not slight. Before idmapped mount support, containerd 1.7 implemented this by recursively changing ownership of every file in the image, which duplicated the image's storage for each pod and added startup time proportional to the number of files. If you read a 2023 benchmark showing this feature is expensive, that is what it measured, and it no longer applies.

What it does not protect you against

Every honest version of this article has this section, and the vendor versions tend not to.

Kernel exploits. The boundary is enforced by the kernel, and a bug in the kernel does not care what your uid maps to. A use-after-free in netfilter is reachable from a pod with a user namespace exactly as it is from one without.

The filesystem reach that remains. After an escape the attacker is an unprivileged host user, which is not the same as having no access. Kubernetes states it directly: the attacker can do on the host filesystem whatever the permission bits for other allow. World-readable files on the node are still readable.

Anything that is not an identity problem. Resource exhaustion, noisy neighbours, filling a disk, exhausting PIDs. Those are cgroups and quotas, and this feature touches none of them.

Workloads that genuinely need host privilege. If the container loads a kernel module or manages host networking, a namespaced CAP_SYS_MODULE does nothing for it. You cannot combine this with hostNetwork, hostPID or hostIPC on current stable releases, and no container in the pod may use raw block volumes.

And then the one that most write-ups omit entirely, which is the one a security reviewer will ask about.

User namespaces are themselves an attack surface

Creating a user namespace requires no privilege. Since Linux 3.8 an unprivileged process can call unshare with CLONE_NEWUSER, and the manual page is explicit that combining it with other namespace flags gives the caller privileges over those namespaces.

That is a privilege escalation primitive, and it has been used as one repeatedly. Red Hat's advisory for CVE-2023-32233, a use-after-free in netfilter, puts it as directly as anyone: local unprivileged users can exploit unprivileged user namespaces to grant themselves the capability the bug requires. The same shape appears in CVE-2022-0185, a heap overflow in the filesystem context API, and in CVE-2024-1086, where Red Hat notes the flaw allows privilege escalation specifically if user namespaces are enabled. Canonical's listed mitigation for the last of those is to stop unprivileged users creating namespaces at all.

Canonical restricted them in Ubuntu 23.10 and enabled that restriction by default in 24.04, citing a Google report that 44% of the exploits they saw used unprivileged user namespaces somewhere in the chain. Treat that figure as Canonical's citation rather than as a number you have read at source.

Does that mean enabling this feature increases your attack surface? Mostly no, and the reason is more interesting than a yes or no.

KEP-127, the enhancement proposal behind the feature, points out that a container without a seccomp profile blocking unshare can already create a nested user namespace and obtain a full capability set inside it, whether or not you set hostUsers: false. The primitive is available to your pods today. What the pod-level user namespace changes is what those capabilities are worth: they apply inside a namespace whose root maps to an unprivileged host id.

So the accurate claim is narrow. User namespaces reduce the consequence of an escape. They do not reduce the reachable kernel attack surface, and they are not a reason to skip a seccomp profile.

The part nobody mentions until it is Friday

Here is where this actually bites in production, and it is not security.

File ownership on a volume is stored as a number in the inode. The mapping changes which number a process presents, not which number is written on disk. So the question every team hits is what happens to a volume that was written by a pod without a user namespace and is now read by a pod with one.

Kubernetes made a deliberate choice here. runAsUser, runAsGroup and fsGroup all continue to mean the id inside the container, and inode ownership in volumes is unchanged. The practical consequence is good: you can turn this on and off without chowning anything, and a volume can be shared between pods that use the feature and pods that do not.

What you cannot do is reach a file whose owner falls outside your mapped range. Those appear as owned by the overflow id, conventionally 65534, and are not modifiable. If a pod suddenly sees nobody as the owner of files it used to own, that is the mapping, not corruption.

The failure you are most likely to meet first is blunter than that. If a filesystem under the pod does not support idmapped mounts, the mount fails with an invalid argument error on MOUNT_ATTR_IDMAP and the pod does not start. It is an event on the pod, not a clean admission rejection, which means it shows up at runtime on some nodes and not others if your fleet is not uniform.

The thing that makes this worth doing

There is an argument for this feature that is stronger than the escape scenario, and most writing on the subject buries it.

Pod Security Standards at the restricted level require runAsNonRoot. That is the rule that blocks a large amount of vendored and legacy software, because plenty of images want to be root inside their own filesystem and were never built to run any other way.

For pods with hostUsers: false, Kubernetes relaxes that. The documentation states that runAsNonRoot and runAsUser are not constrained for such pods even under baseline or restricted, with the justification that root inside a user namespace is never mapped to a privileged user on the host.

Read that again, because it reframes the feature. User namespaces are not only a mitigation you add on top of a hardened pod. They are the mechanism that lets a pod satisfy the restricted standard while still running as uid 0. For a platform team trying to get an entire estate onto restricted without rebuilding every third-party image, that is the whole argument.

The alternatives, and why this is not a choice between them

User namespaces sit in a stack of controls rather than replacing one. The useful question is not which is best, it is which problem each one solves and what it costs you.

Run as a non-root user

Setting runAsNonRoot: true in the security context refuses to start a container whose image would run as uid 0, and runAsUser pins a specific one.

This is the cheapest control here and for most workloads it is the right first move. It costs nothing at runtime and it needs no kernel features, no runtime version, and no node configuration.

Its limit is the reason user namespaces exist. Some software genuinely wants root inside its own filesystem: it writes to a config directory owned by root, binds a low port, or adjusts something at startup before dropping privileges. Vendored and legacy images do this constantly. With runAsNonRoot those containers fail to start, and your options are to rebuild the image, patch it at runtime, or give up. With a user namespace they run unmodified, as root, while the node sees an unprivileged id.

The two also answer different questions. runAsNonRoot decides what the process is inside the container. A user namespace decides what that identity means outside it. A container running as uid 1000 with no user namespace is still uid 1000 on the node, and if uid 1000 happens to own something interesting there, that matters.

Drop capabilities

capabilities: drop: ["ALL"], usually with allowPrivilegeEscalation: false, takes away the privileges rather than the number.

This is complementary rather than alternative, and the pairing is better than either alone. Dropping capabilities reduces what a compromised process can attempt in the first place, which shrinks the surface it can use to escape. A user namespace changes what it lands as if it succeeds anyway. One reduces the chance, the other reduces the blast radius.

The mechanism is worth knowing because it explains why the drop is not advisory. Runtimes implement it through the capability bounding set, described in capabilities(7) as a limiting superset on what can be gained during an execve. A capability removed from the bounding set cannot be regained by anything in the container, including a process running as uid 0. That is a genuinely hard boundary rather than a policy check.

There is a neat interaction with the previous section. In a pod with a user namespace, the capabilities that remain are namespaced: CAP_SYS_ADMIN applies inside the pod's namespace and means nothing outside it, and CAP_SYS_MODULE has no effect at all. So the same capability is worth less once the identity behind it is unprivileged on the node.

If you are only going to do one thing after reading this, drop capabilities. It is easier to roll out, it breaks fewer workloads than people expect, and it is the control that most directly reduces the number of reachable escape paths.

Give each pod its own kernel

Kata Containers and Firecracker take the other route: stop sharing the kernel at all. Each pod gets a lightweight virtual machine with its own kernel, and the host kernel sits behind a hypervisor boundary rather than a namespace boundary. gVisor takes a third path, implementing the system call interface in userspace so that, in its own words, no system call is passed through directly to the host.

These give stronger isolation than user namespaces, and they are the honest answer when you are running genuinely untrusted code, such as a platform executing customer-supplied builds.

They also cost more, though it is harder to say how much than the internet suggests. Kata declines to publish a single overhead figure at all, on the grounds that it depends on too many deployment-specific factors, and gVisor's performance page warns that its own published results are a worst case measured on a platform it no longer uses by default. Any confident number you read about either project came from somewhere other than the project.

What can be said structurally is enough to decide with. A hypervisor boundary means a separate kernel to boot and hold in memory for every pod, so startup and footprint both rise. A userspace syscall layer means syscalls cost more and some are unsupported, so the penalty tracks how syscall-heavy your workload is. A batch job running for an hour absorbs a startup cost that a short-lived request handler cannot. Measure your own workload.

How they stack

ControlStops the process being root insideStops root meaning anything outsideReduces escape pathsSeparate kernel
runAsNonRootyesnosomewhatno
Drop all capabilitiesno, but removes the powersnoyesno
User namespacenoyessomewhatno
microVM or gVisornoyesyesyes

Read the table by rows rather than picking a winner. The first three are cheap enough that on a multi-tenant cluster the reasonable default is all three, with the fourth reserved for workloads you actually do not trust.

What I would turn on, and in what order

If you are starting from a cluster with none of this, the order matters more than the list, because the cheap controls remove more risk per hour of work than the expensive ones.

First, a seccomp profile. RuntimeDefault on every workload. It is one field, it blocks a large set of syscalls including the ones several of the escapes above depend on, and it is required by the restricted standard anyway. The restricted Pod Security Standard prohibits leaving it unset, which is a good indication of how the project ranks it.

Second, drop all capabilities and set allowPrivilegeEscalation: false. Both are cheap, both break fewer workloads than teams expect, and together they cut the number of reachable escape paths rather than the consequence of one.

Third, hostUsers: false on the workloads you do not fully trust, once you have checked the kernel and runtime versions on the nodes. Start with the tenant workloads and the third-party images, not with your own services, because your own services are the ones most likely to already run as non-root and gain least.

Fourth, a microVM or gVisor runtime class, but only for the workloads that genuinely execute untrusted code. This is the one with real operational cost, and it is wasted on your own application.

The thing to avoid is the order people actually use, which is to argue about the fourth item for a quarter while the first two remain unset.

Sources and further reading

The primary material

  • User namespaces in the Kubernetes documentation is the single best page on this, and it covers the id allocation rules, the subuid configuration and the limitations. It describes the current release only, which is why the version history below is sourced separately.
  • The release announcements are worth reading in order, because each one explains what changed and why: beta in 1.30, on by default in 1.33, and stable in 1.36. The 1.30 post is the one that states plainly that a headline CVE was reduced rather than eliminated.
  • KEP-127 is the enhancement proposal. It lists the CVEs the feature mitigates and is candid about the ones it only partially mitigates.
  • capabilities(7) and user_namespaces(7) are the two manual pages behind most of this article. The first is where the traditional root model is described and then immediately qualified.
  • mount_setattr(2) carries the per-filesystem idmapped mount support table, which is the one to check before you assume your storage works.

Runtime and node

  • containerd's user namespaces document explains why 2.0 removed the storage and startup cost that 1.7 had, and warns that 1.6 ignores the field silently.
  • Linux kernel security constraints puts seccomp, AppArmor, SELinux and capabilities in one place and is explicit that they are meant to be combined.
  • Pod Security Standards is worth reading rather than summarising, particularly the gap between baseline and restricted.

The other side of the argument

Continue learning

A reasonable order, if this is the first of these controls you have looked at properly.

Start with capabilities, because everything else here is downstream of them. Read capabilities(7) end to end once, then look at what your own pods actually hold. Most teams find at least one workload running with far more than it needs, and fixing that is worth more than any single feature flag.

Then seccomp, because it is the control with the best ratio of risk removed to effort, and because several of the escapes described here depend on a syscall that the default profile blocks.

Then come back to user namespaces with the node in mind rather than the manifest. The interesting question is not how to write the field, it is whether your kernel, your runtime and your storage support it, and how you would know if they silently did not.

After that, Pod Security Admission, which is how you stop this being a per-manifest decision and make it a property of a namespace. Pay attention to the fact that enforcement applies to pods rather than to the workload resources that create them, so a bad Deployment is accepted and only its pods are rejected.

The natural next topic after all of that is the storage layer, because idmapped mounts are where this feature meets the part of your cluster that is hardest to change.

Frequently asked questions

Is this on by default in a recent cluster?

The feature is available by default from 1.33 and stable from 1.36, but it is not applied by default. hostUsers defaults to true, which means the pod shares the host's user namespace. Every pod that does not set the field explicitly is running the way it always did.

Does this stop container escapes?

No, and anyone who tells you it does is selling something. It reduces what an escape is worth. After a successful escape the attacker is an unprivileged host user, which still means access to anything the permission bits for other allow, and a kernel bug is reachable regardless of how ids are mapped. The Kubernetes project's own wording about the most widely publicised runc vulnerability is that the impact is greatly reduced rather than the vulnerability prevented.

Do I still need to run as non-root?

If you can, yes, and this is not a substitute. But the honest answer is that user namespaces exist largely for the cases where you cannot. An image that insists on being root inside its own filesystem will run with hostUsers: false while presenting an unprivileged identity to the node, and for pods with a user namespace Kubernetes relaxes the Pod Security Standards constraint on runAsNonRoot for exactly that reason.

Will this break my volumes?

Ownership inside volumes is not rewritten, so you can enable and disable the feature without chowning anything, and volumes can be shared with pods that do not use it. Two things do break. Files owned outside the pod's mapped range appear as owned by the overflow id and cannot be modified. And a filesystem that does not support idmapped mounts will fail the mount rather than degrade, which is why NFS-backed volumes do not work today.

What is the minimum version of everything?

Kubernetes 1.33 for the feature to be on without a gate, containerd 2.0 or CRI-O 1.25, runc 1.2 or crun 1.9, and Linux 6.3 in practice. The kernel number is the one people get wrong: idmapped mounts landed in 5.12, but tmpfs did not support them until 6.3, and tmpfs is what your service account token and secrets are mounted from.

Can I use it with hostNetwork?

Not on current stable releases. hostNetwork, hostPID and hostIPC are all incompatible with hostUsers: false, as are raw block volumes. An alpha feature gate in 1.35 lifts the network restriction, so if you read somewhere that these are permanently exclusive, check the version before believing it.