blog: 'The most secure credential is the one you never mount'

Least-privilege for Kubernetes workloads — own ServiceAccount + no auto-mounted
API token, then non-root + dropped caps, then Pod Security as the enforced floor.
Adds a matching house-style SVG diagram (workload-least-privilege).
This commit is contained in:
2026-06-23 19:17:03 +10:00
parent cbb82eb97b
commit b18c6589bf
2 changed files with 111 additions and 0 deletions
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1120" height="400" viewBox="0 0 1120 400" role="img" aria-label="Workload least privilege: each pod gets its own ServiceAccount with no API token, runs non-root with capabilities dropped, inside a namespace with Pod Security enforced; the cluster API it never calls stays out of reach, and only a few scoped components keep a token">
<rect width="1120" height="400" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="44" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">Service accounts all the way</text>
<text x="61" y="68" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">least privilege = identity &#8594; non-root &#8594; an enforced floor</text>
<!-- Kubernetes API -->
<rect x="60" y="150" width="180" height="96" rx="14" fill="#f8fafc" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="150" y="192" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#0e1726">Kubernetes API</text>
<text x="150" y="214" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#64748b">control plane</text>
<!-- broken link: API -> pod (token never mounted) -->
<text x="336" y="180" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11.5" fill="#64748b">no API token mounted</text>
<path d="M244 198 H424" fill="none" stroke="#64748b" stroke-width="2" stroke-dasharray="6 6" stroke-linecap="round"></path>
<path d="M328 190 L344 206 M344 190 L328 206" fill="none" stroke="#0d9488" stroke-width="2.6" stroke-linecap="round"></path>
<!-- namespace boundary -->
<rect x="430" y="92" width="630" height="252" rx="18" fill="#0d9488" fill-opacity="0.04" stroke="#0d9488" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="452" y="121" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0d9488">namespace</text>
<rect x="792" y="104" width="246" height="30" rx="15" fill="#ffffff" stroke="#0d9488" stroke-width="1.4"></rect>
<text x="915" y="124" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12" fill="#0d9488">PodSecurity: baseline &#8594; restricted</text>
<!-- workload pod -->
<rect x="470" y="156" width="420" height="176" rx="14" fill="#ffffff" stroke="#3b6fb0" stroke-width="1.8"></rect>
<text x="490" y="184" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">workload pod</text>
<!-- badge 1 -->
<rect x="490" y="196" width="380" height="26" rx="13" fill="#f1f5f9"></rect>
<circle cx="507" cy="209" r="3.6" fill="#0d9488"></circle>
<text x="522" y="213" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12" fill="#0e1726">ServiceAccount: its own &#8212; not the default</text>
<!-- badge 2 -->
<rect x="490" y="228" width="380" height="26" rx="13" fill="#f1f5f9"></rect>
<circle cx="507" cy="241" r="3.6" fill="#0d9488"></circle>
<text x="522" y="245" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12" fill="#0e1726">automountServiceAccountToken: false</text>
<!-- badge 3 -->
<rect x="490" y="260" width="380" height="26" rx="13" fill="#f1f5f9"></rect>
<circle cx="507" cy="273" r="3.6" fill="#0d9488"></circle>
<text x="522" y="277" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12" fill="#0e1726">runAsNonRoot &#183; uid 1000 &#183; drop ALL caps</text>
<!-- badge 4 -->
<rect x="490" y="292" width="380" height="26" rx="13" fill="#f1f5f9"></rect>
<circle cx="507" cy="305" r="3.6" fill="#0d9488"></circle>
<text x="522" y="309" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12" fill="#0e1726">seccompProfile: RuntimeDefault</text>
<!-- the exception: components that DO call the API -->
<rect x="60" y="286" width="300" height="80" rx="12" fill="#ffffff" stroke="#2e8b57" stroke-width="1.6"></rect>
<text x="78" y="309" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" font-weight="700" fill="#2e8b57">the documented exception</text>
<text x="78" y="330" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">GitOps &#183; LoadBalancer &#183; secrets operator</text>
<text x="78" y="350" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">&#8594; scoped Role + token, nothing more</text>
<path d="M150 286 V246" fill="none" stroke="#2e8b57" stroke-width="1.6" stroke-linecap="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

