Kubernetes v1.36 shipped on April 23, 2026, and quietly closed out one of the longest-running KEPs in the project's history: User Namespaces graduated to General Availability. The KEP itself was first opened roughly a decade ago; six years of active engineering — alpha in v1.25 (October 2022), beta in v1.30 (April 2024), on-by-default in v1.33 (April 2025) — led to this release, where the UserNamespacesSupport feature gate is gone entirely and pod.spec.hostUsers: false is a frozen, stable API field. For a self-hosted PaaS packing untrusted tenant workloads onto shared nodes without a dedicated node pool per customer, that one field is the cheapest tenant-isolation upgrade available in 2026 — and this post is the recipe for making it the cluster's default instead of something each tenant has to remember to ask for.
Why GA Took Six Years: The Stateful Pod Problem
User namespace isolation for containers isn't new — Docker has supported it for a decade. What kept it out of Kubernetes for so long was a specific, ugly cost: without a kernel mechanism to remap UID ownership on an existing volume, the only way to isolate a pod's UID range was to recursively chown every file in every mounted volume at pod startup. On a stateful workload — a Postgres volume with a few million inodes, tens of gigabytes — that's a multi-minute startup penalty, paid on every restart. Nobody was going to ship that as a cluster default, or even recommend it for anything besides toy stateless pods.
The fix was ID-mapped mounts, a Linux kernel feature that landed for most filesystems in 5.12 and — critically for Kubernetes — reached tmpfs (the filesystem behind every Secret, ConfigMap, and projected service-account token volume) only in Linux 6.3. An idmap mount does the UID translation at the kernel's mount layer: the container sees UID 0 owning its files, the host disk keeps its original ownership, and no bytes get rewritten. It's an O(1) operation instead of an O(files) one. That's what turned "user namespaces work great until you attach a volume" into "user namespaces work great, full stop," and it's the concrete technical reason GA landed in 1.36 and not three years earlier when the feature first went alpha.
What hostUsers: false Actually Buys You
The mechanism itself is simple. With hostUsers: false set on a pod, the kubelet allocates that pod a range of host UIDs/GIDs it owns exclusively, then maps the container's internal UID 0 to the first ID in that range — some unprivileged UID at 65536 or above, never UID 0 on the host. Everything the process does inside the container that looks like root — writing files, holding CAP_NET_ADMIN, whatever capabilities its securityContext grants — is confined to that namespace. Capabilities become namespaced: a container process holding CAP_NET_ADMIN can reconfigure its own veth pair, but it cannot touch the host's routing table, because from the kernel's point of view that capability applies only inside its own user namespace.
Run that forward against two real CVEs and the payoff gets concrete. CVE-2024-21626 was a runc breakout via a leaked file descriptor pointing at the host filesystem through /proc/self/fd — an attacker who reached it got a process with a working directory on the host, and from there, host-level file access. CVE-2022-0492 was a cgroups release_agent bug that let a privileged container write an arbitrary path into a cgroup control file, which the kernel would then execute as root on the host. Both bugs assumed the escaping process carried real root privileges once it reached the host namespace.
With hostUsers: false, that assumption breaks: the escaped process still lands with the UID it was mapped to — an ordinary, unprivileged host user — not root. It can't read another tenant's /var/lib/kubelet/pods/<uid> volume data, because on-host that directory is owned by a different unprivileged UID that this tenant's mapping never grants access to. The same node, the same escape, and the blast radius stops at the tenant boundary instead of at the node boundary.
The Cluster-Default Recipe
GA doesn't mean every pod gets this automatically — hostUsers: false remains a per-pod opt-in field, and a PaaS operator can't rely on every tenant's deployment manifest remembering to set it. Making it the practical default for every workload on a shared node takes three pieces, all enforced at the platform layer rather than trusted to tenant YAML.
1. Node image: kernel ≥6.3 and an idmap-capable root filesystem. Confirm the node's /var/lib/kubelet/pods/ filesystem and every filesystem type your PVs use support idmap mounts — btrfs, ext4, xfs, fat, tmpfs, and overlayfs all qualify on 6.3+. If your node image still ships a 5.x LTS kernel (common on cloud vendor default images), this is a hard blocker, not a warning — pods requesting hostUsers: false will fail to schedule cleanly on older kernels.
2. subuid/subgid ranges baked into the node image. The kubelet needs its own subordinate UID/GID range configured in /etc/subuid and /etc/subgid for a system user literally named kubelet, with getsubids (from shadow-utils) installed and on PATH. The math has hard constraints: the starting ID must be a multiple of 65536 and at least 65536 (nothing in 0–65535 is eligible), and the range must cover at least 65536 × <maxPods>. For the Kubernetes default of 110 max pods per node, that's:
# /etc/subuid and /etc/subgid — identical ranges required in both
kubelet:65536:720896065536 × 110 = 7,208,960 — one full 65536-ID block per pod slot the node can ever schedule. Bake this into the base image (Packer, cloud-init, or your Talos/Cluster API node template) so it's present before kubelet ever starts; retrofitting it on a live fleet means a rolling node replacement, not a config push.
3. Admission-time enforcement, not a per-tenant checkbox. Since hostUsers defaults to unset (effectively true, no isolation) and stays that way even at GA, the only way to make isolation cluster-wide is to inject it before the pod is admitted. A Kubernetes-native MutatingAdmissionPolicy, or the equivalent Kyverno ClusterPolicy, does the job:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: default-user-namespace-isolation
spec:
rules:
- name: set-hostusers-false
match:
any:
- resources:
kinds: ["Pod"]
namespaceSelector:
matchLabels:
tenant-workload: "true"
mutate:
patchStrategicMerge:
spec:
hostUsers: falsePair the mutation with a matching validate rule (or a native ValidatingAdmissionPolicy with a CEL check on object.spec.hostUsers) that rejects any tenant manifest explicitly setting hostUsers: true in a tenant namespace — otherwise a tenant can simply override the mutated default back to unprotected. The combination gets you what a per-pod opt-in never will: isolation that doesn't depend on every tenant's Dockerfile author having read this post.
The Exception That Breaks It: hostNetwork
There's one hard incompatibility worth carving an exception for before it breaks something in production: hostUsers: false cannot coexist with hostNetwork: true. Kubernetes rejects the combination outright. That's a narrow list of pods on most clusters, but it's exactly the pods you'd otherwise forget — CNI plugin daemonsets, node-level monitoring agents (node-exporter and friends), and any ingress controller pinned to the host network for performance. Scope the mutating policy's namespace/label selector to exclude these (a platform-system or kube-system-style label works well) rather than discovering the conflict when a CNI pod fails to schedule after a policy rollout.
The Real Limitation: One Kernel, Every Tenant
Here's the part worth stating plainly rather than burying in a caveats section: user namespaces do not stop a kernel-CVE sandbox escape. Every pod on the node — isolated by hostUsers: false or not — still executes its syscalls against the same running kernel. If an attacker finds a genuine kernel vulnerability (a UAF in a network subsystem, an io_uring bug, the kind of flaw that shows up in kernel CVE advisories every few months), user namespaces don't put anything between the container process and that kernel code path. The isolation user namespaces provide is about identity and file ownership on a successful escape — not about preventing the escape.
There's also a subtler gap worth knowing about: the container creation flow — OCI runtime hooks, the steps before pivot_root — runs as a privileged process on the host, before the user namespace is even applied to the eventual container process. A vulnerability in that pre-namespace setup path bypasses the isolation model entirely, because there's no user namespace boundary yet to bypass.
This is exactly the gap gVisor and Kata Containers exist to close, and they close it in different ways. gVisor intercepts syscalls in userspace through its Sentry component — a container process reaching the real host kernel has to go through gVisor's own reimplementation first, which shrinks the exposed kernel surface substantially but doesn't eliminate it; a Sentry vulnerability is still a path to the host kernel.
Kata Containers (and Firecracker-based runtimes generally) go further: each pod runs in its own lightweight VM with its own kernel, so a kernel-level exploit inside the guest has no host kernel to escalate into at all. That isolation is strictly stronger than anything a shared-kernel model — user namespaces included — can offer, at the cost of the memory and startup overhead a VM boundary always carries.
| Blast radius on tenant compromise | Blast radius on kernel CVE | |
|---|---|---|
No isolation (hostUsers unset) | Full host root | Full host root |
hostUsers: false | Confined to mapped UID, no cross-tenant file access | Still shared kernel — no protection |
gVisor (RuntimeClass) | Confined, plus reduced kernel syscall surface | Reduced, not eliminated |
Kata/Firecracker (RuntimeClass) | Confined | Isolated — separate guest kernel |
What This Means for a Self-Hosted PaaS
The practical shape this takes on a real platform is layered, not either/or. hostUsers: false is nearly free — no VM overhead, no per-pod memory tax, and after GA, no feature gate to manage — so there's no reason not to make it the cluster-wide default for every tenant workload via the admission policy above. It closes the exact failure mode a shared-node, no-dedicated-node-pool PaaS cares about most: one tenant's compromised container reading another tenant's files on the same node. Reserve RuntimeClass: kata or RuntimeClass: gvisor for the workloads that actually warrant the extra overhead — genuinely untrusted code execution, customer-submitted build steps, anything closer to "run whatever this stranger pushed" than "run this vetted application image." Treating hostUsers: false as the floor and hardware-virtualized runtimes as the ceiling gets a multi-tenant platform real isolation gains at every tier, instead of spending a VM-per-pod budget on workloads that never needed it.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Multi-tenant isolation defaults like this are exactly the kind of platform-layer plumbing that shouldn't be left to a tenant's YAML. Star the repo on GitHub or deploy your first app today.
Sources
- Kubernetes v1.36: User Namespaces in Kubernetes are finally GA
- User Namespaces — Kubernetes documentation
- Kubernetes 1.36: User Namespaces Are Finally GA – And Why It Matters — vCluster
- Kubernetes User Namespaces in 1.36 with hostUsers: false
- KEP-127: User Namespaces — kubernetes/enhancements
- gVisor and Kata Containers: What the Shared Kernel Problem Forced the Industry to Build
- Kyverno Validate Rules documentation