<?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>Kubernetes | FirewallSync</title><link>https://firewallsync.com/tags/kubernetes/</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>Mon, 26 Oct 2026 04:45:00 +0000</lastBuildDate><atom:link href="https://firewallsync.com/tags/kubernetes/index.xml" rel="self" type="application/rss+xml"/><item><title>Kubernetes Audit Log Detections: 8 Rules We Tested</title><link>https://firewallsync.com/posts/kubernetes-audit-log-detections-8-rules-tested/</link><guid>https://firewallsync.com/posts/kubernetes-audit-log-detections-8-rules-tested/</guid><pubDate>Mon, 26 Oct 2026 04:45:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>Cloud Security</category><description>Eight detection rules for the Kubernetes audit log, written as jq filters and mapped to MITRE ATT&amp;CK: exec and debug sessions, pods asking for host access, cluster-admin bindings, Secret sweeps, minted tokens, impersonation, permission discovery and anonymous calls. We ran them against a scripted attack on kube-apiserver v1.34.1 and report what each caught, what it missed and how to tune it.</description><enclosure url="https://firewallsync.com/posts/kubernetes-audit-log-detections-8-rules-tested/cover_hu578911609021610890.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/kubernetes-audit-log-detections-8-rules-tested/cover_hu578911609021610890.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/kubernetes-audit-log-detections-8-rules-tested/cover_hu578911609021610890.jpg" alt="A single white surveillance camera on a short bracket, mounted on a plain pale grey textured wall crossed by a diagonal band of light" width="1200" height="630"></p><p>Kubernetes audit log detections work because most attack techniques against a cluster have to go through kube-apiserver. Getting a shell in a Pod, starting a container that mounts the node&rsquo;s filesystem, granting yourself cluster-admin, sweeping Secrets, minting a token: each is an API request, and each leaves an audit event with the identity, the verb, the resource and the result. The eight rules below turn those events into alerts.</p>
<p>We wrote them as <code>jq</code> filters so you can run them against a log file today, then ran them against kube-apiserver v1.34.1 while we played out a small attack: a stolen CI credential probing what it can do, a compromised administrator setting up persistence, and an anonymous caller. This article shows what each rule caught, what it would miss, and how to tune it before you port it to a SIEM.</p>
<p>The rules assume an audit policy that logs everything at <code>Metadata</code> or above and keeps request bodies for writes. If you do not have one yet, start with our <a href="/posts/kubernetes-audit-policy-what-to-log-and-at-what-level/">Kubernetes audit policy guide</a>; we used the same six-rule policy here.</p>
<h2 id="the-eight-rules">The eight rules</h2>
<table>
  <thead>
      <tr>
          <th>Rule</th>
          <th>What it looks for</th>
          <th>MITRE ATT&amp;CK</th>
          <th>Level needed</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>D1</td>
          <td><code>exec</code>, <code>attach</code> and <code>ephemeralcontainers</code> on Pods</td>
          <td>T1609 Container Administration Command</td>
          <td>Metadata (Request to see debug images)</td>
      </tr>
      <tr>
          <td>D2</td>
          <td>Pod creates asking for <code>privileged</code>, <code>hostPID</code>, <code>hostNetwork</code>, <code>hostIPC</code> or a <code>hostPath</code> volume, allowed or rejected</td>
          <td>T1610 Deploy Container, T1611 Escape to Host</td>
          <td>Request</td>
      </tr>
      <tr>
          <td>D3</td>
          <td>Bindings whose <code>roleRef</code> is <code>cluster-admin</code></td>
          <td>T1098.006 Additional Container Cluster Roles</td>
          <td>Request</td>
      </tr>
      <tr>
          <td>D4</td>
          <td>Secret <code>get</code>, <code>list</code> or <code>watch</code> across all namespaces or in <code>kube-system</code></td>
          <td>T1552.007 Container API</td>
          <td>Metadata</td>
      </tr>
      <tr>
          <td>D5</td>
          <td><code>serviceaccounts/token</code> creates by anything other than a kubelet</td>
          <td>T1528 Steal Application Access Token</td>
          <td>Metadata</td>
      </tr>
      <tr>
          <td>D6</td>
          <td>Any request carrying <code>impersonatedUser</code></td>
          <td>Context for every other rule</td>
          <td>Metadata</td>
      </tr>
      <tr>
          <td>D7</td>
          <td><code>selfsubjectrulesreviews</code> (<code>kubectl auth can-i --list</code>) and bursts of <code>forbid</code> decisions</td>
          <td>T1613 Container and Resource Discovery</td>
          <td>Metadata</td>
      </tr>
      <tr>
          <td>D8</td>
          <td>Requests from <code>system:anonymous</code></td>
          <td>Context: exposure check</td>
          <td>Metadata</td>
      </tr>
  </tbody>