@@ -0,0 +1,60 @@
---
title: "The most secure credential is the one you never mount"
date: 2026-06-23
summary: "Least privilege for Kubernetes workloads doesn't start with an RBAC role — it starts with revoking the API token every pod silently carries, then layering identity, non-root, and Pod Security on top."
tags: ["kubernetes", "security", "least-privilege", "rbac", "service-accounts"]
draft: false
---
Open a shell in almost any Kubernetes pod and look in `/var/run/secrets/kubernetes.io`. There's a token
there — a live credential for the cluster API, mounted automatically, signed and ready. Now ask the harder
question: does this workload ever actually *call* the Kubernetes API? For a media server, a credential vault,
a DNS sinkhole, a search proxy — the answer is no. Never. Not once.
So every one of those pods is holding a key to a door it never opens. That's not least privilege. That's a
standing credential waiting to be stolen.
![Workload least privilege — each pod gets its own ServiceAccount with no API token, runs non-root with capabilities dropped, inside a namespace whose Pod Security level is pinned; the cluster API it never calls stays out of reach](/diagrams/workload-least-privilege.svg)
## Identity first, RBAC second
The reflex when someone says "least-privilege RBAC" is to start writing Roles. But a Role narrows what a
credential *can do* — it does nothing about a credential that shouldn't exist. The first move is cheaper and
safer: give every workload its **own** ServiceAccount instead of the shared namespace default, and set
`automountServiceAccountToken: false`.
That single line changes no runtime behaviour — no capabilities, no user, no restart semantics — because
the app wasn't using the token anyway. You've simply stopped handing out a key. The handful of components
that genuinely talk to the API (the GitOps controller, the load-balancer, the secrets operator) get a
scoped Role and keep their token. Everything else gets an identity and nothing else. It's the highest
security-per-effort change in the whole estate, and it's effectively free.
## Then take away what root gave you
Identity is one axis; what the process can do on the node is another. A container that runs as root with the
full Linux capability set is one escape away from the host, regardless of how few API rights its token has.
So the next layer is the container itself: run as a non-root UID, drop **all** capabilities and add back only
the few a workload truly needs, forbid privilege escalation, and pin a `RuntimeDefault` seccomp profile.
This is where honesty matters. Some images — anything running several processes under an init system, or the
classic "start as root, drop to a user" pattern — break the moment you drop capabilities, because something
inside still expects to `chown` a directory or bind a port. The lesson isn't "give up"; it's that non-root is
a *per-app, verified* change, while own-identity-and-no-token is a *universal, safe* one. Do the free thing
everywhere; do the careful thing where you've tested it.
## Make the floor non-negotiable
Authoring all of this per workload is good, but authoring isn't enforcing — a future manifest can forget. The
enforcement floor is **Pod Security Admission**, set at the namespace. Pin each namespace's level explicitly
rather than inheriting a cluster default you can't see: `baseline` where root init-containers still live,
`restricted` for the apps that have earned it, and a documented `privileged` exception for the genuine cases
(a VPN sidecar that needs `NET_ADMIN`, a time daemon that needs `SYS_TIME`). Run the rest at `warn` for the
restricted profile, and every apply tells you exactly which workload is one refactor away from the top tier.
## The principle
Least privilege isn't a pile of Roles. It's a direction: start by removing the access that nothing is using —
the auto-mounted token first — then narrow what genuinely remains, then make the boundary enforce itself. Give
every workload its own identity, hand it no credential it doesn't need, strip it to the smallest process it can
be, and let the namespace refuse anything bigger. The credential you never mount can't leak, can't be replayed,
and can't be the thing you forgot to revoke.