<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>Least Privilege | FirewallSync</title><link>https://firewallsync.com/tags/least-privilege/</link><description>Practical cybersecurity writing for engineering teams that ship fast.</description><language>en-us</language><image><url>https://firewallsync.com/icon-512.png</url><title>FirewallSync</title><link>https://firewallsync.com/</link></image><lastBuildDate>Sat, 24 Oct 2026 04:45:00 +0000</lastBuildDate><atom:link href="https://firewallsync.com/tags/least-privilege/index.xml" rel="self" type="application/rss+xml"/><item><title>Kubernetes Service Account Tokens: Retire Legacy Secrets</title><link>https://firewallsync.com/posts/kubernetes-service-account-tokens-bound-vs-legacy-secrets/</link><guid>https://firewallsync.com/posts/kubernetes-service-account-tokens-bound-vs-legacy-secrets/</guid><pubDate>Sat, 24 Oct 2026 04:45:00 +0000</pubDate><dc:creator>John Marshall</dc:creator><category>Identity &amp; Access</category><description>Kubernetes Pods get short-lived, Pod-bound service account tokens from the TokenRequest API, but Secret-based tokens still never expire and stay valid until someone deletes the Secret. Here is how the two differ, why a default Pod token can stay valid for up to a year, and how to find, trace and remove the legacy tokens left in your clusters.</description><enclosure url="https://firewallsync.com/posts/kubernetes-service-account-tokens-bound-vs-legacy-secrets/cover_hu16050852746697225062.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/kubernetes-service-account-tokens-bound-vs-legacy-secrets/cover_hu16050852746697225062.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/kubernetes-service-account-tokens-bound-vs-legacy-secrets/cover_hu16050852746697225062.jpg" alt="A grey bowl heaped with loose green numbered raffle tickets printed with the word TICKET" width="1200" height="630"></p><p>A Kubernetes service account token is only as safe as its lifetime. Tokens that Kubernetes issues through the TokenRequest API expire, carry an audience, and stop working the moment the Pod they were issued for is deleted. Secret-based tokens, the <code>kubernetes.io/service-account-token</code> type, never expire and stay valid until someone deletes the Secret. Kubernetes stopped creating those automatically in v1.24, but clusters that predate it and any team that created one by hand may still have them. Find them, find out who still uses them, move those callers to short-lived tokens, then delete the Secrets.</p>
<h2 id="two-kinds-of-token">Two kinds of token</h2>
<p>Both are signed JWTs that a client sends as <code>Authorization: Bearer &lt;token&gt;</code>. What differs is how long they live and what they are tied to.</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>Bound token (TokenRequest)</th>
          <th>Secret-based token</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>How it is issued</td>
          <td>TokenRequest API: the kubelet for a projected volume, or <code>kubectl create token</code></td>
          <td>A Secret of type <code>kubernetes.io/service-account-token</code>; the control plane fills in the token</td>
      </tr>
      <tr>
          <td>Expiry</td>
          <td>Yes. Projected volume default 1 hour, minimum 10 minutes, capped by <code>--service-account-max-token-expiration</code></td>
          <td>None</td>
      </tr>
      <tr>
          <td>Rotation</td>
          <td>The kubelet refreshes the file before expiry</td>
          <td>None</td>
      </tr>
      <tr>
          <td>Bound to</td>
          <td>A Pod, Secret or Node (by name and UID), plus the ServiceAccount</td>
          <td>The Secret and the ServiceAccount</td>
      </tr>
      <tr>
          <td>Audience</td>
          <td>Set per request; defaults to the API server&rsquo;s audiences</td>
          <td>API server only</td>
      </tr>
      <tr>
          <td>Dies when</td>
          <td>It expires, or the bound object or ServiceAccount is deleted</td>
          <td>The Secret or ServiceAccount is deleted</td>
      </tr>
      <tr>
          <td>Default since</td>
          <td>v1.22 (<code>BoundServiceAccountTokenVolume</code> stable)</td>
          <td>Auto-created for every ServiceAccount before v1.24</td>
      </tr>
  </tbody>
</table>
<p>The Kubernetes documentation states the trade-off without hedging: Secret-based tokens &ldquo;don&rsquo;t expire and don&rsquo;t rotate&rdquo;. A leaked bound token gives an attacker an hour, or until the Pod goes away. A leaked Secret-based token gives them that ServiceAccount&rsquo;s permissions until someone notices. What those permissions allow is a separate question, and our <a href="/posts/kubernetes-rbac-audit-11-permissions-that-escalate-access/">Kubernetes RBAC audit</a> covers the ones that let a ServiceAccount escalate.</p>
<h2 id="how-kubernetes-got-here">How Kubernetes got here</h2>
<table>
  <thead>
      <tr>
          <th>Version</th>
          <th>Change</th>
          <th>Feature gate</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>v1.20</td>
          <td>Service account token volume projection stable</td>
          <td>—</td>
      </tr>
      <tr>
          <td>v1.22</td>
          <td>Pods get bound tokens through a projected volume by default</td>
          <td><code>BoundServiceAccountTokenVolume</code></td>
      </tr>
      <tr>
          <td>v1.24</td>
          <td>Token Secrets no longer auto-created for new ServiceAccounts (beta, on by default)</td>
          <td><code>LegacyServiceAccountTokenNoAutoGeneration</code></td>
      </tr>
      <tr>
          <td>v1.26</td>
          <td>That change goes GA</td>
          <td><code>LegacyServiceAccountTokenNoAutoGeneration</code></td>
      </tr>
      <tr>
          <td>v1.28</td>
          <td>Last-used tracking for Secret-based tokens GA</td>
          <td><code>LegacyServiceAccountTokenTracking</code></td>
      </tr>
      <tr>
          <td>v1.29</td>
          <td>Unused auto-generated tokens invalidated and then deleted (beta, on by default)</td>
          <td><code>LegacyServiceAccountTokenCleanUp</code></td>
      </tr>
      <tr>
          <td>v1.30</td>
          <td>That clean-up goes GA</td>
          <td><code>LegacyServiceAccountTokenCleanUp</code></td>
      </tr>
      <tr>
          <td>v1.33</td>
          <td>Creating Node-bound tokens GA</td>
          <td><code>ServiceAccountTokenNodeBinding</code></td>
      </tr>
  </tbody>
