<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[AuditRadar Blog]]></title><description><![CDATA[AuditRadar Blog]]></description><link>https://blog.audit-radar.com</link><image><url>https://cdn.hashnode.com/uploads/logos/69c679d5c7e84c5670c8a41f/8c770da1-33cc-4816-b83d-a044b9d11fd4.svg</url><title>AuditRadar Blog</title><link>https://blog.audit-radar.com</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 21 May 2026 06:13:48 GMT</lastBuildDate><atom:link href="https://blog.audit-radar.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Why Kubernetes Has No Login — And How We Solved It for AuditRadar]]></title><description><![CDATA[When we set out to build the Logins page for AuditRadar — a real-time audit log explorer for OpenShift and Kubernetes — we hit a wall that forced us to deeply understand how authentication actually wo]]></description><link>https://blog.audit-radar.com/why-kubernetes-has-no-login-and-how-we-solved-it-for-auditradar</link><guid isPermaLink="true">https://blog.audit-radar.com/why-kubernetes-has-no-login-and-how-we-solved-it-for-auditradar</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[openshift]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Security]]></category><category><![CDATA[Go Language]]></category><dc:creator><![CDATA[hybrid2k3]]></dc:creator><pubDate>Fri, 27 Mar 2026 13:15:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69c679d5c7e84c5670c8a41f/50048be9-6639-46e8-ad31-41c060fec732.svg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When we set out to build the <strong>Logins page</strong> for <a href="https://audit-radar.com">AuditRadar</a> — a real-time audit log explorer for OpenShift and Kubernetes — we hit a wall that forced us to deeply understand how authentication actually works on each platform. What started as "just add a login tracking feature" turned into a fundamental lesson about the architectural differences between OpenShift and vanilla Kubernetes.</p>
<p>This is the story of what we found, and how we built session detection that works on both platforms.</p>
<hr />
<h2>The Problem: "Who logged in?" is not a simple question</h2>
<p>AuditRadar collects and displays audit events from the Kubernetes API server. We already had the Events page showing every <code>CREATE</code>, <code>DELETE</code>, <code>PATCH</code> in real time. The next natural feature was: <strong>show who logged in, from where, and when</strong>.</p>
<p>On OpenShift, this was relatively straightforward. On Kubernetes, it turned out to be architecturally impossible in the traditional sense — because Kubernetes has no concept of "login" at all.</p>
<hr />
<h2>OpenShift: A Real OAuth Server</h2>
<p>OpenShift ships with a <strong>built-in OAuth 2.0 server</strong> (<code>oauth-openshift</code>) running in the <code>openshift-authentication</code> namespace. Every user interaction goes through it:</p>
<ul>
<li><p>You open the web console → browser redirects to OAuth</p>
</li>
<li><p>You run <code>oc login</code> → CLI performs OAuth token exchange</p>
</li>
<li><p>You use a service account → SA token is issued</p>
</li>
</ul>
<p>This OAuth flow creates real, auditable events. Specifically, when a user logs in, the OAuth server creates an <code>oauthaccesstoken</code> resource. When they log out, it gets deleted. These events appear in the <code>openshift-apiserver</code> audit log with full details.</p>
<p>Here's what a login event looks like in the raw audit log:</p>
<pre><code class="language-json">{
  "verb": "create",
  "requestURI": "/apis/oauth.openshift.io/v1/oauthaccesstokens",
  "user": {
    "username": "system:serviceaccount:openshift-authentication:oauth-openshift"
  },
  "requestObject": {
    "userName": "kubeadmin",
    "clientName": "console"
  }
}
</code></pre>
<p>The <code>clientName</code> field is gold — it tells you <strong>exactly</strong> how the user authenticated:</p>
<table>
<thead>
<tr>
<th>clientName</th>
<th>Method</th>
</tr>
</thead>
<tbody><tr>
<td><code>console</code></td>
<td>Web Console (browser)</td>
</tr>
<tr>
<td><code>openshift-challenging-client</code></td>
<td><code>oc login</code> (CLI)</td>
</tr>
<tr>
<td>anything else</td>
<td>API token</td>
</tr>
</tbody></table>
<h3>The Real IP Problem on OpenShift</h3>
<p>One catch: OpenShift's default ingress (HAProxy) operates in <strong>TLS passthrough mode</strong> (<code>be_tcp</code>). This means HAProxy never terminates the TLS connection — it just forwards raw TCP packets to <code>oauth-openshift</code>. The result: all login events show the internal HAProxy IP, not the real client IP.</p>
<p>To fix this, we set up an Nginx L7 reverse proxy in front of the OAuth endpoint with <code>X-Forwarded-For</code> injection, and configured the IngressController with <code>forwardedHeaderPolicy: Append</code>. We then added an XFF cache in the collector that correlates HAProxy access log entries (which do have the real IP) with OAuth events by timestamp.</p>
<h3>What We Capture on OpenShift</h3>
<ul>
<li><p>✅ Login events (web console vs CLI vs API token)</p>
</li>
<li><p>✅ Logout events</p>
</li>
<li><p>✅ Failed login attempts</p>
</li>
<li><p>✅ Real client IP (with Nginx reverse proxy)</p>
</li>
<li><p>✅ Session duration (login → logout)</p>
</li>
</ul>
<hr />
<h2>Kubernetes: No Login Concept at All</h2>
<p>Vanilla Kubernetes has <strong>no built-in OAuth server</strong>. It has no <code>login</code> command, no token exchange, no session concept. Authentication in Kubernetes works through:</p>
<ul>
<li><p><strong>X.509 client certificates</strong> — <code>system:admin</code> in kubeconfig uses a cert</p>
</li>
<li><p><strong>Service Account tokens</strong> — pods use JWT tokens mounted at runtime</p>
</li>
<li><p><strong>OIDC</strong> — optional external integration</p>
</li>
<li><p><strong>Static token files</strong> — legacy, rarely used</p>
</li>
</ul>
<p>When <code>system:admin</code> runs <code>kubectl get pods</code>, there is no "login" event. The certificate is simply presented with every request, like a key you hold in your hand. There is no moment of "entering the cluster" — you are always either presenting a valid cert or you're not.</p>
<pre><code class="language-bash"># This is NOT a login — it's just an API call authenticated with a certificate
kubectl get pods -n audit-vision
</code></pre>
<p>The Kubernetes audit log has no <code>oauthaccesstokens</code>, no <code>login</code> resource, no session concept whatsoever.</p>
<h3>The Discovery: credential-id</h3>
<p>While digging through k3s audit logs trying to figure out how to identify "sessions", we found something interesting in <code>user.extra</code>:</p>
<pre><code class="language-json">{
  "user": {
    "username": "system:admin",
    "groups": ["system:masters", "system:authenticated"],
    "extra": {
      "authentication.kubernetes.io/credential-id": [
        "X509SHA256=7095e7ebb6c0f08fb8c1a1151246adfd32bf243bcac72eddcd5e67ebd0ee33dd"
      ]
    }
  }
}
</code></pre>
<p>The <code>credential-id</code> field is a <strong>SHA256 fingerprint of the X.509 certificate</strong> used to authenticate the request. This is unique per certificate — meaning it's unique per kubeconfig context.</p>
<p>This gave us our "login" heuristic: <strong>the first API request from a new credential-id = session start</strong>.</p>
<h3>Implementation</h3>
<p>We added <code>Extra map[string][]string</code> to our <code>AuditUser</code> struct, extracted the credential-id in the normalizer, and built an in-memory cache keyed by <code>actor + ":" + credentialID</code>:</p>
<pre><code class="language-go">func extractK8sSessionEvent(ne model.NormalizedEvent) (model.AuthEvent, bool) {
    if ne.ActorType != "human" {
        return model.AuthEvent{}, false
    }

    credID := ne.Annotations["authentication.kubernetes.io/credential-id"]
    if credID == "" {
        return model.AuthEvent{}, false
    }

    cacheKey := ne.Actor + ":" + credID

    // Seen this credential in the last 8 hours? Not a new session.
    if v, found := credentialCache.Load(cacheKey); found {
        if time.Since(v.(time.Time)) &lt; 8*time.Hour {
            return model.AuthEvent{}, false
        }
    }

    credentialCache.Store(cacheKey, time.Now())

    return model.AuthEvent{
        Actor:     ne.Actor,
        Method:    detectLoginMethod(ne.UserAgent, ne.Source),
        SourceIP:  ne.SourceIP,
        EventType: "login",
        Success:   true,
    }, true
}
</code></pre>
<p>The <code>detectLoginMethod</code> function parses the User-Agent:</p>
<pre><code class="language-go">func detectLoginMethod(userAgent, source string) string {
    ua := strings.ToLower(userAgent)
    switch {
    case strings.Contains(ua, "kubectl/"):
        return "oc-cli"  // kubectl
    case strings.Contains(ua, "mozilla/") || strings.Contains(ua, "chrome/"):
        return "web-console"  // browser (k8s dashboard)
    default:
        return "api-token"  // scripts, CI/CD
    }
}
</code></pre>
<h3>What We Capture on Kubernetes</h3>
<ul>
<li><p>✅ "Session start" (first request per credential-id)</p>
</li>
<li><p>✅ Real client IP (kube-apiserver sees it directly — no proxy needed!)</p>
</li>
<li><p>✅ Authentication method (kubectl vs browser vs API)</p>
</li>
<li><p>❌ No logout events (certificates don't expire mid-session)</p>
</li>
<li><p>❌ No failed login attempts (invalid certs get TCP-rejected before audit)</p>
</li>
</ul>
<p>Interestingly, on Kubernetes the real IP is available <strong>for free</strong> — because <code>kubectl</code> connects directly to <code>kube-apiserver:6443</code> without going through any OAuth proxy. No nginx, no HAProxy, no XFF magic needed.</p>
<hr />
<h2>Side by Side: The Fundamental Difference</h2>
<table>
<thead>
<tr>
<th></th>
<th>OpenShift</th>
<th>Kubernetes</th>
</tr>
</thead>
<tbody><tr>
<td>Auth mechanism</td>
<td>Built-in OAuth 2.0 server</td>
<td>X.509 certs / SA tokens / OIDC</td>
</tr>
<tr>
<td>Login event</td>
<td><code>oauthaccesstokens CREATE</code></td>
<td>No equivalent</td>
</tr>
<tr>
<td>Logout event</td>
<td><code>oauthaccesstokens DELETE</code></td>
<td>No equivalent</td>
</tr>
<tr>
<td>Session concept</td>
<td>Native</td>
<td>Heuristic (credential-id)</td>
</tr>
<tr>
<td>Real IP tracking</td>
<td>Requires L7 proxy + XFF</td>
<td>Free (direct TCP to apiserver)</td>
</tr>
<tr>
<td>Failed auth visible in audit</td>
<td>Yes (via oauth-server log)</td>
<td>No (TLS rejected before audit)</td>
</tr>
<tr>
<td>Method detection</td>
<td><code>clientName</code> field</td>
<td>User-Agent parsing</td>
</tr>
</tbody></table>
<hr />
<h2>Platform Detection in the UI</h2>
<p>Since AuditRadar supports both platforms, we added a <code>PLATFORM</code> environment variable that the UI reads to switch themes and labels. On OpenShift deployments, the header shows a red <code>OCP</code> badge; on Kubernetes it shows a blue <code>K8S</code> badge. The CSS vars switch accordingly:</p>
<pre><code class="language-css">/* Default: OpenShift red */
:root { --accent: #ee0000; }

/* Kubernetes: blue */
body.k8s { --accent: #326CE5; --bg: #08101a; }
</code></pre>
<p>This is set via Helm values in the respective charts:</p>
<pre><code class="language-yaml"># Helm/audit-radar-k8s/values.yaml
env:
  - name: PLATFORM
    value: kubernetes
</code></pre>
<hr />
<h2>What This Means for Security Teams</h2>
<p>If you're running a <strong>compliance audit</strong> (SOC2, PCI-DSS) and you need to answer "who logged into the cluster and when":</p>
<p><strong>On OpenShift</strong> — you get a clean login/logout trail with timestamps, method, and (with proper proxy setup) real IPs. This maps directly to traditional access log requirements.</p>
<p><strong>On Kubernetes</strong> — you need to accept that "login" is a construct, not a fact. The credential-id approach gives you meaningful session boundaries, but there are no failed auth events and no logout signals. For real user tracking, the industry answer is OIDC integration with an external provider (Dex, Keycloak, Okta) — but that's a separate setup that most self-managed clusters don't have out of the box.</p>
<hr />
<h2>Try It Yourself</h2>
<p>AuditRadar is open source: <a href="https://github.com/vsenatorov/auditvision">github.com/vsenatorov/auditvision</a></p>
<p>Helm charts for both OpenShift and Kubernetes are included. The Logins page works on both platforms — it just means something slightly different on each.</p>
<p>If you're running k3s, k8s, or OpenShift and want real-time visibility into who's doing what in your cluster, give it a try.</p>
<hr />
<p><em>Viktor Senatorov — Senior Architect at Red Hat. Building open-source security tools for Kubernetes and OpenShift.</em></p>
]]></content:encoded></item></channel></rss>