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:
@@ -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 → non-root → 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 → 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 — 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 · uid 1000 · 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 · LoadBalancer · secrets operator</text>
|
||||||
|
<text x="78" y="350" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">→ 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
Reference in New Issue
Block a user