</table>
<p>Upgrading never removed existing token Secrets. A cluster created on v1.21 and upgraded every quarter since can still hold a never-expiring token for every ServiceAccount that existed back then.</p>
<h2 id="the-clean-up-only-covers-half-of-the-problem">The clean-up only covers half of the problem</h2>
<p>From v1.29, the legacy token cleaner in kube-controller-manager checks every 24 hours. It marks an auto-generated token invalid if all of these are true:</p>
<ul>
<li>it is auto-generated, meaning the Secret and the ServiceAccount reference each other;</li>
<li>no Pod currently mounts it;</li>
<li>it has not been used for the clean-up period since it was created or last used.</li>
</ul>
<p>Invalid tokens get a <code>kubernetes.io/legacy-token-invalid-since</code> label. If one stays unused for another period, the cleaner deletes it. Both periods default to one year and are set with <code>--legacy-service-account-token-clean-up-period</code>.</p>
<p>Two gaps follow from those rules. A token in active use is never invalidated, which is the point, but it also means the riskiest token, the one a CI system or an old script still uses every day, survives. And <strong>manually created token Secrets are never cleaned up</strong>, because they are not auto-generated. Kubernetes still lets you create them, and the docs ask you to &ldquo;take into account the security implications&rdquo; when you do.</p>
<h2 id="the-one-year-pod-token">The one-year Pod token</h2>
<p>There is a less obvious long-lived token in most clusters: the default one in every Pod.</p>
<p>The ServiceAccount admission controller requests tokens with a lifetime of 3,607 seconds. In the Kubernetes source, <code>--service-account-extend-token-expiration</code> defaults to true, and the flag&rsquo;s help text says that when it is enabled &ldquo;admission injected tokens would be extended up to 1 year to prevent unexpected failure during transition, ignoring value of service-account-max-token-expiration.&rdquo; The token also gets a <code>warnafter</code> claim at the original expiry.</p>
<p>The example claims in the Kubernetes documentation show exactly this: <code>exp - iat</code> is 31,536,000 seconds (365 days), and <code>warnafter - iat</code> is 3,607 seconds. We computed both from the published example.</p>
<p>That token is still bound to its Pod, so deleting the Pod invalidates it. But a token copied out of a running Pod, for example through a file-read bug in the application, can stay valid for a long time if the Pod is long-lived. Managed Kubernetes providers set kube-apiserver flags themselves, so check yours.</p>
<p>When a client uses such a token after <code>warnafter</code>, the API server accepts it but adds the audit annotation <code>authentication.k8s.io/stale-token</code>, naming the subject, and increments <code>serviceaccount_stale_tokens_total</code>. Those are the clients that read the token once at start-up and never reload it. Fix them first. The documentation&rsquo;s advice is simple: reload the token file on a schedule, for example every five minutes. Once nothing is stale, you can consider turning the extension off.</p>
<h2 id="step-1-inventory-secret-based-tokens">Step 1: inventory Secret-based tokens</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>kubectl get secrets -A --field-selector type<span style="color:#f92672">=</span>kubernetes.io/service-account-token
</span></span></code></pre></div><p>Two labels tell you most of what you need. The API server sets <code>kubernetes.io/legacy-token-last-used</code> to the current date whenever a Secret-based token authenticates, both auto-generated and manual ones; the date is a day, not a timestamp. <code>kubernetes.io/legacy-token-invalid-since</code> appears once the cleaner has invalidated the token.</p>
<p>This short script turns the JSON into a table. It only reads stdin and changes nothing:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e">#!/usr/bin/env python3</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;List Secret-based service account tokens from `kubectl get secrets -A -o json`.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">Usage: kubectl get secrets -A -o json | python3 sa_token_secrets.py
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">Read-only: it parses JSON on stdin and prints a table. Python 3.8+.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> json
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> sys
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>LAST_USED <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;kubernetes.io/legacy-token-last-used&#34;</span>
</span></span><span style="display:flex;"><span>INVALID_SINCE <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;kubernetes.io/legacy-token-invalid-since&#34;</span>
</span></span><span style="display:flex;"><span>SA_ANNOTATION <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;kubernetes.io/service-account.name&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>items <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>load(sys<span style="color:#f92672">.</span>stdin)<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;items&#34;</span>, [])
</span></span><span style="display:flex;"><span>rows <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> s <span style="color:#f92672">in</span> items:
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> s<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;type&#34;</span>) <span style="color:#f92672">!=</span> <span style="color:#e6db74">&#34;kubernetes.io/service-account-token&#34;</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>    md <span style="color:#f92672">=</span> s<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;metadata&#34;</span>, {})
</span></span><span style="display:flex;"><span>    labels <span style="color:#f92672">=</span> md<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;labels&#34;</span>) <span style="color:#f92672">or</span> {}
</span></span><span style="display:flex;"><span>    rows<span style="color:#f92672">.</span>append((
</span></span><span style="display:flex;"><span>        md<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;namespace&#34;</span>, <span style="color:#e6db74">&#34;&#34;</span>),
</span></span><span style="display:flex;"><span>        md<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;name&#34;</span>, <span style="color:#e6db74">&#34;&#34;</span>),
</span></span><span style="display:flex;"><span>        (md<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;annotations&#34;</span>) <span style="color:#f92672">or</span> {})<span style="color:#f92672">.</span>get(SA_ANNOTATION, <span style="color:#e6db74">&#34;?&#34;</span>),
</span></span><span style="display:flex;"><span>        md<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;creationTimestamp&#34;</span>, <span style="color:#e6db74">&#34;&#34;</span>),
</span></span><span style="display:flex;"><span>        labels<span style="color:#f92672">.</span>get(LAST_USED, <span style="color:#e6db74">&#34;-&#34;</span>),
</span></span><span style="display:flex;"><span>        labels<span style="color:#f92672">.</span>get(INVALID_SINCE, <span style="color:#e6db74">&#34;-&#34;</span>),
</span></span><span style="display:flex;"><span>    ))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>header <span style="color:#f92672">=</span> (<span style="color:#e6db74">&#34;NAMESPACE&#34;</span>, <span style="color:#e6db74">&#34;SECRET&#34;</span>, <span style="color:#e6db74">&#34;SERVICEACCOUNT&#34;</span>, <span style="color:#e6db74">&#34;CREATED&#34;</span>, <span style="color:#e6db74">&#34;LAST-USED&#34;</span>, <span style="color:#e6db74">&#34;INVALID-SINCE&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> r <span style="color:#f92672">in</span> [header] <span style="color:#f92672">+</span> sorted(rows):
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\t</span><span style="color:#e6db74">&#34;</span><span style="color:#f92672">.</span>join(r))
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">{</span>len(rows)<span style="color:#e6db74">}</span><span style="color:#e6db74"> Secret-based service account token(s)&#34;</span>, file<span style="color:#f92672">=</span>sys<span style="color:#f92672">.</span>stderr)
</span></span></code></pre></div><p>We ran it with Python 3.13 against a sample <code>kubectl</code> JSON document with two token Secrets and one ordinary Secret; it listed the two tokens with their labels and skipped the other. Not reading the token value is deliberate: the inventory does not need it.</p>
<p>Sort the output into three groups:</p>
<table>
  <thead>
      <tr>
          <th>LAST-USED</th>
          <th>INVALID-SINCE</th>
          <th>What it means</th>
          <th>Action</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Recent</td>
          <td><code>-</code></td>
          <td>Someone depends on it today</td>
          <td>Trace the caller (step 2), migrate, then delete</td>
      </tr>
      <tr>
          <td>Old or <code>-</code></td>
          <td><code>-</code></td>
          <td>Not used recently, or not used since tracking started</td>
          <td>Confirm with audit logs, then delete</td>
      </tr>
      <tr>
          <td>Any</td>
          <td>A date</td>
          <td>Already invalidated by the control plane</td>
          <td>Delete now rather than waiting a year</td>
      </tr>
  </tbody>
</table>
<p>A missing last-used label does not prove a token was never used. Tracking only began when your cluster first ran a version with it enabled; the control plane records that date in the <code>kube-system/kube-apiserver-legacy-service-account-token-tracking</code> ConfigMap.</p>
<h2 id="step-2-find-out-who-still-uses-them">Step 2: find out who still uses them</h2>
<p>The API server adds an audit annotation every time a Secret-based token authenticates:</p>
<table>
  <thead>
      <tr>
          <th>Audit annotation</th>
          <th>Meaning</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>authentication.k8s.io/legacy-token-autogenerated-secret</code></td>
          <td>An auto-generated token Secret was used; the value is the Secret name</td>
      </tr>
      <tr>
          <td><code>authentication.k8s.io/legacy-token-manual-secret</code></td>
          <td>A manually created token Secret was used</td>
      </tr>
      <tr>
          <td><code>authentication.k8s.io/legacy-token-invalidated</code></td>
          <td>Someone tried an invalidated token; the value is <code>&lt;secret&gt;/&lt;namespace&gt;</code></td>
      </tr>
  </tbody>
</table>
<p>Search your audit logs for those keys and read <code>sourceIPs</code> and <code>userAgent</code> from the same events to identify the caller. If you don&rsquo;t have audit logs, the counters <code>serviceaccount_legacy_auto_token_uses_total</code> and <code>serviceaccount_legacy_manual_token_uses_total</code> at least tell you whether usage is going up or down. Clients using an auto-generated token also get an API warning asking them to use TokenRequest tokens instead, which sometimes shows up in CI logs.</p>
<h2 id="step-3-move-each-caller-to-a-short-lived-token">Step 3: move each caller to a short-lived token</h2>
<table>
  <thead>
      <tr>
          <th>Caller</th>
          <th>Replacement</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A workload in the cluster</td>
          <td>The default projected token, mounted automatically. Make sure the client reloads the file.</td>
      </tr>
      <tr>
          <td>A workload that sends the token to another service</td>
          <td>A projected <code>serviceAccountToken</code> volume with that service&rsquo;s <code>audience</code> and an explicit <code>expirationSeconds</code></td>
      </tr>
      <tr>
          <td>A person or a one-off job</td>
          <td><code>kubectl create token &lt;serviceaccount&gt; --duration=1h</code>; the issued duration may differ from what you ask for</td>
      </tr>
      <tr>
          <td>CI/CD or another external system</td>
          <td>Prefer an identity the cluster already trusts, such as your provider&rsquo;s workload identity or OIDC. If you must use a ServiceAccount token, issue a short one per run with <code>kubectl create token</code>.</td>
      </tr>
  </tbody>
</table>
<p>A projected token for a specific audience looks like this, adapted from the Kubernetes documentation example:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">Pod</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">metadata</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">app</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">serviceAccountName</span>: <span style="color:#ae81ff">build-robot</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">containers</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">app</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">registry.example.com/app:1.4.2</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumeMounts</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">mountPath</span>: <span style="color:#ae81ff">/var/run/secrets/tokens</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">name</span>: <span style="color:#ae81ff">vault-token</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">vault-token</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">projected</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">sources</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">serviceAccountToken</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">path</span>: <span style="color:#ae81ff">vault-token</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">expirationSeconds</span>: <span style="color:#ae81ff">7200</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">audience</span>: <span style="color:#ae81ff">vault</span>
</span></span></code></pre></div><p>A service that receives this token should reject any token whose audience is not its own. If you write that validation yourself, the Kubernetes project recommends the TokenReview API over OIDC discovery, because TokenReview fails immediately once the bound Pod is deleted, while plain JWT validation would keep accepting the token until it expires. Our guide to <a href="/posts/jwt-security-9-mistakes-that-can-turn-tokens-into-an-attack-path/">JWT security mistakes</a> covers the audience and expiry checks that apply here too.</p>
<h2 id="step-4-delete-and-stop-new-ones-appearing">Step 4: delete, and stop new ones appearing</h2>
<p>When a token Secret has no callers left, delete it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>kubectl -n &lt;namespace&gt; delete secret &lt;token-secret-name&gt;
</span></span></code></pre></div><p>If a caller you missed fails afterwards, you will see it in your audit logs and can issue a short-lived replacement. That is a better failure than an unexpired credential nobody owns.</p>
<p>To keep new ones from appearing, decide who may create Secrets of this type. RBAC can&rsquo;t filter on a Secret&rsquo;s <code>type</code> field, so use an admission policy for that rule; our walkthrough of <a href="/posts/kubernetes-validatingadmissionpolicy-cel-rules-for-registry-allowlists-and-memory-limits-with-a-warn-first-rollout/">ValidatingAdmissionPolicy with CEL</a> shows the warn-first rollout pattern. Expressions along the lines of <code>object.type != 'kubernetes.io/service-account-token'</code>, scoped to namespaces where nobody should create them, will do.</p>
<h2 id="reduce-what-any-token-can-do">Reduce what any token can do</h2>
<ul>
<li><strong>Don&rsquo;t mount tokens Pods don&rsquo;t use.</strong> Set <code>automountServiceAccountToken: false</code> on the ServiceAccount or the Pod; the Pod setting wins if they disagree. Most application Pods never call the Kubernetes API.</li>
<li><strong>Give each workload its own ServiceAccount</strong> instead of sharing <code>default</code>, so a token&rsquo;s permissions match one workload.</li>
<li><strong>Know how to revoke.</strong> There is no central record of issued bound tokens, so you can&rsquo;t revoke one by ID. Delete the Pod to kill a Pod-bound token. To kill every token for a ServiceAccount, delete and recreate it; the new UID invalidates the tokens issued for it.</li>
<li><strong>Don&rsquo;t mount projected tokens with <code>subPath</code>.</strong> A <code>subPath</code> mount never receives the refreshed file, so the application ends up with an expired token.</li>
</ul>
<h2 id="common-mistakes">Common mistakes</h2>
<ul>
<li>Treating an upgrade to v1.24 or later as the fix. It stopped new auto-generated tokens; it didn&rsquo;t remove old ones.</li>
<li>Waiting for the automatic clean-up. It ignores manual tokens and anything still in use, and takes up to two years by default.</li>
<li>Copying a Pod&rsquo;s token out for use elsewhere because it &ldquo;lasts a year&rdquo;. It is bound to that Pod and dies with it, and the API server counts it as stale after about an hour.</li>
<li>Hunting for leaked tokens in Secrets but not in Git, CI variables and kubeconfig files on laptops, where most old Secret-based tokens end up.</li>
</ul>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>This guide follows the upstream Kubernetes documentation for v1.37 and the Kubernetes source on the main branch. Managed services (EKS, GKE, AKS and others) set kube-apiserver and kube-controller-manager flags themselves and may disable or change the token extension, the clean-up period or audit logging. Check your provider&rsquo;s documentation before relying on any default described here. We tested the inventory script against sample JSON, not a live cluster.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Secret-based tokens don&rsquo;t expire or rotate; auto-creation stopped in v1.24; manual creation still possible</td>
          <td><a href="https://kubernetes.io/docs/concepts/security/service-accounts/">Service Accounts concept page</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Bound object types; v1.29 invalidation and deletion after one year each; cleaner conditions and 24-hour check; labels; <code>--legacy-service-account-token-clean-up-period</code>; no central record of short-lived tokens; recreate ServiceAccount to revoke</td>
          <td><a href="https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/">Managing Service Accounts</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>kubectl create token --duration</code>; projection flags; example claims with <code>warnafter</code>; kubelet refresh at 80% of TTL or 24 hours; reload every five minutes</td>
          <td><a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/">Configure Service Accounts for Pods</a></td>
          <td>Verified; lifetimes computed from the example</td>
      </tr>
      <tr>
          <td><code>expirationSeconds</code> defaults to 1 hour, minimum 600 seconds; <code>subPath</code> mounts don&rsquo;t receive updates</td>
          <td><a href="https://kubernetes.io/docs/concepts/storage/projected-volumes/">Projected Volumes</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>TokenReview recommended over OIDC discovery because it fails once bound objects are deleted</td>
          <td><a href="https://kubernetes.io/docs/concepts/security/service-accounts/">Service Accounts concept page</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Feature gate stages and versions</td>
          <td><a href="https://kubernetes.io/docs/reference/command-line-tools-reference/feature-gates/">Feature gates reference</a> (website repository source)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>3,607-second injected lifetime; one-year extension; extension defaults to true; stale-token and legacy-token audit annotations; last-used label set for manual and auto-generated tokens; metric names</td>
          <td>Kubernetes source: <a href="https://github.com/kubernetes/kubernetes/blob/master/pkg/serviceaccount/claims.go">claims.go</a>, <a href="https://github.com/kubernetes/kubernetes/blob/master/pkg/serviceaccount/legacy.go">legacy.go</a>, <a href="https://github.com/kubernetes/kubernetes/blob/master/pkg/serviceaccount/metrics.go">metrics.go</a>, <a href="https://github.com/kubernetes/kubernetes/blob/master/pkg/kubeapiserver/options/authentication.go">authentication options</a></td>
          <td>Verified from main branch; released versions may differ</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Kubernetes RBAC Audit: 11 Permissions That Escalate Access</title><link>https://firewallsync.com/posts/kubernetes-rbac-audit-11-permissions-that-escalate-access/</link><guid>https://firewallsync.com/posts/kubernetes-rbac-audit-11-permissions-that-escalate-access/</guid><pubDate>Thu, 22 Oct 2026 18:43:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>Identity &amp; Access</category><description>Some Kubernetes RBAC permissions look harmless but let the holder become someone more powerful: list on Secrets, create on Pods, get on nodes/proxy, and the escalate, bind and impersonate verbs among them. This guide lists the 11 the Kubernetes project flags and gives a tested script that finds them in your bindings.</description><enclosure url="https://firewallsync.com/posts/kubernetes-rbac-audit-11-permissions-that-escalate-access/cover_hu683712424518613144.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/kubernetes-rbac-audit-11-permissions-that-escalate-access/cover_hu683712424518613144.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/kubernetes-rbac-audit-11-permissions-that-escalate-access/cover_hu683712424518613144.jpg" alt="A heap of old rusty iron and steel keys of many shapes and sizes in bright sunlight" width="1200" height="630"></p><p>Kubernetes RBAC is usually audited by looking for <code>cluster-admin</code> bindings. That misses the permissions that don&rsquo;t look like admin but lead there: a service account that can list Secrets, a CI identity that can create Pods, a monitoring agent with <code>get</code> on <code>nodes/proxy</code>. The Kubernetes project documents these escalation paths on its RBAC Good Practices page. This guide turns that list into an audit you can run.</p>
<p><strong>Scope.</strong> Based on the Kubernetes v1.37 documentation pages &ldquo;Role Based Access Control Good Practices&rdquo;, &ldquo;Using RBAC Authorization&rdquo; and &ldquo;Authorization&rdquo;, read from the kubernetes/website repository on October 22, 2026. The audit script was tested with Python 3.13 on a sample JSON file we wrote; we did not run it against a live cluster.</p>
<h2 id="the-11-escalation-risks">The 11 escalation risks</h2>
<p>The RBAC Good Practices page lists these under &ldquo;privilege escalation risks&rdquo;. The &ldquo;why&rdquo; column paraphrases the documentation.</p>
<table>
  <thead>
      <tr>
          <th>#</th>
          <th>Permission</th>
          <th>Why it escalates</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td><code>get</code>, <code>list</code> or <code>watch</code> on <code>secrets</code></td>
          <td>All three reveal Secret contents; a list response includes every Secret&rsquo;s data</td>
      </tr>
      <tr>
          <td>2</td>
          <td>Creating Pods or workload resources (Deployments, Jobs and so on)</td>
          <td>Pods can mount the namespace&rsquo;s Secrets, ConfigMaps and volumes and run as any service account in the namespace; privileged Pods can reach the node</td>
      </tr>
      <tr>
          <td>3</td>
          <td><code>create</code> on <code>persistentvolumes</code></td>
          <td>Allows <code>hostPath</code> volumes, which expose the node&rsquo;s file system</td>
      </tr>
      <tr>
          <td>4</td>
          <td>Any access to <code>nodes/proxy</code></td>
          <td>Reaches the kubelet API, which can execute commands in every pod on the node, bypassing audit logging and admission control. <code>get</code> alone is enough</td>
      </tr>
      <tr>
          <td>5</td>
          <td><code>escalate</code> on <code>roles</code> or <code>clusterroles</code></td>
          <td>Lets the holder write roles with permissions it does not have</td>
      </tr>
      <tr>
          <td>6</td>
          <td><code>bind</code> on roles</td>
          <td>Lets the holder bind roles with permissions it does not have</td>
      </tr>
      <tr>
          <td>7</td>
          <td><code>impersonate</code></td>
          <td>Lets the holder act as other users, groups or service accounts</td>
      </tr>
      <tr>
          <td>8</td>
          <td><code>create</code> on <code>certificatesigningrequests</code> plus <code>update</code> on <code>certificatesigningrequests/approval</code></td>
          <td>With the <code>kubernetes.io/kube-apiserver-client</code> signer, can mint client certificates with arbitrary names, including system component names</td>
      </tr>
      <tr>
          <td>9</td>
          <td><code>create</code> on <code>serviceaccounts/token</code></td>
          <td>Issues tokens for existing service accounts</td>
      </tr>
      <tr>
          <td>10</td>
          <td>Control of <code>validatingwebhookconfigurations</code> or <code>mutatingwebhookconfigurations</code></td>
          <td>Webhooks can read every admitted object; mutating webhooks can change them</td>
      </tr>
      <tr>
          <td>11</td>
          <td><code>patch</code> on <code>namespaces</code></td>
          <td>Can relabel a namespace, which can loosen Pod Security Admission or widen NetworkPolicy access</td>
      </tr>
  </tbody>
</table>
<p>Two points deserve emphasis.</p>
<p><strong>Read is not always read-only.</strong> The documentation states plainly that <code>get</code> on <code>nodes/proxy</code> &ldquo;is not a read-only permission&rdquo;, because the kubelet APIs can be reached with websocket <code>GET</code> requests. Monitoring tools sometimes request it. If one of yours does, find out exactly why. There is often a narrower option. The kubelet maps <code>/metrics</code>, <code>/stats</code> and <code>/logs</code> to their own subresources (<code>nodes/metrics</code>, <code>nodes/stats</code>, <code>nodes/log</code>), so a collector that only reads those needs no <code>nodes/proxy</code>. On recent versions, kubelet fine-grained authorization (the <code>KubeletFineGrainedAuthz</code> feature, beta and on by default from v1.33, stable in v1.36) adds the same for <code>/pods</code>, <code>/runningPods</code>, <code>/healthz</code> and <code>/configz</code>, checking <code>nodes/pods</code>, <code>nodes/healthz</code> or <code>nodes/configz</code> before falling back to <code>nodes/proxy</code>.</p>
<p><strong>Pod creation is a namespace-wide grant.</strong> Anyone who can create a Pod can choose which service account it runs as and which Secrets it mounts. The documentation concludes that &ldquo;boundaries within a namespace should be considered weak&rdquo; and recommends enforcing the Baseline or Restricted Pod Security Standard where you don&rsquo;t fully trust the creator. See <a href="/posts/kubernetes-pod-security-admission-namespace-labels-modes-and-a-safe-rollout-from-warn-to-enforce/">our Pod Security Admission rollout guide</a>.</p>
<h2 id="two-problems-a-rolebinding-review-wont-catch">Two problems a RoleBinding review won&rsquo;t catch</h2>
<ul>
<li><strong><code>system:masters</code>.</strong> Members of this group bypass all RBAC checks, and that cannot be revoked by removing RoleBindings or ClusterRoleBindings. If an authorization webhook is in use, they bypass that too. Membership comes from the credential itself (for example a client certificate&rsquo;s organization field), so look at how credentials are issued, not at bindings.</li>
<li><strong>Wildcards.</strong> <code>*</code> in resources or verbs grants rights not only to every resource type that exists now but to every type created in the future, for example by a new CRD (CustomResourceDefinition).</li>
</ul>
<p>The documentation also warns about deleted users: if an attacker can create an account with the same name as a deleted user, they inherit any bindings that still name it. Remove bindings when you remove people.</p>
<h2 id="how-the-built-in-guardrails-work">How the built-in guardrails work</h2>
<p>RBAC does have an anti-escalation check. Per the RBAC reference, you can only create or update a role if you already hold all of its permissions at the same scope, or have <code>escalate</code> on roles. You can only create or update a role binding if you already hold all of the referenced role&rsquo;s permissions, or have <code>bind</code> on that role. That is why <code>escalate</code> and <code>bind</code> are on the list: they are the switches that turn the guardrail off.</p>
<p>The documentation shows a legitimate, narrow use of <code>bind</code>: letting a team lead grant only the built-in <code>admin</code>, <code>edit</code> and <code>view</code> ClusterRoles, by listing them in <code>resourceNames</code>. Without <code>resourceNames</code>, <code>bind</code> covers every role.</p>
<h2 id="a-script-to-find-risky-grants">A script to find risky grants</h2>
<p>The script below reads the JSON that kubectl produces for roles and bindings, and prints one line per subject per risky finding. It reports wildcards once instead of listing every risk they imply, and it skips cluster-scoped resources (such as <code>nodes/proxy</code> or <code>persistentvolumes</code>) when the grant comes through a RoleBinding, because the RBAC reference says those permissions only take effect through a ClusterRoleBinding.</p>
<p>Export the data first:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>kubectl get clusterroles,clusterrolebindings,roles,rolebindings -A -o json &gt; rbac.json
</span></span><span style="display:flex;"><span>python3 rbac_audit.py rbac.json
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> json<span style="color:#f92672">,</span> sys
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># (resource, verbs, why) - from the Kubernetes &#34;RBAC Good Practices&#34; page</span>
</span></span><span style="display:flex;"><span>RISKY <span style="color:#f92672">=</span> [
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;secrets&#34;</span>, {<span style="color:#e6db74">&#34;get&#34;</span>, <span style="color:#e6db74">&#34;list&#34;</span>, <span style="color:#e6db74">&#34;watch&#34;</span>}, <span style="color:#e6db74">&#34;reads Secret contents&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;pods&#34;</span>, {<span style="color:#e6db74">&#34;create&#34;</span>}, <span style="color:#e6db74">&#34;workload creation: mounts Secrets, runs as any ServiceAccount in the namespace&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;deployments&#34;</span>, {<span style="color:#e6db74">&#34;create&#34;</span>, <span style="color:#e6db74">&#34;update&#34;</span>, <span style="color:#e6db74">&#34;patch&#34;</span>}, <span style="color:#e6db74">&#34;workload creation&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;daemonsets&#34;</span>, {<span style="color:#e6db74">&#34;create&#34;</span>, <span style="color:#e6db74">&#34;update&#34;</span>, <span style="color:#e6db74">&#34;patch&#34;</span>}, <span style="color:#e6db74">&#34;workload creation&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;statefulsets&#34;</span>, {<span style="color:#e6db74">&#34;create&#34;</span>, <span style="color:#e6db74">&#34;update&#34;</span>, <span style="color:#e6db74">&#34;patch&#34;</span>}, <span style="color:#e6db74">&#34;workload creation&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;jobs&#34;</span>, {<span style="color:#e6db74">&#34;create&#34;</span>}, <span style="color:#e6db74">&#34;workload creation&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;cronjobs&#34;</span>, {<span style="color:#e6db74">&#34;create&#34;</span>}, <span style="color:#e6db74">&#34;workload creation&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;persistentvolumes&#34;</span>, {<span style="color:#e6db74">&#34;create&#34;</span>}, <span style="color:#e6db74">&#34;hostPath volumes give node filesystem access&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;nodes/proxy&#34;</span>, {<span style="color:#e6db74">&#34;get&#34;</span>, <span style="color:#e6db74">&#34;create&#34;</span>}, <span style="color:#e6db74">&#34;kubelet API: exec into pods, bypasses audit and admission&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;roles&#34;</span>, {<span style="color:#e6db74">&#34;escalate&#34;</span>}, <span style="color:#e6db74">&#34;can write roles with rights it does not hold&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;clusterroles&#34;</span>, {<span style="color:#e6db74">&#34;escalate&#34;</span>}, <span style="color:#e6db74">&#34;can write roles with rights it does not hold&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;roles&#34;</span>, {<span style="color:#e6db74">&#34;bind&#34;</span>}, <span style="color:#e6db74">&#34;can bind roles with rights it does not hold&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;clusterroles&#34;</span>, {<span style="color:#e6db74">&#34;bind&#34;</span>}, <span style="color:#e6db74">&#34;can bind roles with rights it does not hold&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;users&#34;</span>, {<span style="color:#e6db74">&#34;impersonate&#34;</span>}, <span style="color:#e6db74">&#34;acts as other users&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;groups&#34;</span>, {<span style="color:#e6db74">&#34;impersonate&#34;</span>}, <span style="color:#e6db74">&#34;acts as other groups&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;serviceaccounts&#34;</span>, {<span style="color:#e6db74">&#34;impersonate&#34;</span>}, <span style="color:#e6db74">&#34;acts as service accounts&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;certificatesigningrequests/approval&#34;</span>, {<span style="color:#e6db74">&#34;update&#34;</span>}, <span style="color:#e6db74">&#34;with CSR create, can mint client certificates&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;serviceaccounts/token&#34;</span>, {<span style="color:#e6db74">&#34;create&#34;</span>}, <span style="color:#e6db74">&#34;issues tokens for existing service accounts&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;validatingwebhookconfigurations&#34;</span>, {<span style="color:#e6db74">&#34;create&#34;</span>, <span style="color:#e6db74">&#34;update&#34;</span>, <span style="color:#e6db74">&#34;patch&#34;</span>}, <span style="color:#e6db74">&#34;sees every admitted object&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;mutatingwebhookconfigurations&#34;</span>, {<span style="color:#e6db74">&#34;create&#34;</span>, <span style="color:#e6db74">&#34;update&#34;</span>, <span style="color:#e6db74">&#34;patch&#34;</span>}, <span style="color:#e6db74">&#34;sees and changes every admitted object&#34;</span>),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;namespaces&#34;</span>, {<span style="color:#e6db74">&#34;patch&#34;</span>, <span style="color:#e6db74">&#34;update&#34;</span>}, <span style="color:#e6db74">&#34;can relabel namespaces (Pod Security, NetworkPolicy selectors)&#34;</span>),
</span></span><span style="display:flex;"><span>]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># A RoleBinding cannot grant access to cluster-scoped resources.</span>
</span></span><span style="display:flex;"><span>CLUSTER_SCOPED <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;persistentvolumes&#34;</span>, <span style="color:#e6db74">&#34;nodes/proxy&#34;</span>, <span style="color:#e6db74">&#34;clusterroles&#34;</span>,
</span></span><span style="display:flex;"><span>                  <span style="color:#e6db74">&#34;certificatesigningrequests/approval&#34;</span>, <span style="color:#e6db74">&#34;users&#34;</span>, <span style="color:#e6db74">&#34;groups&#34;</span>,
</span></span><span style="display:flex;"><span>                  <span style="color:#e6db74">&#34;validatingwebhookconfigurations&#34;</span>,
</span></span><span style="display:flex;"><span>                  <span style="color:#e6db74">&#34;mutatingwebhookconfigurations&#34;</span>, <span style="color:#e6db74">&#34;namespaces&#34;</span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">findings</span>(rule, namespaced_binding):
</span></span><span style="display:flex;"><span>    res, verbs <span style="color:#f92672">=</span> set(rule<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;resources&#34;</span>, [])), set(rule<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;verbs&#34;</span>, []))
</span></span><span style="display:flex;"><span>    out <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#e6db74">&#34;*&#34;</span> <span style="color:#f92672">in</span> res <span style="color:#f92672">or</span> <span style="color:#e6db74">&#34;*&#34;</span> <span style="color:#f92672">in</span> verbs:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> [<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;wildcard resources=</span><span style="color:#e6db74">{</span>sorted(res)<span style="color:#e6db74">}</span><span style="color:#e6db74"> verbs=</span><span style="color:#e6db74">{</span>sorted(verbs)<span style="color:#e6db74">}</span><span style="color:#e6db74">: &#34;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#34;covers current and future resource types&#34;</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> r, risky_verbs, why <span style="color:#f92672">in</span> RISKY:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> namespaced_binding <span style="color:#f92672">and</span> r <span style="color:#f92672">in</span> CLUSTER_SCOPED:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> r <span style="color:#f92672">in</span> res <span style="color:#f92672">and</span> verbs <span style="color:#f92672">&amp;</span> risky_verbs:
</span></span><span style="display:flex;"><span>            out<span style="color:#f92672">.</span>append(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>r<span style="color:#e6db74">}</span><span style="color:#e6db74"> </span><span style="color:#e6db74">{</span>sorted(verbs <span style="color:#f92672">&amp;</span> risky_verbs)<span style="color:#e6db74">}</span><span style="color:#e6db74">: </span><span style="color:#e6db74">{</span>why<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> out
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">main</span>(path):
</span></span><span style="display:flex;"><span>    items <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>load(open(path))[<span style="color:#e6db74">&#34;items&#34;</span>]
</span></span><span style="display:flex;"><span>    roles <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> o <span style="color:#f92672">in</span> items:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> o[<span style="color:#e6db74">&#34;kind&#34;</span>] <span style="color:#f92672">in</span> (<span style="color:#e6db74">&#34;Role&#34;</span>, <span style="color:#e6db74">&#34;ClusterRole&#34;</span>):
</span></span><span style="display:flex;"><span>            ns <span style="color:#f92672">=</span> o[<span style="color:#e6db74">&#34;metadata&#34;</span>]<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;namespace&#34;</span>, <span style="color:#e6db74">&#34;&#34;</span>)
</span></span><span style="display:flex;"><span>            roles[(o[<span style="color:#e6db74">&#34;kind&#34;</span>], ns, o[<span style="color:#e6db74">&#34;metadata&#34;</span>][<span style="color:#e6db74">&#34;name&#34;</span>])] <span style="color:#f92672">=</span> o<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;rules&#34;</span>) <span style="color:#f92672">or</span> []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> b <span style="color:#f92672">in</span> items:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> b[<span style="color:#e6db74">&#34;kind&#34;</span>] <span style="color:#f92672">not</span> <span style="color:#f92672">in</span> (<span style="color:#e6db74">&#34;RoleBinding&#34;</span>, <span style="color:#e6db74">&#34;ClusterRoleBinding&#34;</span>):
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>        ref <span style="color:#f92672">=</span> b[<span style="color:#e6db74">&#34;roleRef&#34;</span>]
</span></span><span style="display:flex;"><span>        bns <span style="color:#f92672">=</span> b[<span style="color:#e6db74">&#34;metadata&#34;</span>]<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;namespace&#34;</span>, <span style="color:#e6db74">&#34;&#34;</span>)
</span></span><span style="display:flex;"><span>        key <span style="color:#f92672">=</span> (ref[<span style="color:#e6db74">&#34;kind&#34;</span>], bns <span style="color:#66d9ef">if</span> ref[<span style="color:#e6db74">&#34;kind&#34;</span>] <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;Role&#34;</span> <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34;&#34;</span>, ref[<span style="color:#e6db74">&#34;name&#34;</span>])
</span></span><span style="display:flex;"><span>        nsb <span style="color:#f92672">=</span> b[<span style="color:#e6db74">&#34;kind&#34;</span>] <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;RoleBinding&#34;</span>
</span></span><span style="display:flex;"><span>        hits <span style="color:#f92672">=</span> sorted({f <span style="color:#66d9ef">for</span> rule <span style="color:#f92672">in</span> roles<span style="color:#f92672">.</span>get(key, []) <span style="color:#66d9ef">for</span> f <span style="color:#f92672">in</span> findings(rule, nsb)})
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> hits:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>        scope <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;cluster-wide&#34;</span> <span style="color:#66d9ef">if</span> b[<span style="color:#e6db74">&#34;kind&#34;</span>] <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;ClusterRoleBinding&#34;</span> <span style="color:#66d9ef">else</span> <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;namespace </span><span style="color:#e6db74">{</span>bns<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> s <span style="color:#f92672">in</span> b<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;subjects&#34;</span>) <span style="color:#f92672">or</span> []:
</span></span><span style="display:flex;"><span>            who <span style="color:#f92672">=</span> <span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;</span><span style="color:#e6db74">{</span>s[<span style="color:#e6db74">&#34;kind&#34;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74"> </span><span style="color:#e6db74">{</span>s<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;namespace&#34;</span>, <span style="color:#e6db74">&#34;&#34;</span>) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;/&#34;</span> <span style="color:#66d9ef">if</span> s<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;namespace&#34;</span>) <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34;&#34;</span><span style="color:#e6db74">}{</span>s[<span style="color:#e6db74">&#34;name&#34;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">for</span> h <span style="color:#f92672">in</span> hits:
</span></span><span style="display:flex;"><span>                print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>who<span style="color:#e6db74">}</span><span style="color:#e6db74"> | </span><span style="color:#e6db74">{</span>ref[<span style="color:#e6db74">&#39;kind&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74"> </span><span style="color:#e6db74">{</span>ref[<span style="color:#e6db74">&#39;name&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74"> | </span><span style="color:#e6db74">{</span>scope<span style="color:#e6db74">}</span><span style="color:#e6db74"> | </span><span style="color:#e6db74">{</span>h<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    main(sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">1</span>])
</span></span></code></pre></div><p>We ran it on a sample file with five bindings: a CI service account with a ClusterRole that can patch Deployments and list Secrets cluster-wide; a namespaced &ldquo;release&rdquo; Role with Pod creation and <code>bind</code>; a read-only debug Role; a monitoring agent bound through a RoleBinding to a ClusterRole with <code>get</code> on <code>nodes/proxy</code>; and a user with a full wildcard ClusterRole. Output:</p>
<pre tabindex="0"><code>ServiceAccount build/ci | ClusterRole ci-deployer | cluster-wide | deployments [&#39;patch&#39;]: workload creation
ServiceAccount build/ci | ClusterRole ci-deployer | cluster-wide | secrets [&#39;list&#39;]: reads Secret contents
ServiceAccount payments/release-bot | Role release | namespace payments | pods [&#39;create&#39;]: workload creation: mounts Secrets, runs as any ServiceAccount in the namespace
ServiceAccount payments/release-bot | Role release | namespace payments | roles [&#39;bind&#39;]: can bind roles with rights it does not hold
User alice@example.com | ClusterRole platform-admin | cluster-wide | wildcard resources=[&#39;*&#39;] verbs=[&#39;*&#39;]: covers current and future resource types
</code></pre><p>The read-only debug Role produced nothing, as intended, and the <code>nodes/proxy</code> grant was correctly skipped because a RoleBinding cannot grant a cluster-scoped resource. Had it been a ClusterRoleBinding, it would have been reported.</p>
<p><strong>Known limits of the script.</strong> It ignores <code>apiGroups</code> (a CRD that reuses a name like <code>jobs</code> would be flagged), does not evaluate <code>resourceNames</code> restrictions, flags <code>update</code> and <code>patch</code> on workloads as well as <code>create</code>, does not cover every workload type (for example ReplicaSets), and does not see <code>system:masters</code> membership or permissions granted by authorizers other than RBAC. Treat its output as a review list, not a verdict.</p>
<h2 id="confirming-a-finding">Confirming a finding</h2>
<p>For any subject the script flags, check what it can actually do. The Kubernetes authorization documentation shows <code>kubectl auth can-i</code> combined with impersonation:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>kubectl auth can-i list secrets --namespace payments <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  --as system:serviceaccount:build:ci
</span></span></code></pre></div><p>You need impersonation rights to run that, which is itself item 7 on the list. Keep those rights with a small group of administrators.</p>
<h2 id="fixing-what-you-find">Fixing what you find</h2>
<p>From the Good Practices page, plus our notes:</p>
<ul>
<li><strong>Prefer RoleBindings to ClusterRoleBindings,</strong> so rights stay inside one namespace.</li>
<li><strong>Replace wildcards</strong> with explicit resources and verbs.</li>
<li><strong>Give powerful service accounts to as few Pods as possible,</strong> and keep those Pods off nodes that run untrusted or internet-facing workloads, using taints and tolerations or affinity rules.</li>
<li><strong>Turn off token automounting</strong> where a workload doesn&rsquo;t call the API, with <code>automountServiceAccountToken: false</code> on the ServiceAccount or Pod; the Pod setting takes precedence.</li>
<li><strong>Review bindings for <code>system:unauthenticated</code>,</strong> which give access to anyone who can reach the API server.</li>
<li><strong>Use <code>resourceNames</code></strong> to limit <code>bind</code> and <code>impersonate</code> to specific roles or identities.</li>
<li><strong>Review periodically,</strong> and remove bindings for departed users and retired service accounts.</li>
</ul>
<p>The same least-privilege thinking applies to cloud IAM; <a href="/posts/how-to-find-overprivileged-aws-iam-users-and-roles/">our guide to finding overprivileged AWS IAM users and roles</a> covers that side. Namespace labels also drive network isolation, which is why item 11 matters for <a href="/posts/kubernetes-networkpolicy-default-deny-safe-rollout/">NetworkPolicy default deny</a>.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>The list of risks is the Kubernetes project&rsquo;s; it is not exhaustive for every add-on or CRD in your cluster, many of which define their own sensitive resources. The script was tested on sample data only. Managed Kubernetes services add their own identity layers (for example cloud IAM mappings) that are outside this article.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 22, 2026.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>The 11 privilege-escalation risks and their explanations; nodes/proxy get is not read-only; namespace boundaries are weak; system:masters bypasses RBAC; wildcard and deleted-user warnings; hardening advice</td>
          <td><a href="https://kubernetes.io/docs/concepts/security/rbac-good-practices/">Kubernetes: RBAC Good Practices</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Role creation and binding restrictions; escalate and bind; resourceNames example; cluster-scoped resources need a ClusterRoleBinding</td>
          <td><a href="https://kubernetes.io/docs/reference/access-authn-authz/rbac/">Kubernetes: Using RBAC Authorization</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Kubelet path-to-subresource mapping; fine-grained authorization endpoints; feature stages (beta default-on from v1.33, stable v1.36)</td>
          <td><a href="https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/">Kubernetes: Kubelet authentication/authorization</a> and the KubeletFineGrainedAuthz feature-gate page</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Client certificates can carry group memberships in their organization fields</td>
          <td><a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/">Kubernetes: Authenticating</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>kubectl auth can-i</code> with <code>--as</code> for service accounts</td>
          <td><a href="https://kubernetes.io/docs/reference/access-authn-authz/authorization/">Kubernetes: Authorization</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>automountServiceAccountToken: false</code>; Pod setting takes precedence</td>
          <td><a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/">Kubernetes: Configure Service Accounts for Pods</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Script output</td>
          <td>Run by us on October 22, 2026 with Python 3.13 on a five-binding sample file</td>
          <td>Verified on sample data; not run on a live cluster</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item></channel></rss>