</table>
<p>The ATT&amp;CK IDs and names are from MITRE&rsquo;s STIX data (enterprise collection version 20.0). The mapping is ours: ATT&amp;CK describes techniques, not audit fields.</p>
<h2 id="the-test">The test</h2>
<p>We ran kube-apiserver v1.34.1 and etcd 3.6.4 (the controller-runtime envtest binaries) with RBAC authorization, static bearer tokens for three users and the audit policy linked above, writing JSON lines to a file. There were no nodes, kubelets, controller manager or scheduler, so Pods were never scheduled. That keeps the log readable, but a real cluster&rsquo;s log is far noisier; see the limits section.</p>
<p>The scenario, in order:</p>
<ol>
<li><strong>Benign:</strong> <code>dev-bob</code>, in the <code>developers</code> group, creates a Pod in <code>dev</code>, reads a Secret in his own namespace and runs <code>kubectl exec</code>.</li>
<li><strong>Stolen CI token:</strong> <code>ci-deployer</code>, which can create Pods and exec in <code>prod</code>, runs <code>kubectl auth can-i --list</code>, tries to list Secrets, nodes, namespaces, ClusterRoles, ConfigMaps and ServiceAccounts cluster-wide, tries Secrets in <code>kube-system</code>, then tries to create a privileged Pod with <code>hostPID</code>, <code>hostNetwork</code> and a <code>hostPath</code> mount of <code>/</code>. The API server rejected that one (it was running with the default <code>--allow-privileged=false</code>), so the attacker dropped the privileged flags and created a Pod that only mounts <code>/</code> from the host. That one succeeded.</li>
<li><strong>Compromised admin:</strong> <code>alice</code> binds <code>cluster-admin</code> to the <code>prod/default</code> service account, mints a 24-hour token for it with <code>kubectl create token</code>, reads <code>kube-system</code> Secrets while impersonating it, and attaches a debug container to a running Pod with <code>kubectl debug</code>.</li>
<li><strong>Anonymous:</strong> an unauthenticated <code>curl</code> to <code>/api/v1/namespaces/kube-system/secrets</code>.</li>
</ol>
<p>The resulting log had 574 events. 532 came from <code>system:apiserver</code> itself; the other 42 were ours (alice 18, ci-deployer 15, dev-bob 8, anonymous 1).</p>
<h2 id="the-rules-as-a-script">The rules as a script</h2>
<p>Save this as <code>detections.sh</code> and run <code>sh detections.sh audit.log</code>. It needs jq 1.6 or later for <code>IN</code>; we ran it with jq 1.7.</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><span style="color:#75715e">#!/bin/sh
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#75715e"># Kubernetes audit log detections. Usage: ./detections.sh audit.log</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Input: kube-apiserver JSON-lines audit log (audit.k8s.io/v1 Event objects).</span>
</span></span><span style="display:flex;"><span>LOG<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span>$1<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Control-plane identities to ignore. Extend this for your distribution.</span>
</span></span><span style="display:flex;"><span>NOT_CP<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;(.user.username | IN(&#34;system:apiserver&#34;,&#34;system:kube-controller-manager&#34;,&#34;system:kube-scheduler&#34;) | not)&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Count each request once: long-running calls such as exec also emit ResponseStarted.</span>
</span></span><span style="display:flex;"><span>DONE<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;.stage==&#34;ResponseComplete&#34;&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D1 exec, attach and ephemeral containers&#34;</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#34;select(</span>$DONE<span style="color:#e6db74">)&#34;</span><span style="color:#e6db74">&#39; | select(.objectRef.resource==&#34;pods&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      and (.objectRef.subresource | IN(&#34;exec&#34;,&#34;attach&#34;,&#34;ephemeralcontainers&#34;)))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, sub:.objectRef.subresource, verb, ns:.objectRef.namespace,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     pod:.objectRef.name, code:.responseStatus.code, uri:.requestURI}&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D2 pods requesting host access (allowed or rejected)&#34;</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#39;select(.objectRef.resource==&#34;pods&#34; and .verb==&#34;create&#34; and .objectRef.subresource==null)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | select(.requestObject.spec.hostPID or .requestObject.spec.hostNetwork or .requestObject.spec.hostIPC
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      or any(.requestObject.spec.volumes[]?; .hostPath)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      or any(.requestObject.spec.containers[]?; .securityContext.privileged))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, ns:.objectRef.namespace, pod:.objectRef.name, code:.responseStatus.code,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     hostPaths:[.requestObject.spec.volumes[]?.hostPath.path | select(.)]}&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D3 bindings that grant cluster-admin&#34;</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#34;select(</span>$NOT_CP<span style="color:#e6db74">)&#34;</span><span style="color:#e6db74">&#39; | select(.objectRef.apiGroup==&#34;rbac.authorization.k8s.io&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      and (.objectRef.resource | IN(&#34;clusterrolebindings&#34;,&#34;rolebindings&#34;))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      and (.verb | IN(&#34;create&#34;,&#34;update&#34;,&#34;patch&#34;))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      and .requestObject.roleRef.name==&#34;cluster-admin&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, binding:.objectRef.name, subjects:.requestObject.subjects,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     code:.responseStatus.code}&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D4 Secret reads across all namespaces or in kube-system&#34;</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#34;select(</span>$NOT_CP<span style="color:#e6db74">)&#34;</span><span style="color:#e6db74">&#39; | select(.objectRef.resource==&#34;secrets&#34; and (.verb | IN(&#34;get&#34;,&#34;list&#34;,&#34;watch&#34;)))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | select(.objectRef.namespace==null or .objectRef.namespace==&#34;kube-system&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, as:.impersonatedUser.username, verb, ns:.objectRef.namespace,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     code:.responseStatus.code}&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D5 service account tokens minted outside the kubelet&#34;</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#34;select(</span>$NOT_CP<span style="color:#e6db74">)&#34;</span><span style="color:#e6db74">&#39; | select(.objectRef.resource==&#34;serviceaccounts&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      and .objectRef.subresource==&#34;token&#34; and .verb==&#34;create&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | select(.user.username | startswith(&#34;system:node:&#34;) | not)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, sa:(.objectRef.namespace + &#34;/&#34; + .objectRef.name),
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     code:.responseStatus.code}&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D6 impersonated requests&#34;</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#39;select(.impersonatedUser)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, as:.impersonatedUser.username, verb, res:.objectRef.resource,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     ns:.objectRef.namespace, code:.responseStatus.code}&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D7a permission listing (kubectl auth can-i --list)&#34;</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#39;select(.objectRef.resource==&#34;selfsubjectrulesreviews&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, ns:.objectRef.namespace, ip:.sourceIPs[0]}&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D7b identities with 5 or more denied requests in the file&#34;</span>
</span></span><span style="display:flex;"><span>jq -sc <span style="color:#e6db74">&#39;[.[] | select(.annotations[&#34;authorization.k8s.io/decision&#34;]==&#34;forbid&#34;)]
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | group_by(.user.username)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | map({user:.[0].user.username, denied:length,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">         resources:([.[].objectRef.resource] | unique)})
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | map(select(.denied &gt;= 5))&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D8 anonymous requests&#34;</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#39;select(.user.username==&#34;system:anonymous&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {uri:.requestURI, ip:.sourceIPs[0], code:.responseStatus.code}&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><h2 id="what-each-rule-caught">What each rule caught</h2>
<p>All 15 hits from the run, condensed:</p>
<table>
  <thead>
      <tr>
          <th>Rule</th>
          <th>Hits</th>
          <th>What fired</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>D1</td>
          <td>3</td>
          <td>dev-bob&rsquo;s exec (one <code>get</code>, one <code>create</code>, both 400 because the Pod had no node); alice&rsquo;s <code>patch</code> to <code>pods/ephemeralcontainers</code> (200)</td>
      </tr>
      <tr>
          <td>D2</td>
          <td>2</td>
          <td>ci-deployer&rsquo;s privileged Pod, rejected with 422; ci-deployer&rsquo;s hostPath Pod, created with 201. Both show <code>hostPaths: [&quot;/&quot;]</code></td>
      </tr>
      <tr>
          <td>D3</td>
          <td>1</td>
          <td>alice&rsquo;s <code>backup-sync</code> ClusterRoleBinding of <code>cluster-admin</code> to <code>prod/default</code></td>
      </tr>
      <tr>
          <td>D4</td>
          <td>4</td>
          <td>ci-deployer&rsquo;s cluster-wide list and <code>kube-system</code> list (both 403); alice&rsquo;s <code>kube-system</code> list as <code>prod/default</code> (200); the anonymous call (403)</td>
      </tr>
      <tr>
          <td>D5</td>
          <td>1</td>
          <td>alice minting a token for <code>prod/default</code></td>
      </tr>
      <tr>
          <td>D6</td>
          <td>1</td>
          <td>alice listing <code>kube-system</code> Secrets as <code>system:serviceaccount:prod:default</code> (200)</td>
      </tr>
      <tr>
          <td>D7</td>
          <td>2</td>
          <td>ci-deployer&rsquo;s <code>selfsubjectrulesreviews</code>; ci-deployer with 8 denials across 6 resource types</td>
      </tr>
      <tr>
          <td>D8</td>
          <td>1</td>
          <td>the anonymous request (403)</td>
      </tr>
  </tbody>
</table>
<p>Every step of both attack paths fired at least one rule. The only benign activity that fired was dev-bob&rsquo;s exec, which is the false positive you would expect: D1 is a &ldquo;who is in our workloads&rdquo; feed, not a high-severity alert on its own.</p>
<h3 id="d1-exec-attach-and-debug-sessions">D1: exec, attach and debug sessions</h3>
<p>Two details matter. First, <code>exec</code> is a long-running request, and the Kubernetes docs say <code>ResponseStarted</code> &ldquo;is only generated for long-running requests&rdquo;. Our first version of the rule returned every exec event twice, once per stage; filtering on <code>ResponseComplete</code> fixed it. Second, one <code>kubectl exec</code> produced both a <code>get</code> and a <code>create</code> on <code>pods/exec</code>, so count sessions by <code>auditID</code> or by time window, not by event.</p>
<p><code>kubectl debug</code> does not use <code>exec</code> at all. It sends a <code>patch</code> to the <code>pods/ephemeralcontainers</code> subresource, which is why the rule matches that subresource explicitly. Because it is a write, our policy logged it at <code>Request</code> level, and the event carried the debug container&rsquo;s image (<code>busybox:1.36</code>). A debug container running an image you have never seen in the cluster is worth a look.</p>
<p>To tune: alert on exec into <code>kube-system</code> and other platform namespaces, exec by service accounts (people run exec, workloads rarely should), and exec whose <code>requestURI</code> contains shells or tools you do not expect. Route the rest to a lower-severity feed.</p>
<h3 id="d2-pods-asking-for-the-host">D2: Pods asking for the host</h3>
<p>The fields come from the Pod Security Standards&rsquo; Baseline profile, which disallows host namespaces (<code>spec.hostNetwork</code>, <code>spec.hostPID</code>, <code>spec.hostIPC</code>), privileged containers and hostPath volumes. If you enforce those with <a href="/posts/kubernetes-pod-security-admission-namespace-labels-modes-and-a-safe-rollout-from-warn-to-enforce/">Pod Security Admission</a>, D2 tells you who keeps trying.</p>
<p>The useful finding from our run is that the rejected request was logged in full. The privileged Pod failed validation with HTTP 422 (&ldquo;disallowed by cluster policy&rdquo;), and the audit event still held the complete <code>requestObject</code>, including the <code>hostPath</code> of <code>/</code>. That is why D2 does not filter on success. An attacker&rsquo;s first attempt is often the one that fails.</p>
<p>D2 needs <code>Request</code> level on Pod creates; at <code>Metadata</code> the spec is not there. It also only sees Pods. Deployments, DaemonSets, Jobs and CronJobs create Pods through controllers, so the Pod create event&rsquo;s user is the controller&rsquo;s identity. Add the same <code>select</code> on <code>.requestObject.spec.template.spec</code> for workload resources to see the person who submitted the template.</p>
<h3 id="d3-bindings-to-cluster-admin">D3: bindings to cluster-admin</h3>
<p>Without the control-plane exclusion, this rule fired on startup: <code>system:apiserver</code> creates the <code>cluster-admin</code> ClusterRoleBinding to <code>system:masters</code> when it bootstraps RBAC. After excluding it, the only hit was the persistence binding.</p>
<p>The binding name <code>backup-sync</code> is deliberately bland; do not rely on names. The rule matches <code>roleRef.name</code>, which our policy logs because RBAC writes are kept at <code>RequestResponse</code>. Extend it with the other roles that are as good as cluster-admin in your cluster, and with writes to ClusterRoles that add <code>*</code> verbs or <code>escalate</code>, <code>bind</code> or <code>impersonate</code>. Our <a href="/posts/kubernetes-rbac-audit-11-permissions-that-escalate-access/">RBAC permission audit</a> lists them.</p>
<h3 id="d4-secret-sweeps">D4: Secret sweeps</h3>
<p>A cluster-wide list has no namespace in <code>objectRef</code>, which is how D4 finds it. Our policy logs Secrets at <code>Metadata</code>, so none of these events contain Secret values, and the rule does not need them: who, which verb, which scope and the response code are enough.</p>
<p>The 403s are as interesting as the 200. Two of the four hits were the stolen CI token being denied, which tells you someone is trying the credential well outside its job. Do not exclude every <code>system:</code> user here. <code>system:serviceaccount:...</code> identities are <code>system:</code> users too: had the attacker used the minted token directly instead of impersonating, the read would have been logged as <code>system:serviceaccount:prod:default</code> and a blanket filter would have dropped it. That is why the script excludes control-plane components by exact name. Some distributions run controllers under <code>kube-system</code> service accounts; add those by name after you have looked at your own log.</p>
<h3 id="d5-minted-tokens">D5: minted tokens</h3>
<p><code>kubectl create token</code> is a <code>create</code> on the <code>serviceaccounts/token</code> subresource. Our policy logs that subresource at <code>Metadata</code>, so the event recorded who asked and for which service account, but not the token. Keep it that way: at <code>RequestResponse</code> the response would contain a usable token.</p>
<p>The kubelet requests tokens on behalf of Pods that use projected service account tokens, so in a real cluster most of these events come from <code>system:node:&lt;name&gt;</code> identities. The rule drops those. What is left, a person or a workload minting a token for another service account, is rare and worth reviewing. Our <a href="/posts/kubernetes-service-account-tokens-bound-vs-legacy-secrets/">service account token guide</a> covers why bound tokens are still worth watching.</p>
<h3 id="d6-impersonation">D6: impersonation</h3>
<p>When a request uses impersonation, the audit event keeps the real caller in <code>user</code> and adds <code>impersonatedUser</code>. Here alice read <code>kube-system</code> Secrets as the service account she had just made cluster-admin. D4 fired on that read too, but only D6 tells you the request ran with the service account&rsquo;s permissions, which is what links the read back to the binding created minutes earlier. Treat D6 as context: join it to every other rule so each alert shows both the real caller and the identity whose permissions were used.</p>
<h3 id="d7-permission-discovery">D7: permission discovery</h3>
<p><code>kubectl auth can-i --list</code> sends a <code>SelfSubjectRulesReview</code>, which is logged as a create on <code>selfsubjectrulesreviews</code>. Developers run it too, so it is weak alone, but it is a strong signal from a CI or workload identity: our stolen CI token ran it first. The burst rule then found 8 denials for ci-deployer across 6 resource types.</p>
<p>The threshold of 5 per file is only for this demo. In a SIEM, count <code>forbid</code> decisions per identity over a window such as 10 minutes, and weight by the number of distinct resources: one identity hitting one forbidden resource repeatedly is usually a broken deployment; one identity hitting many is usually someone exploring.</p>
<h3 id="d8-anonymous-requests">D8: anonymous requests</h3>
<p>kube-apiserver accepts anonymous requests by default (<code>--anonymous-auth</code> defaults to true; they arrive as <code>system:anonymous</code> in group <code>system:unauthenticated</code>). RBAC denied our anonymous call, but the event is still useful: anonymous requests for resource paths from outside your network mean the API server is reachable by people scanning for it. Any anonymous request that gets a 2xx on a resource path is an incident.</p>
<h2 id="porting-the-rules-to-a-siem">Porting the rules to a SIEM</h2>
<p>The fields used here (<code>user.username</code>, <code>impersonatedUser</code>, <code>objectRef.*</code>, <code>verb</code>, <code>stage</code>, <code>responseStatus.code</code>, <code>annotations</code>, <code>requestObject</code>, <code>requestURI</code>, <code>sourceIPs</code>) are fields of the <code>audit.k8s.io/v1</code> Event object, so the logic carries over to any backend that stores the events. Managed services may wrap each event in their own envelope and may run their own audit policy, which decides whether <code>requestObject</code> is present at all. Check one raw event from your provider before you trust a rule that depends on request bodies.</p>
<p>Three practical points:</p>
<ul>
<li><strong>Deduplicate on <code>auditID</code>.</strong> It is the same across a request&rsquo;s stages.</li>
<li><strong>Alert on attempts.</strong> Keep 403 and 422 results in every rule unless you have a reason to drop them.</li>
<li><strong>Join with identity context.</strong> Mapping <code>user.username</code> and <code>sourceIPs</code> to a person, a pipeline or a node is what turns these rules from noise into findings. Our <a href="/posts/logging-for-incidents-not-for-dashboards/">incident logging guide</a> covers what to keep alongside the audit log.</li>
</ul>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>We tested on a single kube-apiserver with no nodes, controllers or scheduler, so no Pod ever ran and exec returned 400 instead of opening a session. A production log has far more control-plane and controller traffic, and your exclusion list will need tuning against it. We used static tokens; OIDC, certificates and cloud IAM identities change what <code>user.username</code> looks like. Managed Kubernetes providers control their own audit policies, and some do not log request bodies, which disables D2, D3 and the image detail in D1. These rules detect API activity only: they do not see what happens inside a container or on a node, which needs runtime or host telemetry.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Audit stages; <code>ResponseStarted</code> only for long-running requests</td>
          <td><a href="https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/">Kubernetes docs: Auditing</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>impersonatedUser</code> and other Event fields</td>
          <td><a href="https://github.com/kubernetes/apiserver/blob/master/pkg/apis/audit/v1/types.go">kubernetes/apiserver audit/v1 types.go</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Host namespaces, privileged and hostPath restrictions</td>
          <td><a href="https://kubernetes.io/docs/concepts/security/pod-security-standards/">Pod Security Standards</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Kubelet requests projected tokens on behalf of Pods</td>
          <td><a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/">Configure Service Accounts for Pods</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>--anonymous-auth</code> and <code>--allow-privileged</code> defaults</td>
          <td><code>kube-apiserver --help</code>, v1.34.1</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>ATT&amp;CK technique IDs and names</td>
          <td><a href="https://github.com/mitre-attack/attack-stix-data">MITRE ATT&amp;CK STIX data</a>, enterprise collection 20.0</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>All hits, counts and observed behaviour (exec stages, 422 logged with body, debug as ephemeralcontainers patch, bootstrap binding)</td>
          <td>Our run: kube-apiserver v1.34.1, etcd 3.6.4, jq 1.7, October 26, 2026</td>
          <td>Verified (single test environment)</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Kubernetes Audit Policy: What to Log, at What Level</title><link>https://firewallsync.com/posts/kubernetes-audit-policy-what-to-log-and-at-what-level/</link><guid>https://firewallsync.com/posts/kubernetes-audit-policy-what-to-log-and-at-what-level/</guid><pubDate>Sun, 25 Oct 2026 04:45:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>Cloud Security</category><description>A Kubernetes audit policy decides which API requests kube-apiserver records and how much of each it keeps. The first matching rule wins, so one misplaced rule can write Secret values into your logs. Here is a six-rule policy we ran against kube-apiserver v1.34.1, what each rule captured, and the jq queries that turn the log into answers.</description><enclosure url="https://firewallsync.com/posts/kubernetes-audit-policy-what-to-log-and-at-what-level/cover_hu8208247904918711124.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/kubernetes-audit-policy-what-to-log-and-at-what-level/cover_hu8208247904918711124.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/kubernetes-audit-policy-what-to-log-and-at-what-level/cover_hu8208247904918711124.jpg" alt="A worn, yellowed ledger lying open on green baize, its ruled pages covered in faded handwriting, with a white quill pen resting across them" width="1200" height="630"></p><p>A Kubernetes audit policy is the file that tells kube-apiserver which API requests to record and how much of each to keep. Without one, upstream kube-apiserver records nothing. A good policy logs everything at <code>Metadata</code> at minimum, keeps request bodies for changes that alter access, and never logs the contents of Secrets. Rule order is what makes that work: the first matching rule wins, and a broad rule placed too high will quietly write Secret values into your logs. We demonstrate that below.</p>
<p>This guide gives a six-rule policy we ran against kube-apiserver v1.34.1, shows what each rule captured, and ends with queries you can run against the log during an incident.</p>
<h2 id="how-kube-apiserver-evaluates-a-policy">How kube-apiserver evaluates a policy</h2>
<p>Every request produces audit events at one or more <strong>stages</strong>:</p>
<table>
  <thead>
      <tr>
          <th>Stage</th>
          <th>When</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>RequestReceived</code></td>
          <td>As soon as the audit handler receives the request</td>
      </tr>
      <tr>
          <td><code>ResponseStarted</code></td>
          <td>After response headers are sent, for long-running requests such as watch</td>
      </tr>
      <tr>
          <td><code>ResponseComplete</code></td>
          <td>When the response body is finished</td>
      </tr>
      <tr>
          <td><code>Panic</code></td>
          <td>When a panic occurred</td>
      </tr>
  </tbody>
</table>
<p>The policy is a list of rules. kube-apiserver compares each event with the rules <strong>in order</strong>, and the first rule that matches sets the event&rsquo;s <strong>level</strong>:</p>
<table>
  <thead>
      <tr>
          <th>Level</th>
          <th>What gets recorded</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>None</code></td>
          <td>Nothing</td>
      </tr>
      <tr>
          <td><code>Metadata</code></td>
          <td>User, groups, source IPs, user agent, verb, resource, namespace, name, response status, timestamps, annotations</td>
      </tr>
      <tr>
          <td><code>Request</code></td>
          <td>Metadata plus the request body</td>
      </tr>
      <tr>
          <td><code>RequestResponse</code></td>
          <td>Metadata plus request and response bodies</td>
      </tr>
  </tbody>
</table>
<p><code>Request</code> and <code>RequestResponse</code> don&rsquo;t apply to non-resource URLs such as <code>/healthz</code>. A rule can match on <code>users</code>, <code>userGroups</code>, <code>verbs</code>, <code>resources</code> (group, resource, subresource and <code>resourceNames</code>), <code>namespaces</code> and <code>nonResourceURLs</code>. A rule with no match fields matches everything, which is what you want for the last rule and exactly what you don&rsquo;t want near the top.</p>
<p>The policy file is passed with <code>--audit-policy-file</code>. If the flag is omitted, no events are logged, and a policy with no rules is rejected.</p>
<h2 id="a-policy-that-answers-incident-questions">A policy that answers incident questions</h2>
<p>The policy below is built around the questions you get asked during an incident: who read this Secret, who exec&rsquo;d into that Pod, who granted this binding, and was it allowed. It is a starting point, not a complete production policy; see the limits at the end.</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">audit.k8s.io/v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">Policy</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">omitStages</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#e6db74">&#34;RequestReceived&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">rules</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># 1. Drop health and version probes.</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">level</span>: <span style="color:#ae81ff">None</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">nonResourceURLs</span>: [<span style="color:#e6db74">&#34;/healthz*&#34;</span>, <span style="color:#e6db74">&#34;/livez*&#34;</span>, <span style="color:#e6db74">&#34;/readyz*&#34;</span>, <span style="color:#e6db74">&#34;/version&#34;</span>]
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># 2. Sensitive payloads: who touched them, never the contents.</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">level</span>: <span style="color:#ae81ff">Metadata</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">resources</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">group</span>: <span style="color:#e6db74">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">resources</span>: [<span style="color:#e6db74">&#34;secrets&#34;</span>, <span style="color:#e6db74">&#34;configmaps&#34;</span>, <span style="color:#e6db74">&#34;serviceaccounts/token&#34;</span>]
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">group</span>: <span style="color:#e6db74">&#34;authentication.k8s.io&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">resources</span>: [<span style="color:#e6db74">&#34;tokenreviews&#34;</span>]
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># 3. Interactive access into running workloads.</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">level</span>: <span style="color:#ae81ff">Metadata</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">resources</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">group</span>: <span style="color:#e6db74">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">resources</span>: [<span style="color:#e6db74">&#34;pods/exec&#34;</span>, <span style="color:#e6db74">&#34;pods/attach&#34;</span>, <span style="color:#e6db74">&#34;pods/portforward&#34;</span>, <span style="color:#e6db74">&#34;pods/proxy&#34;</span>, <span style="color:#e6db74">&#34;nodes/proxy&#34;</span>]
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># 4. Changes to who can do what, and to admission control.</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">level</span>: <span style="color:#ae81ff">RequestResponse</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">verbs</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;delete&#34;</span>, <span style="color:#e6db74">&#34;deletecollection&#34;</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">resources</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">group</span>: <span style="color:#e6db74">&#34;rbac.authorization.k8s.io&#34;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">group</span>: <span style="color:#e6db74">&#34;admissionregistration.k8s.io&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># 5. Other writes: keep the submitted object.</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">level</span>: <span style="color:#ae81ff">Request</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">verbs</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;delete&#34;</span>, <span style="color:#e6db74">&#34;deletecollection&#34;</span>]
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># 6. Everything else (reads, watches, non-resource URLs).</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">level</span>: <span style="color:#ae81ff">Metadata</span>
</span></span></code></pre></div><p>Why each rule is where it is:</p>
<table>
  <thead>
      <tr>
          <th>Rule</th>
          <th>Level</th>
          <th>Reason</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1. Probes</td>
          <td>None</td>
          <td>High volume, no investigative value. Leaving out <code>RequestReceived</code> globally also halves events for most requests, since <code>ResponseComplete</code> carries the same metadata plus the result.</td>
      </tr>
      <tr>
          <td>2. Secrets, ConfigMaps, TokenRequest, TokenReview</td>
          <td>Metadata</td>
          <td>Their bodies hold credentials and tokens. The upstream GCE policy treats them the same way, noting they &ldquo;can contain sensitive &amp; binary data&rdquo;. It must come before any rule that logs bodies.</td>
      </tr>
      <tr>
          <td>3. exec, attach, port-forward, proxy</td>
          <td>Metadata</td>
          <td>These are how people get inside workloads. The body adds nothing, but you always want the event.</td>
      </tr>
      <tr>
          <td>4. RBAC and admission configuration</td>
          <td>RequestResponse</td>
          <td>You need the exact subjects, <code>roleRef</code>, rules and webhook targets that were written, and the response confirms what was stored. Listing a group with no <code>resources</code> matches every resource in it.</td>
      </tr>
      <tr>
          <td>5. Other writes</td>
          <td>Request</td>
          <td>Keeps the object that was submitted, such as a Pod spec with a privileged container or a hostPath mount.</td>
      </tr>
      <tr>
          <td>6. Catch-all</td>
          <td>Metadata</td>
          <td>Every read and watch is still attributable to someone.</td>
      </tr>
  </tbody>
</table>
<p>Pod Security Admission and our <a href="/posts/kubernetes-rbac-audit-11-permissions-that-escalate-access/">RBAC permission audit</a> tell you what <em>could</em> happen. The audit log is how you find out what did.</p>
<h2 id="what-it-recorded">What it recorded</h2>
<p>We ran the policy on kube-apiserver v1.34.1 (the envtest build from the kubernetes-sigs/controller-tools releases) with etcd and RBAC authorization, then made requests as a <code>system:masters</code> user and as an unprivileged user <code>alice</code>. There were no nodes, so no Pod actually ran. Results:</p>
<table>
  <thead>
      <tr>
          <th>Action</th>
          <th>Level logged</th>
          <th>What the event held</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>kubectl create secret generic db-creds --from-literal=password=...</code></td>
          <td>Metadata</td>
          <td>User, namespace, name, <code>201</code>. No request body; the value appeared nowhere in the log.</td>
      </tr>
      <tr>
          <td><code>kubectl create rolebinding alice-view --clusterrole=view --user=alice</code></td>
          <td>RequestResponse</td>
          <td>Full <code>requestObject</code> and <code>responseObject</code>, including the subject <code>alice</code> and <code>roleRef</code> <code>view</code>.</td>
      </tr>
      <tr>
          <td><code>alice</code> lists Secrets in <code>kube-system</code></td>
          <td>Metadata</td>
          <td><code>403</code>, annotation <code>authorization.k8s.io/decision: forbid</code></td>
      </tr>
      <tr>
          <td>Admin runs <code>kubectl --as alice get pods</code></td>
          <td>Metadata</td>
          <td><code>user.username: admin</code> and <code>impersonatedUser.username: alice</code> in the same event</td>
      </tr>
      <tr>
          <td><code>kubectl create deployment web --image=nginx:1.27</code></td>
          <td>Request</td>
          <td><code>requestObject</code> with the Deployment spec, no response body</td>
      </tr>
      <tr>
          <td><code>kubectl create token ci-bot</code></td>
          <td>Metadata</td>
          <td>Resource <code>serviceaccounts</code>, subresource <code>token</code>, name <code>ci-bot</code>, no token in the log</td>
      </tr>
      <tr>
          <td><code>kubectl exec shell -- cat /etc/shadow</code></td>
          <td>Metadata</td>
          <td><code>requestURI</code> of <code>/api/v1/namespaces/default/pods/shell/exec?command=cat&amp;command=%2Fetc%2Fshadow&amp;...</code></td>
      </tr>
  </tbody>
</table>
<p>Two details from the test are worth building detections around:</p>
<ul>
<li><strong>Exec shows up under more than one verb.</strong> That single <code>kubectl exec</code> produced events with verb <code>get</code> and verb <code>create</code> for the <code>exec</code> subresource. Filter on <code>objectRef.subresource == &quot;exec&quot;</code>, not on the verb.</li>
<li><strong>Impersonation keeps both identities.</strong> The authenticated user is in <code>user</code> and the identity they acted as is in <code>impersonatedUser</code>. If you only alert on <code>user.username</code>, impersonated actions look like they came from the admin&rsquo;s own account.</li>
</ul>
<p>Also keep in mind what the API reference warns about two fields: <code>userAgent</code> is reported by the client &ldquo;and must not be trusted&rdquo;, and in <code>sourceIPs</code> all entries except the last &ldquo;can be arbitrarily set by the client&rdquo;.</p>
<h2 id="the-rule-order-mistake-that-leaks-secrets">The rule-order mistake that leaks Secrets</h2>
<p>We then moved rule 5 (Request for all writes) above rule 2, restarted kube-apiserver with that policy, and created a Secret with the value <code>S3cretValue-456</code>. The audit log now contained:</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-text" data-lang="text"><span style="display:flex;"><span>&#34;data&#34;:{&#34;key&#34;:&#34;UzNjcmV0VmFsdWUtNDU2&#34;}
</span></span></code></pre></div><p>That is the base64 encoding of <code>S3cretValue-456</code>. The broad write rule matched first, logged the request body at <code>Request</code> level, and the Metadata rule for Secrets was never reached. With the correct order, the equivalent test (a Secret holding <code>S3cretValue-123</code>) left no trace of either the plain or the encoded value in the log.</p>
<p>This is the most common way an audit log becomes a credential store. Your audit pipeline usually has wider read access than the cluster&rsquo;s Secrets do, so treat a body-logging rule above your Secrets rule as a bug. Check it in code review with a simple test: create a dummy Secret in a test cluster and search the log for its encoded value.</p>
<p>If you already store <a href="/posts/kubernetes-secrets-encryption-at-rest-what-it-protects/">Secrets encrypted at rest</a>, remember that encryption protects etcd, not the audit log. And a <a href="/posts/kubernetes-service-account-tokens-bound-vs-legacy-secrets/">Secret-based service account token</a> leaked this way never expires.</p>
<h2 id="backends-and-volume">Backends and volume</h2>
<p>There are two backends: a log file and a webhook.</p>
<ul>
<li><strong>Log backend:</strong> <code>--audit-log-path</code> sets the file (<code>-</code> means stdout). Rotation is controlled by <code>--audit-log-maxage</code> (days), <code>--audit-log-maxbackup</code> (files) and <code>--audit-log-maxsize</code> (megabytes). Batching is off by default for the log backend.</li>
<li><strong>Webhook backend:</strong> <code>--audit-webhook-config-file</code> points at a kubeconfig-format file for the remote endpoint. Batching is on by default, with a maximum of 400 events per batch and a 30-second maximum wait.</li>
<li><strong>Truncation</strong> of oversized events is off by default for both backends and has to be enabled explicitly.</li>
</ul>
<p>The Kubernetes docs also note that auditing increases API server memory use, because some context is kept for each request, and that the increase depends on the policy. <code>RequestResponse</code> on a busy resource is where cost grows: the GCE reference policy, for instance, logs <code>get</code>, <code>list</code> and <code>watch</code> at <code>Request</code> level rather than <code>RequestResponse</code> because &ldquo;Get responses can be large&rdquo;.</p>
<p>Ship the log off the control plane. An attacker with control-plane access can edit a local file, and an <a href="/posts/logging-for-incidents-not-for-dashboards/">incident log is only useful</a> if it survives the incident.</p>
<h2 id="managed-kubernetes">Managed Kubernetes</h2>
<p>On EKS, AKS and similar services the provider runs kube-apiserver, so you generally choose whether audit events are exported, not what the policy contains. Check your provider&rsquo;s documentation for what its policy logs.</p>
<ul>
<li><strong>Amazon EKS:</strong> control plane logs are not sent to CloudWatch Logs by default. Enable the <code>audit</code> log type per cluster, for example with <code>aws eks update-cluster-config --logging</code>. The audit stream is named <code>kube-apiserver-audit-&lt;id&gt;</code> in the <code>/aws/eks/&lt;cluster&gt;/cluster</code> log group. AWS notes delivery is best effort and standard CloudWatch Logs charges apply.</li>
<li><strong>Azure AKS:</strong> resource logs are not collected until you create a diagnostic setting. Microsoft&rsquo;s guidance warns that <code>kube-audit</code> can be costly and suggests <code>kube-audit-admin</code>, which leaves out <code>get</code> and <code>list</code> events. That saves money but drops exactly the read events, such as Secret reads, that matter in a credential-theft investigation. Decide that trade-off deliberately.</li>
</ul>
<h2 id="queries-to-keep-ready">Queries to keep ready</h2>
<p>These run against a JSON-lines audit log with <code>jq</code>. We ran each one against the log from the test above.</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><span style="color:#75715e"># Who read or changed Secrets (excluding the API server itself)</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#39;select(.objectRef.resource==&#34;secrets&#34; and .user.username!=&#34;system:apiserver&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {ts:.requestReceivedTimestamp, user:.user.username, verb, ns:.objectRef.namespace,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     name:.objectRef.name, code:.responseStatus.code}&#39;</span> audit.log
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Every exec into a Pod, with the command line</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#39;select(.objectRef.subresource==&#34;exec&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {ts:.requestReceivedTimestamp, user:.user.username, ns:.objectRef.namespace,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     pod:.objectRef.name, uri:.requestURI}&#39;</span> audit.log
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Denied requests, including impersonated ones</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#39;select(.annotations[&#34;authorization.k8s.io/decision&#34;]==&#34;forbid&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, as:.impersonatedUser.username, verb,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     res:.objectRef.resource, ns:.objectRef.namespace}&#39;</span> audit.log
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># RBAC writes by people rather than the control plane, with what they granted</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#39;select(.objectRef.apiGroup==&#34;rbac.authorization.k8s.io&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">       and (.verb|test(&#34;create|update|patch|delete&#34;))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">       and (.user.username|startswith(&#34;system:&#34;)|not))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, verb, res:.objectRef.resource, name:.objectRef.name,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     subjects:.requestObject.subjects, role:.requestObject.roleRef.name}&#39;</span> audit.log
</span></span></code></pre></div><p>A burst of <code>forbid</code> events from one identity across many resources can be a sign that someone is enumerating what a stolen credential can do.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>We tested on a single kube-apiserver v1.34.1 with no nodes, no controller manager and static token authentication, so no workload actually ran and no kubelet or controller traffic was present. A real cluster produces far more events, and you will want extra <code>None</code> or <code>Metadata</code> rules for high-volume system traffic (the GCE policy is a good reference). The policy does not cover custom resources that hold credentials; add those to rule 2. <code>Request</code> level on Pod writes will log any credentials placed directly in environment variables. We did not test the webhook backend or any managed service.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Stages, levels, first-match evaluation, no logging without <code>--audit-policy-file</code>, zero-rule policy rejected, log and webhook flags, batching defaults, truncation off by default, memory note</td>
          <td><a href="https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/">Kubernetes docs: Auditing</a> (<a href="https://github.com/kubernetes/website/blob/main/content/en/docs/tasks/debug/debug-cluster/audit.md">source</a>)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Policy fields, <code>sourceIPs</code> and <code>userAgent</code> trust warnings, empty <code>resources</code> matches whole group</td>
          <td><a href="https://github.com/kubernetes/apiserver/blob/master/pkg/apis/audit/v1/types.go">audit.k8s.io/v1 types</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Secrets, ConfigMaps, TokenRequest and TokenReviews logged at Metadata; &ldquo;Get responses can be large&rdquo;</td>
          <td><a href="https://github.com/kubernetes/kubernetes/blob/master/cluster/gce/gci/configure-helper.sh">GCE configure-helper.sh audit policy</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>authorization.k8s.io/decision</code> annotation</td>
          <td><a href="https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/pkg/endpoints/filters/authorization.go">kube-apiserver authorization filter</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Test results: Metadata Secret events without values, RequestResponse RBAC bodies, forbid annotation, impersonatedUser, exec URI and verbs, Secret value leaked with wrong rule order</td>
          <td>Our test on kube-apiserver v1.34.1 (envtest build)</td>
          <td>Verified (single test environment)</td>
      </tr>
      <tr>
          <td>EKS audit log off by default, stream naming, best-effort delivery, CloudWatch charges</td>
          <td><a href="https://docs.aws.amazon.com/eks/latest/userguide/control-plane-logs.html">Amazon EKS: Send control plane logs to CloudWatch Logs</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>AKS <code>kube-audit-admin</code> excludes get and list; logs need a diagnostic setting</td>
          <td><a href="https://docs.azure.cn/en-us/aks/monitor-apiserver">Microsoft: Monitor the AKS API server</a> (Azure China documentation mirror)</td>
          <td>Qualified</td>
      </tr>
      <tr>
          <td>Managed providers control the audit policy</td>
          <td>General provider architecture; not stated in one primary source</td>
          <td>Qualified</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><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 Secrets Encryption at Rest: What It Protects</title><link>https://firewallsync.com/posts/kubernetes-secrets-encryption-at-rest-what-it-protects/</link><guid>https://firewallsync.com/posts/kubernetes-secrets-encryption-at-rest-what-it-protects/</guid><pubDate>Fri, 23 Oct 2026 04:45:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>Cloud Security</category><description>Kubernetes stores Secrets unencrypted in etcd unless you configure an EncryptionConfiguration. Turning it on protects etcd data, but not against anyone who can read Secrets through the API, create Pods, or, with local keys, read the control plane host. This guide maps each threat to the control that actually stops it, with a tested config checker.</description><enclosure url="https://firewallsync.com/posts/kubernetes-secrets-encryption-at-rest-what-it-protects/cover_hu14419892505578868447.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/kubernetes-secrets-encryption-at-rest-what-it-protects/cover_hu14419892505578868447.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/kubernetes-secrets-encryption-at-rest-what-it-protects/cover_hu14419892505578868447.jpg" alt="Rows of ornate brass deposit boxes with keyholes and numbers 564 to 624, viewed at an angle" width="1200" height="630"></p><p>Kubernetes encryption at rest protects one thing: the Secret data stored in etcd. It stops someone who gets etcd&rsquo;s data, or a copy of it, from reading Secrets in plain text. It does not stop anyone the API server is willing to serve, and that group is larger than most teams assume: everyone with <code>get</code>, <code>list</code> or <code>watch</code> on Secrets, and everyone who can create a Pod in the namespace. Turn encryption on, but don&rsquo;t count it as your Secrets access control.</p>
<h2 id="what-kubernetes-does-by-default">What Kubernetes does by default</h2>
<p>The Kubernetes documentation is blunt: &ldquo;Kubernetes Secrets are, by default, stored unencrypted in the API server&rsquo;s underlying data store (etcd).&rdquo; The default provider is <code>identity</code>, which, in the docs&rsquo; words, &ldquo;does not encrypt stored data and provides <em>no</em> additional confidentiality protection.&rdquo;</p>
<p>The base64 you see in a Secret manifest doesn&rsquo;t change that. The good-practices page says it plainly: &ldquo;Base64 encoding is <em>not</em> an encryption method, it provides no additional confidentiality over plain text.&rdquo; A Secret manifest committed to Git is a plain-text credential in Git.</p>
<p>Managed Kubernetes services run the control plane for you, and their defaults may differ from upstream. This article describes upstream behavior; check your provider&rsquo;s documentation for what it encrypts by default and whether it supports envelope encryption with your own key.</p>
<h2 id="who-can-read-a-secret">Who can read a Secret</h2>
<p>Before choosing an encryption provider, list who can read Secrets at all. Encryption at rest changes only the last two rows of this table.</p>
<table>
  <thead>
      <tr>
          <th>Who</th>
          <th>How they read Secrets</th>
          <th>Does encryption at rest stop it?</th>
          <th>Control that does</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A user or service account with <code>get</code> on Secrets</td>
          <td>Asks the API server</td>
          <td>No; the API server decrypts for them</td>
          <td>Least-privilege RBAC</td>
      </tr>
      <tr>
          <td>A subject with <code>list</code> or <code>watch</code> on Secrets</td>
          <td>A list response includes the contents of every Secret returned</td>
          <td>No</td>
          <td>Restrict <code>list</code>/<code>watch</code> to system components</td>
      </tr>
      <tr>
          <td>Anyone who can create Pods, Deployments or other workloads in the namespace</td>
          <td>Mounts any Secret in that namespace into a Pod they control</td>
          <td>No</td>
          <td>Separate namespaces by trust; RBAC on workload creation</td>
      </tr>
      <tr>
          <td>Someone with access to etcd, its disks or a copy of its data</td>
          <td>Reads stored bytes directly</td>
          <td><strong>Yes</strong></td>
          <td>Encryption at rest; restrict etcd access</td>
      </tr>
      <tr>
          <td>Someone with root on a control plane host</td>
          <td>Reads etcd data <em>and</em> the local key file</td>
          <td>Only with KMS</td>
          <td>KMS provider; host hardening</td>
      </tr>
  </tbody>
</table>
<p>The middle row surprises people. The Secret documentation says &ldquo;anyone who is authorized to create a Pod in a namespace can use that access to read any Secret in that namespace; this includes indirect access such as the ability to create a Deployment.&rdquo; Our <a href="/posts/kubernetes-rbac-audit-11-permissions-that-escalate-access/">Kubernetes RBAC audit</a> covers how to find these grants in your bindings; this article stays on the storage side.</p>
<h2 id="how-encryption-at-rest-is-configured">How encryption at rest is configured</h2>
<p>You pass an <code>EncryptionConfiguration</code> file to <code>kube-apiserver</code> with <code>--encryption-provider-config</code>. A minimal example for Secrets with a local AES-CBC key, shaped like the one in the Kubernetes task page:</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">apiserver.config.k8s.io/v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">EncryptionConfiguration</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">resources</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">resources</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">secrets</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">providers</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">aescbc</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">keys</span>:
</span></span><span style="display:flex;"><span>            - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">key1</span>
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">secret</span>: <span style="color:#ae81ff">&lt;BASE64-ENCODED 32-BYTE KEY&gt;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">identity</span>: {} <span style="color:#75715e"># lets the API server read Secrets written before encryption</span>
</span></span></code></pre></div><p>Three rules from the documentation decide what this file does:</p>
<ul>
<li><strong>The first provider encrypts.</strong> &ldquo;The first provider in the list is used to encrypt resources written into the storage.&rdquo; If <code>identity</code> is first, new writes are plain text even if other providers are listed.</li>
<li><strong>Later providers only decrypt.</strong> On reads, each matching provider is tried in order. The trailing <code>identity</code> is what lets the API server keep reading old plain-text Secrets during a migration. Once everything is rewritten, you can remove it.</li>
<li><strong>Order across rules matters too.</strong> The <code>resources</code> list is processed in order, so an earlier rule that names <code>secrets</code> with <code>identity</code> will win over a later <code>*.*</code> wildcard that encrypts. Wildcards are <code>*.</code> for the core group (which includes Secrets) and <code>*.*</code> for everything.</li>
</ul>
<h2 id="which-provider-stops-which-attacker">Which provider stops which attacker</h2>
<table>
  <thead>
      <tr>
          <th>Provider</th>
          <th>Key location</th>
          <th>Kubernetes docs say</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>identity</code></td>
          <td>None</td>
          <td>No encryption; the default</td>
      </tr>
      <tr>
          <td><code>aescbc</code></td>
          <td>Local file on each control plane host</td>
          <td>Not recommended: CBC is vulnerable to padding oracle attacks</td>
      </tr>
      <tr>
          <td><code>aesgcm</code></td>
          <td>Local file</td>
          <td>Not recommended unless you automate key rotation</td>
      </tr>
      <tr>
          <td><code>secretbox</code></td>
          <td>Local file</td>
          <td>Newer cryptography that may not pass high-review environments</td>
      </tr>
      <tr>
          <td><code>kms</code> v1</td>
          <td>External KMS</td>
          <td>Deprecated since Kubernetes v1.28</td>
      </tr>
      <tr>
          <td><code>kms</code> v2</td>
          <td>External KMS</td>
          <td>Strongest; envelope encryption with AES-GCM data keys; stable from v1.29</td>
      </tr>
  </tbody>
</table>
<p>The key-location column is the one that maps to threats. With a local key, the docs say encryption &ldquo;protects against an etcd compromise, but it fails to protect against a host compromise,&rdquo; because the key sits in the configuration file on the host. With KMS, Kubernetes encrypts data with a data encryption key and the KMS wraps that key, so an attacker &ldquo;would need to compromise etcd <strong>and</strong> the third-party KMS provider.&rdquo;</p>
<p>A local key is still worth having. It turns a leaked etcd snapshot or a mis-scoped etcd client certificate into ciphertext rather than credentials. Our view: use KMS v2 where you have a KMS to point at, and treat a local <code>aescbc</code> key as a stopgap you plan to replace.</p>
<h2 id="checking-a-configuration-file">Checking a configuration file</h2>
<p>We wrote a short checker that reads an <code>EncryptionConfiguration</code> and flags the weak settings above. It reads only the file and does not contact a cluster. It needs Python 3 and PyYAML; we ran it with Python 3 and PyYAML 6.0.3 against three sample files. It is illustrative: it does not validate the whole schema, and it reads <code>kms</code> without an <code>apiVersion</code> as v1.</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;Flag weak settings in a Kubernetes EncryptionConfiguration file.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">Usage: python3 check_encryption_config.py /path/to/encryption-config.yaml
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">Reads the file only; it does not contact a cluster.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> sys
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> yaml
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>LOCAL_KEY <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;aescbc&#34;</span>, <span style="color:#e6db74">&#34;aesgcm&#34;</span>, <span style="color:#e6db74">&#34;secretbox&#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">covers_secrets</span>(resources):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> any(r <span style="color:#f92672">in</span> (<span style="color:#e6db74">&#34;secrets&#34;</span>, <span style="color:#e6db74">&#34;*.&#34;</span>, <span style="color:#e6db74">&#34;*.*&#34;</span>) <span style="color:#66d9ef">for</span> r <span style="color:#f92672">in</span> resources)
</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>    <span style="color:#66d9ef">with</span> open(path) <span style="color:#66d9ef">as</span> f:
</span></span><span style="display:flex;"><span>        cfg <span style="color:#f92672">=</span> yaml<span style="color:#f92672">.</span>safe_load(f)
</span></span><span style="display:flex;"><span>    findings <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    secrets_rule_seen <span style="color:#f92672">=</span> <span style="color:#66d9ef">False</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i, rule <span style="color:#f92672">in</span> enumerate(cfg<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;resources&#34;</span>, [])):
</span></span><span style="display:flex;"><span>        res <span style="color:#f92672">=</span> rule<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;resources&#34;</span>, [])
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> covers_secrets(res):
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># The first matching rule wins, so only the first rule covering secrets counts.</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> secrets_rule_seen:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>        secrets_rule_seen <span style="color:#f92672">=</span> <span style="color:#66d9ef">True</span>
</span></span><span style="display:flex;"><span>        providers <span style="color:#f92672">=</span> rule<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;providers&#34;</span>, [])
</span></span><span style="display:flex;"><span>        first <span style="color:#f92672">=</span> next(iter(providers[<span style="color:#ae81ff">0</span>])) <span style="color:#66d9ef">if</span> providers <span style="color:#66d9ef">else</span> <span style="color:#66d9ef">None</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> first <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;identity&#34;</span>:
</span></span><span style="display:flex;"><span>            findings<span style="color:#f92672">.</span>append(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;rule </span><span style="color:#e6db74">{</span>i<span style="color:#e6db74">}</span><span style="color:#e6db74">: first provider is identity, so new Secrets are written in plain text&#34;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">elif</span> first <span style="color:#f92672">in</span> LOCAL_KEY:
</span></span><span style="display:flex;"><span>            findings<span style="color:#f92672">.</span>append(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;rule </span><span style="color:#e6db74">{</span>i<span style="color:#e6db74">}</span><span style="color:#e6db74">: first provider is </span><span style="color:#e6db74">{</span>first<span style="color:#e6db74">}</span><span style="color:#e6db74">; key sits in this file on every control plane host&#34;</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> first <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;aescbc&#34;</span>:
</span></span><span style="display:flex;"><span>                findings<span style="color:#f92672">.</span>append(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;rule </span><span style="color:#e6db74">{</span>i<span style="color:#e6db74">}</span><span style="color:#e6db74">: aescbc is not recommended by the Kubernetes docs (padding oracle)&#34;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">elif</span> first <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;kms&#34;</span>:
</span></span><span style="display:flex;"><span>            ver <span style="color:#f92672">=</span> providers[<span style="color:#ae81ff">0</span>][<span style="color:#e6db74">&#34;kms&#34;</span>]<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;apiVersion&#34;</span>, <span style="color:#e6db74">&#34;v1&#34;</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> ver <span style="color:#f92672">!=</span> <span style="color:#e6db74">&#34;v2&#34;</span>:
</span></span><span style="display:flex;"><span>                findings<span style="color:#f92672">.</span>append(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;rule </span><span style="color:#e6db74">{</span>i<span style="color:#e6db74">}</span><span style="color:#e6db74">: KMS v1 is deprecated since Kubernetes v1.28; move to KMS v2&#34;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> any(<span style="color:#e6db74">&#34;identity&#34;</span> <span style="color:#f92672">in</span> p <span style="color:#66d9ef">for</span> p <span style="color:#f92672">in</span> providers[<span style="color:#ae81ff">1</span>:]):
</span></span><span style="display:flex;"><span>            findings<span style="color:#f92672">.</span>append(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;rule </span><span style="color:#e6db74">{</span>i<span style="color:#e6db74">}</span><span style="color:#e6db74">: identity fallback present; unencrypted Secrets can still be read (expected during migration)&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> secrets_rule_seen:
</span></span><span style="display:flex;"><span>        findings<span style="color:#f92672">.</span>append(<span style="color:#e6db74">&#34;no rule covers secrets: Secrets are stored unencrypted&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> f_ <span style="color:#f92672">in</span> findings:
</span></span><span style="display:flex;"><span>        print(<span style="color:#e6db74">&#34;WARN&#34;</span>, f_)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> findings:
</span></span><span style="display:flex;"><span>        print(<span style="color:#e6db74">&#34;OK no weak settings found for secrets&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#ae81ff">1</span> <span style="color:#66d9ef">if</span> findings <span style="color:#66d9ef">else</span> <span style="color:#ae81ff">0</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>    sys<span style="color:#f92672">.</span>exit(main(sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">1</span>]))
</span></span></code></pre></div><p>Results on our samples: the <code>aescbc</code>-plus-<code>identity</code> file above produced three warnings and exit code 1; a file using <code>kms</code> with <code>apiVersion: v2</code> printed <code>OK</code> and exited 0; a file that only configured ConfigMaps reported that no rule covers Secrets. The non-zero exit code lets you run it in CI against the configuration you deploy to control plane nodes.</p>
<h2 id="rolling-it-out-without-locking-yourself-out">Rolling it out without locking yourself out</h2>
<p>The Kubernetes task page walks through the full procedure. The steps that most often go wrong:</p>
<ol>
<li>
<p><strong>Every control plane host needs the same file and key.</strong> The docs say to copy the key to every control plane host over a secure channel, and to restart each API server. An API server without the key can&rsquo;t read Secrets another one encrypted.</p>
</li>
<li>
<p><strong>Existing Secrets are not touched.</strong> Encryption applies on write. Rewrite everything once all API servers have the new configuration:</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-shell" data-lang="shell"><span style="display:flex;"><span>kubectl get secrets --all-namespaces -o json | kubectl replace -f -
</span></span></code></pre></div></li>
<li>
<p><strong>Verify in etcd, not through the API.</strong> The API always returns decrypted values, so <code>kubectl get secret</code> proves nothing. Read a Secret with <code>etcdctl get /registry/secrets/&lt;namespace&gt;/&lt;name&gt;</code> and check that the stored value starts with <code>k8s:enc:&lt;provider&gt;:v1:&lt;key-name&gt;:</code>, for example <code>k8s:enc:aescbc:v1:key1:</code>.</p>
</li>
<li>
<p><strong>Rotate keys in two passes.</strong> The docs&rsquo; zero-downtime procedure: add the new key as the <em>second</em> entry on every control plane node and restart all API servers so each can decrypt with it; back the key up; then make it the first entry, restart again, rewrite all Secrets, and only then remove the old key. Losing every copy of a key means deleting the resources encrypted with it.</p>
</li>
</ol>
<h2 id="controls-that-matter-as-much-as-encryption">Controls that matter as much as encryption</h2>
<p>Encryption at rest is one line in the Kubernetes Secrets good-practices list. The others address the readers in the first table:</p>
<ul>
<li><strong>Restrict <code>get</code>, <code>list</code> and <code>watch</code>.</strong> The docs say to limit <code>watch</code> and <code>list</code> to &ldquo;the most privileged, system-level components&rdquo; and caution that <code>list</code> &ldquo;implicitly lets the subject fetch the contents of the Secrets.&rdquo;</li>
<li><strong>Separate namespaces by trust.</strong> Pod creation reaches every Secret in a namespace, so a namespace is the practical boundary. Enforce <a href="/posts/kubernetes-pod-security-admission-namespace-labels-modes-and-a-safe-rollout-from-warn-to-enforce/">Pod Security Admission</a> where workload creators are less trusted, and consider <a href="/posts/kubernetes-validatingadmissionpolicy-cel-rules-for-registry-allowlists-and-memory-limits-with-a-warn-first-rollout/">ValidatingAdmissionPolicy</a> for rules Pod Security doesn&rsquo;t cover.</li>
<li><strong>Audit Secret access at the Metadata level.</strong> The example audit policy in the Kubernetes docs logs Secrets and ConfigMaps at <code>Metadata</code>, which records who accessed what without writing values into your logs. The good-practices page suggests alerting on events such as one user reading many Secrets at once.</li>
<li><strong>Use short-lived credentials and external stores.</strong> The docs recommend short-lived Secrets and mention the Secrets Store CSI Driver for keeping values in an external store. The same reasoning drives <a href="/posts/aws-iam-least-privilege-a-practical-guide-to-finding-excess-permissions/">least privilege in cloud IAM</a>: a credential that expires in an hour is worth less to whoever reads it.</li>
</ul>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>This describes upstream Kubernetes as documented on kubernetes.io at the time of writing. We tested the checker against sample files only, not against a live cluster or a managed service. We have not tested the encryption rollout procedure ourselves; follow the Kubernetes task page and your distribution&rsquo;s documentation, and rehearse on a non-production cluster first.</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>Secrets stored unencrypted in etcd by default; Pod creators can read any Secret in the namespace</td>
          <td><a href="https://kubernetes.io/docs/concepts/configuration/secret/">Kubernetes: Secrets</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Base64 is not encryption; list access reveals contents; short-lived Secrets, audit alerting, Secrets Store CSI Driver</td>
          <td><a href="https://kubernetes.io/docs/concepts/security/secrets-good-practices/">Kubernetes: Good practices for Kubernetes Secrets</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>list and watch reveal contents; workload creation grants namespace Secret access</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>identity default; first provider encrypts; rule ordering; provider ratings; local vs KMS key threat model; KMS v1 deprecated v1.28; KMS v2 stable v1.29; rewrite command; k8s:enc: prefix; key rotation steps</td>
          <td><a href="https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/">Kubernetes: Encrypting confidential data at rest</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Example audit policy logs Secrets and ConfigMaps at Metadata level</td>
          <td><a href="https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/">Kubernetes: Auditing</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>A local key turns a leaked etcd copy into ciphertext</td>
          <td>Editorial inference from the encrypt-data page&rsquo;s statement that local keys protect against etcd compromise</td>
          <td>Qualified</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><item><title>Kubernetes NetworkPolicy Default Deny: A Safe Rollout</title><link>https://firewallsync.com/posts/kubernetes-networkpolicy-default-deny-safe-rollout/</link><guid>https://firewallsync.com/posts/kubernetes-networkpolicy-default-deny-safe-rollout/</guid><pubDate>Thu, 22 Oct 2026 18:42:00 +0000</pubDate><dc:creator>John Marshall</dc:creator><category>Cloud Security</category><description>Kubernetes allows all pod traffic until a NetworkPolicy selects a pod. A default-deny policy flips that, but it also blocks DNS, needs allow rules on both ends of every connection and does nothing without a network plugin that enforces it. Here is how the rules combine and an order to roll it out without an outage.</description><enclosure url="https://firewallsync.com/posts/kubernetes-networkpolicy-default-deny-safe-rollout/cover_hu4373152309049702664.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/kubernetes-networkpolicy-default-deny-safe-rollout/cover_hu4373152309049702664.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/kubernetes-networkpolicy-default-deny-safe-rollout/cover_hu4373152309049702664.jpg" alt="Close-up of black Ethernet cables plugged into lit ports on the side of a grey network switch" width="1200" height="630"></p><p>In a Kubernetes cluster with no NetworkPolicies, every pod can talk to every other pod and to the internet. A default-deny policy changes that for one namespace: nothing gets in or out unless a policy allows it. It is one of the most effective controls against lateral movement after a pod is compromised, and one of the easiest to get wrong, because it silently depends on your network plugin, breaks DNS, and needs allow rules on both ends of every connection.</p>
<p><strong>Scope.</strong> This guide is based on the Kubernetes documentation for v1.37 (the &ldquo;Network Policies&rdquo; concept page and its example manifests in the kubernetes/website repository, read on October 22, 2026). The manifests below passed schema validation with kubeconform 0.6.7 in strict mode against the Kubernetes 1.34 schema. We did not apply them to a running cluster, and behavior can vary by network plugin, as the documentation itself notes.</p>
<h2 id="how-isolation-works">How isolation works</h2>
<p>The documentation describes two independent kinds of isolation for a pod, ingress and egress:</p>
<ul>
<li><strong>By default a pod is non-isolated</strong> in both directions: all inbound and outbound connections are allowed.</li>
<li><strong>A pod becomes isolated for a direction</strong> when any NetworkPolicy both selects it and lists that direction (<code>Ingress</code> or <code>Egress</code>) in <code>policyTypes</code>.</li>
<li><strong>Once isolated, only allowed traffic passes.</strong> The allowed set is the union of what every applicable policy allows. Reply traffic for allowed connections is permitted automatically.</li>
<li><strong>Policies are additive and don&rsquo;t conflict.</strong> There is no deny rule and no ordering. The documentation lists explicit deny as something the API cannot do.</li>
<li><strong>Both ends must agree.</strong> For a connection from pod A to pod B, A&rsquo;s egress and B&rsquo;s ingress must both allow it.</li>
<li><strong>Traffic from the pod&rsquo;s own node is always allowed</strong> for ingress, and a pod cannot block access to itself.</li>
</ul>
<p>Two practical consequences follow. First, the first policy that selects a pod for a direction is what isolates it, even if that policy was meant only to allow something. Second, &ldquo;default deny&rdquo; is not a special setting; it is a policy that selects all pods and allows nothing.</p>
<p>One more prerequisite: the documentation states that creating a NetworkPolicy without a network plugin that implements it &ldquo;will have no effect&rdquo;. Check that your CNI plugin enforces NetworkPolicy before you rely on any of this.</p>
<h2 id="the-default-deny-manifests">The default-deny manifests</h2>
<p>The Kubernetes documentation provides three variants. This one denies both directions:</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">networking.k8s.io/v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">NetworkPolicy</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">default-deny-all</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">namespace</span>: <span style="color:#ae81ff">shop</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">podSelector</span>: {}
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">policyTypes</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">Ingress</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">Egress</span>
</span></span></code></pre></div><p><code>podSelector: {}</code> selects every pod in the namespace, including pods created later. The ingress-only and egress-only versions list only one entry in <code>policyTypes</code>.</p>
<p>A detail that trips people up: if you omit <code>policyTypes</code>, the documentation says <code>Ingress</code> is always set and <code>Egress</code> is set only if the policy has egress rules. A policy with no egress rules and no <code>policyTypes</code> therefore does not isolate egress. Write <code>policyTypes</code> explicitly.</p>
<h2 id="allow-dns-in-the-same-change">Allow DNS in the same change</h2>
<p>The documentation has a caution next to the egress example: a default-deny egress policy also blocks DNS. Add the allow before or with the deny:</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">networking.k8s.io/v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">NetworkPolicy</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">allow-dns-egress</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">namespace</span>: <span style="color:#ae81ff">shop</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">podSelector</span>: {}
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">policyTypes</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">Egress</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">egress</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">to</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">namespaceSelector</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">matchLabels</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">kubernetes.io/metadata.name</span>: <span style="color:#ae81ff">kube-system</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">podSelector</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">matchLabels</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">k8s-app</span>: <span style="color:#ae81ff">kube-dns</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">protocol</span>: <span style="color:#ae81ff">UDP</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">port</span>: <span style="color:#ae81ff">53</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">protocol</span>: <span style="color:#ae81ff">TCP</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">port</span>: <span style="color:#ae81ff">53</span>
</span></span></code></pre></div><p>The <code>k8s-app: kube-dns</code> label is the one the upstream documentation uses for CoreDNS, kept for backward compatibility with the older kube-dns. Managed clusters and node-local DNS caches can differ, so check with <code>kubectl get pods -n kube-system --show-labels</code> before applying. Allow TCP as well as UDP: DNS falls back to TCP for large responses.</p>
<h2 id="writing-the-allow-rules">Writing the allow rules</h2>
<h3 id="the-selector-indentation-trap">The selector indentation trap</h3>
<p>The documentation highlights one YAML detail that changes meaning completely:</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:#75715e"># ONE peer: pods labelled role=client IN namespaces labelled user=alice</span>
</span></span><span style="display:flex;"><span>- <span style="color:#f92672">from</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">namespaceSelector</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">matchLabels</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">user</span>: <span style="color:#ae81ff">alice</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">podSelector</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">matchLabels</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">role</span>: <span style="color:#ae81ff">client</span>
</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-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># TWO peers: role=client pods in THIS namespace, OR any pod in user=alice namespaces</span>
</span></span><span style="display:flex;"><span>- <span style="color:#f92672">from</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">namespaceSelector</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">matchLabels</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">user</span>: <span style="color:#ae81ff">alice</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">podSelector</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">matchLabels</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">role</span>: <span style="color:#ae81ff">client</span>
</span></span></code></pre></div><p>The second form is far broader than it looks. The documentation suggests <code>kubectl describe networkpolicy</code> to see how Kubernetes interpreted a policy.</p>
<h3 id="both-ends-of-an-internal-connection">Both ends of an internal connection</h3>
<p>With a namespace-wide default deny, a <code>web</code> to <code>api</code> call on port 9000 needs two policies:</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">networking.k8s.io/v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">NetworkPolicy</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">web-to-api-egress</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">namespace</span>: <span style="color:#ae81ff">shop</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">podSelector</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">matchLabels</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">app</span>: <span style="color:#ae81ff">web</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">policyTypes</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">Egress</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">egress</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">to</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">podSelector</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">matchLabels</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">app</span>: <span style="color:#ae81ff">api</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">protocol</span>: <span style="color:#ae81ff">TCP</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">port</span>: <span style="color:#ae81ff">9000</span>
</span></span><span style="display:flex;"><span>---
</span></span><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">networking.k8s.io/v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">NetworkPolicy</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">api-from-web</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">namespace</span>: <span style="color:#ae81ff">shop</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">podSelector</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">matchLabels</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">app</span>: <span style="color:#ae81ff">api</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">policyTypes</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">Ingress</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">ingress</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">from</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">podSelector</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">matchLabels</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">app</span>: <span style="color:#ae81ff">web</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">protocol</span>: <span style="color:#ae81ff">TCP</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">port</span>: <span style="color:#ae81ff">9000</span>
</span></span></code></pre></div><p>Traffic from your ingress controller is another namespace-to-pod rule. Use the immutable <code>kubernetes.io/metadata.name</code> label, which the control plane sets on every namespace, rather than a label someone can edit. The example below assumes the controller runs in a namespace called <code>ingress-nginx</code>; adjust it to yours.</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">ingress</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">from</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">namespaceSelector</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">matchLabels</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">kubernetes.io/metadata.name</span>: <span style="color:#ae81ff">ingress-nginx</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">protocol</span>: <span style="color:#ae81ff">TCP</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">port</span>: <span style="color:#ae81ff">8080</span>
</span></span></code></pre></div><h3 id="use-ipblock-only-for-addresses-outside-the-cluster">Use ipBlock only for addresses outside the cluster</h3>
<p>The documentation says <code>ipBlock</code> should be used for cluster-external addresses, because pod IPs are ephemeral. It also warns that source and destination rewriting by load balancers or Service implementations may happen before or after policy evaluation, so the source IP a policy sees for ingress could be a load balancer&rsquo;s or node&rsquo;s address. Test any <code>ipBlock</code> rule with your actual plugin.</p>
<h2 id="what-networkpolicy-does-not-do">What NetworkPolicy does not do</h2>
<p>From the documentation&rsquo;s own list, as of v1.37 the API cannot:</p>
<ul>
<li>Apply a default policy to all namespaces. You need a policy per namespace, or a third-party tool.</li>
<li>Log blocked or allowed connections.</li>
<li>Express explicit deny rules.</li>
<li>Block traffic from the pod&rsquo;s node, or loopback.</li>
<li>Do anything TLS-related, force traffic through a gateway, or target Services by name.</li>
</ul>
<p>It is defined for TCP, UDP and SCTP. For other protocols such as ICMP, the documentation says behavior is undefined and varies by plugin. Policy behavior for <code>hostNetwork</code> pods is also undefined; most plugins treat their traffic as node traffic.</p>
<h2 id="a-rollout-order-that-avoids-outages">A rollout order that avoids outages</h2>
<p>These steps are our recommendations, not part of the Kubernetes documentation.</p>
<ol>
<li>
<p><strong>Confirm enforcement.</strong> Check that your CNI plugin enforces NetworkPolicy, and whether it supports <code>endPort</code> and SCTP if you need them.</p>
</li>
<li>
<p><strong>Find namespaces with no policy at all.</strong> For example (illustrative, not run against a cluster):</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>comm -23 <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  &lt;<span style="color:#f92672">(</span>kubectl get ns -o jsonpath<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;{range .items[*]}{.metadata.name}{&#34;\n&#34;}{end}&#39;</span> | sort<span style="color:#f92672">)</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  &lt;<span style="color:#f92672">(</span>kubectl get netpol -A -o jsonpath<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;{range .items[*]}{.metadata.namespace}{&#34;\n&#34;}{end}&#39;</span> | sort -u<span style="color:#f92672">)</span>
</span></span></code></pre></div></li>
<li>
<p><strong>Inventory real flows</strong> for one namespace: Services it exposes, what calls it, and every outbound dependency (databases, other namespaces, external APIs, metadata endpoints, DNS). Your plugin&rsquo;s flow logs, if it has them, are the best source.</p>
</li>
<li>
<p><strong>Apply the allow policies first, in complete sets.</strong> Remember that any policy selecting a pod isolates it in that direction. The DNS policy above selects every pod for egress, so on its own it already blocks all egress except DNS. Apply it together with every other egress allow rule for the namespace, and each pod&rsquo;s ingress allows together. Start with low-risk namespaces.</p>
</li>
<li>
<p><strong>Apply the default deny,</strong> ingress first, then egress. Egress is where unknown dependencies hide.</p>
</li>
<li>
<p><strong>Test from inside the pods</strong> and watch error rates. The documentation notes that newly created pods may briefly start before policies are applied, and that a pod may start with no connectivity until allow rules arrive; make startup tolerant of that, for example with an init container that waits for dependencies.</p>
</li>
<li>
<p><strong>Lock down namespace labels.</strong> Selectors depend on labels, and the Kubernetes RBAC good-practices page warns that users who can patch a Namespace can change labels to gain access an administrator did not intend. Our <a href="/posts/kubernetes-rbac-audit-11-permissions-that-escalate-access/">RBAC audit guide</a> covers who holds that permission.</p>
</li>
</ol>
<p>Changing policies under live traffic has one more caveat: the documentation says whether a change affects existing connections is implementation-defined.</p>
<p>NetworkPolicy pairs naturally with the admission controls we have covered. <a href="/posts/kubernetes-pod-security-admission-namespace-labels-modes-and-a-safe-rollout-from-warn-to-enforce/">Pod Security Admission</a> is also driven by namespace labels, and a <a href="/posts/kubernetes-validatingadmissionpolicy-cel-rules-for-registry-allowlists-and-memory-limits-with-a-warn-first-rollout/">ValidatingAdmissionPolicy</a> can require that every new namespace gets a default-deny policy through your deployment tooling.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>We validated the manifests against the Kubernetes schema but did not apply them to a cluster or test them with a specific network plugin. The DNS label and the ingress-controller namespace are common defaults, not universal. Plugin-specific features such as cluster-wide policies, logging and layer 7 rules 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>Default non-isolation; isolation by policyTypes; additive union; both ends must allow; node traffic always allowed; no effect without an enforcing plugin</td>
          <td><a href="https://kubernetes.io/docs/concepts/services-networking/network-policies/">Kubernetes: Network Policies</a> (v1.37 docs, source in kubernetes/website)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Default-deny example manifests; DNS caution; policyTypes default when omitted; selector indentation example</td>
          <td>Same page and its example files</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>kubernetes.io/metadata.name</code> immutable label</td>
          <td>Same page</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>ipBlock for external IPs; rewriting before or after policy is undefined; TCP/UDP/SCTP only; hostNetwork undefined; list of unsupported features; existing connections implementation-defined; pod startup timing</td>
          <td>Same page</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>CoreDNS keeps the <code>k8s-app: kube-dns</code> label</td>
          <td><a href="https://kubernetes.io/docs/tasks/administer-cluster/dns-debugging-resolution/">Kubernetes: Debugging DNS Resolution</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Namespace patch permission can change labels and widen NetworkPolicy access</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>Manifests are schema-valid</td>
          <td>kubeconform 0.6.7, <code>-strict</code>, Kubernetes 1.34 schema, run on October 22, 2026</td>
          <td>Verified; not applied to a cluster</td>
      </tr>
      <tr>
          <td>Rollout order</td>
          <td>Our recommendations</td>
          <td>Qualified: not tested on a cluster</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Kubernetes MutatingAdmissionPolicy: CEL Mutations with Apply Configuration or JSON Patch, and When a Webhook Is Still Needed</title><link>https://firewallsync.com/posts/kubernetes-mutatingadmissionpolicy-cel-mutations-apply-configuration-or-json-patch-and-when-a-webhook-is-still-needed/</link><guid>https://firewallsync.com/posts/kubernetes-mutatingadmissionpolicy-cel-mutations-apply-configuration-or-json-patch-and-when-a-webhook-is-still-needed/</guid><pubDate>Sun, 18 Oct 2026 06:05:00 +0000</pubDate><dc:creator>John Marshall</dc:creator><category>Cloud Security</category><description>MutatingAdmissionPolicy lets the Kubernetes API server change incoming objects with CEL expressions instead of calling a webhook. This guide covers the three resources, the two patch types, reinvocation, failure behavior, what the docs do not say, and a cautious rollout.</description><enclosure url="https://firewallsync.com/posts/kubernetes-mutatingadmissionpolicy-cel-mutations-apply-configuration-or-json-patch-and-when-a-webhook-is-still-needed/cover_hu18373587765124506723.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/kubernetes-mutatingadmissionpolicy-cel-mutations-apply-configuration-or-json-patch-and-when-a-webhook-is-still-needed/cover_hu18373587765124506723.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/kubernetes-mutatingadmissionpolicy-cel-mutations-apply-configuration-or-json-patch-and-when-a-webhook-is-still-needed/cover_hu18373587765124506723.jpg" alt="A close-up of a red industrial valve with black solenoid connectors, an orange cable and silver pipework, with a blurred corridor behind" width="1200" height="630"></p><p>A mutating admission policy answers a narrow question: when a request to create or update an object reaches the Kubernetes API server, should something in that object be changed before it is stored? Until recently the usual answer was a mutating webhook, which means running and securing a separate HTTPS service in the request path. <strong>MutatingAdmissionPolicy</strong> (MAP) moves the logic into the API server as declarative CEL expressions. This guide explains how it is built, what the documentation guarantees, and where you should still be careful.</p>
<p><strong>Scope and method.</strong> This is research-based analysis of the Kubernetes v1.37 documentation, read on October 18, 2026: the Mutating Admission Policy page (last modified September 28, 2026), the MutatingAdmissionPolicy and MutatingAdmissionPolicyBinding API references (both last modified August 26, 2026), the Admission Controllers reference (last modified September 28, 2026) and the Dynamic Admission Control page. We did not run a Kubernetes cluster and did not test any policy against an API server. The only thing we ran ourselves is a check of generic JSON Patch behavior, described in the JSON Patch section and labelled there.</p>
<h2 id="the-short-answer">The short answer</h2>
<ul>
<li><strong>What it is.</strong> A declarative, in-process alternative to mutating admission webhooks. Mutations are written in the Common Expression Language (CEL), the small expression language Kubernetes uses for admission rules.</li>
<li><strong>Status.</strong> The Kubernetes documentation says it is stable since v1.36 and enabled by default, and first became available in v1.30.</li>
<li><strong>What you create.</strong> A <code>MutatingAdmissionPolicy</code> (the logic), a <code>MutatingAdmissionPolicyBinding</code> (the scope), and optionally a parameter resource (the values). At least a policy and a matching binding must exist for anything to happen.</li>
<li><strong>Where it runs.</strong> In the mutating phase of admission, before the validating phase.</li>
<li><strong>The catch.</strong> It is part of the control plane. The documentation says to write and deploy these policies &ldquo;with great caution&rdquo;.</li>
</ul>
<h2 id="the-three-resources">The three resources</h2>
<table>
  <thead>
      <tr>
          <th>Resource</th>
          <th>What it does</th>
          <th>Notes from the documentation</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>MutatingAdmissionPolicy</td>
          <td>The abstract logic: what to match, which mutations to apply</td>
          <td><code>matchConstraints</code> and <code>mutations</code> are required; <code>mutations</code> cannot be an empty list</td>
      </tr>
      <tr>
          <td>Parameter resource (optional)</td>
          <td>Concrete values, for example from a ConfigMap or a custom resource</td>
          <td>Referenced by <code>spec.paramKind</code> in the policy and <code>paramRef</code> in the binding</td>
      </tr>
      <tr>
          <td>MutatingAdmissionPolicyBinding</td>
          <td>Links a policy to parameters and scopes it</td>
          <td>Has three fields: <code>policyName</code>, <code>paramRef</code> and <code>matchResources</code></td>
      </tr>
  </tbody>
</table>
<p>Two details are easy to miss. If a binding names a policy that does not exist, the API reference says the binding is &ldquo;considered invalid and will be ignored&rdquo;. If the policy declares a <code>paramKind</code> and the binding&rsquo;s <code>paramRef</code> points to a resource that does not exist, the binding is treated as misconfigured and the policy&rsquo;s <code>failurePolicy</code> applies. Also, per binding, the policy is evaluated once, or once per selected parameter object if parameters are used, and each evaluation has a runtime cost budget.</p>
<h2 id="which-requests-a-policy-sees">Which requests a policy sees</h2>
<p>Matching is layered, and each layer can only narrow the previous one:</p>
<ol>
<li><strong><code>matchConstraints</code></strong> on the policy (required) selects resources and operations. The API reference says the operations CREATE, UPDATE and CONNECT are allowed and DELETE may not be matched.</li>
<li><strong><code>matchResources</code></strong> on the binding narrows further. If it is unset, it does not constrain matching.</li>
<li><strong><code>matchConditions</code></strong> on the policy are CEL expressions evaluated last. If any condition is false the policy is skipped; if none is false but one errors, <code>failurePolicy</code> decides. A policy may have at most 64 match conditions.</li>
</ol>
<p>The documentation notes that <code>namespaceObject</code> is always null in match conditions and that <code>variables</code> cannot be used there, because match conditions are evaluated before the rest of the policy. To select by namespace labels, use a <code>namespaceSelector</code> in <code>matchConstraints</code> or in the binding&rsquo;s <code>matchResources</code>.</p>
<p>One more limit that affects design: the documentation says that only <code>apiVersion</code>, <code>kind</code>, <code>metadata.name</code>, <code>metadata.generateName</code> and <code>metadata.labels</code> are accessible from the root of the object, and that no other metadata properties are accessible. A rule that depends on an annotation, for example, cannot read it through <code>object</code>. This is the documented behavior; we have not tested workarounds.</p>
<h2 id="two-ways-to-express-a-mutation">Two ways to express a mutation</h2>
<p>Each entry in <code>spec.mutations</code> has a <code>patchType</code>, either <code>ApplyConfiguration</code> or <code>JSONPatch</code>.</p>
<h3 id="applyconfiguration">ApplyConfiguration</h3>
<p>The expression must evaluate to a CEL object created with <code>Object{...}</code>, and the result is merged using the server-side apply merge strategy. This is the Kubernetes documentation&rsquo;s own example, which adds a <code>mesh-proxy</code> init container to new Pods that do not already have one:</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">admissionregistration.k8s.io/v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">MutatingAdmissionPolicy</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:#e6db74">&#34;sidecar-policy.example.com&#34;</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">paramKind</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">kind</span>: <span style="color:#ae81ff">Sidecar</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">mutations.example.com/v1</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">matchConstraints</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">resourceRules</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">apiGroups</span>:   [<span style="color:#e6db74">&#34;&#34;</span>]
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">apiVersions</span>: [<span style="color:#e6db74">&#34;v1&#34;</span>]
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">operations</span>:  [<span style="color:#e6db74">&#34;CREATE&#34;</span>]
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">resources</span>:   [<span style="color:#e6db74">&#34;pods&#34;</span>]
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">matchConditions</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">does-not-already-have-sidecar</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">expression</span>: <span style="color:#e6db74">&#34;!object.spec.initContainers.exists(ic, ic.name == \&#34;mesh-proxy\&#34;)&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">failurePolicy</span>: <span style="color:#ae81ff">Fail</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">reinvocationPolicy</span>: <span style="color:#ae81ff">IfNeeded</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">mutations</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">patchType</span>: <span style="color:#e6db74">&#34;ApplyConfiguration&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">applyConfiguration</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">expression</span>: &gt;<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          Object{
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            spec: Object.spec{
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">              initContainers: [
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                Object.spec.initContainers{
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                  name: &#34;mesh-proxy&#34;,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                  image: &#34;mesh/proxy:v1.0.0&#34;,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                  args: [&#34;proxy&#34;, &#34;sidecar&#34;],
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                  restartPolicy: &#34;Always&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                }
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">              ]
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            }
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">          }</span>          
</span></span></code></pre></div><p>The documented restriction to remember: apply configurations may not modify atomic structs, maps or arrays, because of the risk of accidentally deleting values the configuration does not mention.</p>
<h3 id="jsonpatch">JSONPatch</h3>
<p>The expression must return an array of <code>JSONPatch{op, path, value}</code> values, following the <a href="https://jsonpatch.com/">JSON Patch</a> format. The documentation&rsquo;s examples include a conditional change using a <code>test</code> operation before a <code>replace</code>, and a helper, <code>jsonpatch.escapeKey()</code>, for path keys that contain <code>/</code> or <code>~</code>. It escapes <code>~</code> as <code>~0</code> and <code>/</code> as <code>~1</code>, so a label such as <code>example.com/environment</code> can be addressed.</p>
<p><strong>Read the documentation&rsquo;s two sidecar examples side by side.</strong> The page says the JSON Patch version writes &ldquo;the same mutation&rdquo;, but the two examples are not identical: the apply-configuration example uses the image <code>mesh/proxy:v1.0.0</code> and sets <code>args</code>, while the JSON Patch example uses the image string <code>mesh-proxy/v1.0.0</code> and no <code>args</code>. Treat them as illustrations of syntax, not as a verified equivalence, and do not copy the image reference from either.</p>
<h3 id="what-we-tested-about-json-patch">What we tested about JSON Patch</h3>
<p>On October 18, 2026 we ran a generic JSON Patch library (Python <code>jsonpatch</code> 1.35, an implementation of the RFC 6902 patch format) on small sample documents. This does not run the Kubernetes API server&rsquo;s implementation, so it shows how the format behaves, not how your cluster will.</p>
<table>
  <thead>
      <tr>
          <th>Patch</th>
          <th>Document</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>add</code> to <code>/spec/initContainers/-</code></td>
          <td>Pod with an existing <code>initContainers</code> list</td>
          <td>Appended the new item</td>
      </tr>
      <tr>
          <td>Same <code>add</code></td>
          <td>Pod with no <code>initContainers</code> field</td>
          <td>Error: member <code>initContainers</code> not found</td>
      </tr>
      <tr>
          <td><code>add</code> to <code>/metadata/labels/example.com~1environment</code></td>
          <td>Empty <code>labels</code> map</td>
          <td>Added the key <code>example.com/environment</code></td>
      </tr>
      <tr>
          <td><code>add</code> to <code>/metadata/labels/example.com/environment</code> (unescaped)</td>
          <td>Empty <code>labels</code> map</td>
          <td>Error: member <code>example.com</code> not found</td>
      </tr>
      <tr>
          <td><code>test</code> of <code>/spec/example</code> equal to <code>&quot;Red&quot;</code>, then <code>replace</code> with <code>&quot;Green&quot;</code></td>
          <td><code>example</code> is <code>&quot;Red&quot;</code></td>
          <td>Replaced with <code>&quot;Green&quot;</code></td>
      </tr>
      <tr>
          <td>Same pair</td>
          <td><code>example</code> is <code>&quot;Blue&quot;</code></td>
          <td>Error: the test failed, nothing replaced</td>
      </tr>
  </tbody>
</table>
<p>The practical lesson is that a positional JSON Patch needs the parent path to exist and the key to be escaped. The documentation&rsquo;s sidecar example uses a match condition that reads <code>object.spec.initContainers</code>; whether that expression behaves well when the field is absent is something we did not verify and you should test. The Kubernetes documentation we read does not say what the API server does when a JSON Patch fails to apply, so do not assume it is silently skipped.</p>
<h2 id="reinvocation-why-your-mutation-must-be-safe-to-repeat">Reinvocation: why your mutation must be safe to repeat</h2>
<p><code>reinvocationPolicy</code> is a required field on the policy, with two values:</p>
<ul>
<li><strong>Never:</strong> the mutations are not called more than once per binding in a single admission evaluation.</li>
<li><strong>IfNeeded:</strong> they may be called more than once per binding for a single admission request, only when other mutations changed the object after this one ran. The API reference states there is no guarantee of order relative to other admission plugins, webhooks, bindings of this policy and other admission policies.</li>
</ul>
<p>The reason is explained on the Dynamic Admission Control page: no single ordering of mutating plugins works for every case, so mutating plugins can be re-run so that they see changes made by others. That page also states that mutating webhooks must be idempotent, meaning able to process an object they have already modified. Our reading is that the same discipline applies to a policy using <code>IfNeeded</code>, which is why the documentation&rsquo;s example begins with a match condition that skips Pods that already have the <code>mesh-proxy</code> container. For comparison, the webhook field <code>reinvocationPolicy</code> defaults to <code>Never</code>.</p>
<h2 id="failure-behavior">Failure behavior</h2>
<p><code>failurePolicy</code> accepts <code>Fail</code> or <code>Ignore</code> and defaults to <code>Fail</code>. The API reference says failures can come from CEL parse errors, type-check errors, runtime errors, and invalid or misconfigured policy definitions or bindings, such as a <code>paramKind</code> that refers to a non-existent kind. It does not define how validations that evaluate to false are handled; that is a validation concept, not a mutation one.</p>
<p>For a control-plane component, the difference matters. With <code>Fail</code>, an error in your expression can reject writes to every object you matched. With <code>Ignore</code>, the same error means the mutation is silently not applied, and anything that depended on it, such as a validating rule that expects the label to be there, may then reject the object or let a bad one through. The Admission Controllers page says that if any MutatingAdmissionPolicy fails, the request fails.</p>
<h2 id="how-it-fits-with-other-admission-mechanisms">How it fits with other admission mechanisms</h2>
<p>The Admission Controllers reference says admission runs in two phases: mutating controllers first, then validating ones, and a rejection in either phase rejects the whole request.</p>
<ul>
<li><strong>With Pod Security Admission.</strong> The reference lists the <code>PodSecurity</code> plugin as validating. Pods are therefore checked after mutation. See our guide to <a href="/posts/kubernetes-pod-security-admission-namespace-labels-modes-and-a-safe-rollout-from-warn-to-enforce/">Pod Security Admission</a>.</li>
<li><strong>With ValidatingAdmissionPolicy.</strong> A mutating policy can set a value that a validating policy then checks. Our <a href="/posts/kubernetes-validatingadmissionpolicy-cel-rules-for-registry-allowlists-and-memory-limits-with-a-warn-first-rollout/">ValidatingAdmissionPolicy guide</a> covers the validating side, which has Deny, Warn and Audit actions that the MAP binding does not.</li>
<li><strong>With image signature checks.</strong> Verifying cosign signatures at admission is done by a webhook such as Sigstore&rsquo;s policy-controller; see our guide to <a href="/posts/verify-signed-container-images-in-kubernetes-sigstore-policy-controller-keyless-policy-and-rollout/">verifying signed container images in Kubernetes</a>.</li>
<li><strong>With webhooks.</strong> Built-in mutating plugins are re-run if a mutating webhook modifies an object, per the Dynamic Admission Control page.</li>
</ul>
<h3 id="exempt-kinds">Exempt kinds</h3>
<p>Policies created through the REST API cannot mutate ValidatingAdmissionPolicies, their bindings, MutatingAdmissionPolicies or their bindings. The documentation says a policy loaded through manifest-based admission control can intercept those kinds. Separately, the non-persisted authentication and authorization review kinds (TokenReviews, SelfSubjectReviews, LocalSubjectAccessReviews, SelfSubjectAccessReviews, SelfSubjectRulesReviews and SubjectAccessReviews) are exempt from all admission policies, and the page says that starting with Kubernetes v1.37 admission webhooks also exclude them by default.</p>
<h2 id="map-vap-or-webhook-a-decision-guide">MAP, VAP or webhook: a decision guide</h2>
<table>
  <thead>
      <tr>
          <th>Your need</th>
          <th>Fits</th>
          <th>Why (from the documentation)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Reject requests that break a rule</td>
          <td>ValidatingAdmissionPolicy</td>
          <td>The MAP page calls it &ldquo;a simpler and more effective alternative&rdquo; for prevention-only rules</td>
      </tr>
      <tr>
          <td>Add or default fields from the request, an existing object field or a parameter</td>
          <td>MutatingAdmissionPolicy</td>
          <td>Declarative, in-process, CEL-based, parameterizable</td>
      </tr>
      <tr>
          <td>Decision needs an outside system or data not in the request or parameters</td>
          <td>Mutating webhook</td>
          <td>MAP is described as free of external HTTP callouts</td>
      </tr>
  </tbody>
</table>
<p>This table is our summary of the documented capabilities, not a benchmark. We did not measure latency or reliability differences between policies and webhooks, and we make no claim about them.</p>
<h2 id="a-cautious-rollout">A cautious rollout</h2>
<p>The MAP binding has no Warn or Audit action, so you cannot trial a policy in a non-enforcing mode the way you can with a validating binding. Our suggestions, all untested by us:</p>
<ol>
<li><strong>Start in a throwaway cluster</strong> on the Kubernetes minor version you run, because the feature became stable in v1.36 and your cluster may be older.</li>
<li><strong>Scope the binding narrowly</strong> with <code>matchResources</code> and a <code>namespaceSelector</code>. The <code>kubernetes.io/metadata.name</code> label that the API server sets on namespaces, as covered in our ValidatingAdmissionPolicy guide, gives you a name-based selector without labelling anything.</li>
<li><strong>Make the mutation idempotent</strong> and put the &ldquo;already done&rdquo; check in <code>matchConditions</code>.</li>
<li><strong>Decide on <code>failurePolicy</code> deliberately.</strong> Use <code>Ignore</code> while developing so a typo does not block writes, then switch to <code>Fail</code> once you have shown the mutation applies.</li>
<li><strong>Re-read the resulting object</strong>, and verify that your validating rules and Pod Security levels still accept it.</li>
</ol>
<h2 id="limits-of-this-article">Limits of this article</h2>
<ul>
<li>We did not run a Kubernetes cluster, so no policy in this article was tested against an API server.</li>
<li>The JSON Patch results come from a generic Python library, not from Kubernetes.</li>
<li>We did not find, in the pages we read, statements about what happens when a JSON Patch fails to apply, or about the ordering of several mutating policies; we have not claimed any.</li>
<li>The Admission Controllers page still describes the MutatingAdmissionPolicy plugin as needing the <code>mutatingadmissionpolicy</code> feature gate and the <code>admissionregistration.k8s.io/v1alpha1</code> API group, while its default-plugin list for 1.37 includes it and the MAP page says stable and enabled by default. We follow the latter two and flag the inconsistency.</li>
<li>Documentation changes. Re-check the pages below for your Kubernetes version.</li>
</ul>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Documentation read October 18, 2026 (site version v1.37).</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>MAP is a declarative, in-process CEL alternative to mutating webhooks; stable since v1.36, enabled by default, first available in v1.30</td>
          <td><a href="https://kubernetes.io/docs/reference/access-authn-authz/mutating-admission-policy/">Mutating Admission Policy</a> (last modified Sep 28, 2026)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Policy, binding and optional parameter resource; a policy and a matching binding are required; <code>.spec.mutations</code> cannot be empty; names ending <code>.static.k8s.io</code> are reserved</td>
          <td><a href="https://kubernetes.io/docs/reference/access-authn-authz/mutating-admission-policy/">Mutating Admission Policy</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>ApplyConfiguration must evaluate to a CEL object and may not modify atomic structs, maps or arrays; <code>jsonpatch.escapeKey</code> escapes <code>~</code> and <code>/</code>; only apiVersion, kind, metadata.name, metadata.generateName and metadata.labels are accessible from the object root</td>
          <td><a href="https://kubernetes.io/docs/reference/access-authn-authz/mutating-admission-policy/">Mutating Admission Policy</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>matchConditions semantics, maximum of 64, <code>namespaceObject</code> and <code>variables</code> not usable there</td>
          <td><a href="https://kubernetes.io/docs/reference/kubernetes-api/policy-resources/mutating-admission-policy-v1/">MutatingAdmissionPolicy API reference</a> and the MAP page (last modified Aug 26, 2026)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>failurePolicy</code> Fail or Ignore, default Fail; <code>reinvocationPolicy</code> required, Never or IfNeeded with the stated semantics; CREATE, UPDATE, CONNECT allowed, DELETE not</td>
          <td><a href="https://kubernetes.io/docs/reference/kubernetes-api/policy-resources/mutating-admission-policy-v1/">MutatingAdmissionPolicy API reference</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Binding fields (<code>policyName</code>, <code>paramRef</code>, <code>matchResources</code>); missing policy means binding ignored; evaluation per parameter; cost budget</td>
          <td><a href="https://kubernetes.io/docs/reference/kubernetes-api/policy-resources/mutating-admission-policy-binding-v1/">MutatingAdmissionPolicyBinding API reference</a> (last modified Aug 26, 2026)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Two-phase admission, mutating then validating; rejection in either phase rejects the request; PodSecurity is validating; any failing MutatingAdmissionPolicy fails the request</td>
          <td><a href="https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/">Admission Controllers reference</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>MutatingAdmissionPolicy appears in the Kubernetes 1.37 default-enabled plugin list</td>
          <td><a href="https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/">Admission Controllers reference</a></td>
          <td>Qualified: the per-plugin text on the same page still mentions v1alpha1 prerequisites</td>
      </tr>
      <tr>
          <td>Built-in mutating plugins are re-run if a webhook modifies the object; webhooks must be idempotent; webhook <code>reinvocationPolicy</code> defaults to Never</td>
          <td><a href="https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy">Dynamic Admission Control</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Exempt kinds, including virtual authentication and authorization kinds, and the v1.37 webhook default exclusion</td>
          <td><a href="https://kubernetes.io/docs/reference/access-authn-authz/mutating-admission-policy/">Mutating Admission Policy</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>The documentation&rsquo;s JSON Patch sidecar example differs from its apply-configuration example in image string and <code>args</code></td>
          <td><a href="https://kubernetes.io/docs/reference/access-authn-authz/mutating-admission-policy/">Mutating Admission Policy</a></td>
          <td>Verified (our comparison of the two published examples)</td>
      </tr>
      <tr>
          <td>JSON Patch behaviors in the test table</td>
          <td>Our run of Python <code>jsonpatch</code> 1.35, October 18, 2026</td>
          <td>Qualified: generic RFC 6902 library, not the Kubernetes API server</td>
      </tr>
      <tr>
          <td>Rollout suggestions and the reading that <code>IfNeeded</code> calls for idempotent mutations</td>
          <td>Our analysis</td>
          <td>Qualified: untested on a cluster</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item></channel></rss>