Skip to main content

Introducing My Homelab Part-5

Mirecloud — Production Engineering

How I Replaced Kubernetes Static Credentials with Zero Trust OIDC — A Real Production Story

From admin.conf sprawl to Keycloak SSO, group-based RBAC, and centralized audit logging on bare metal. Every step, every trap, every fix.

Emmanuel Catin
• March 2026
• 15 min read
Kubernetes Keycloak OIDC Zero Trust RBAC Security DevOps Bare Metal

1. The Problem with Static Credentials

If you have ever bootstrapped a Kubernetes cluster, you know the file. It lives at /etc/kubernetes/admin.conf and it contains a client certificate that grants its holder full cluster-admin access with no expiry, no identity, and no revocation mechanism. You copy it to ~/.kube/config, you maybe email it to a colleague, and suddenly three people are sharing the same all-powerful credential. This is what security engineers call a “loaded gun left on the kitchen table.”

The most insidious part is not the access itself — it is the invisibility. Open the Kubernetes audit log and you will see requests attributed to kubernetes-admin. Every person who ever touched that config file looks identical. Did a developer accidentally delete a production namespace? Was it an automated pipeline? Was it an attacker who grabbed the file from a misconfigured bucket six months ago? The audit log cannot tell you.

Real consequences I have seen in practice:

  • Offboarding a contractor requires manually rotating cluster certificates, forcing control-plane restarts — an outage to revoke one person.
  • Static kubeconfig files spread into repos, Docker images, CI secret stores, and laptops — each copy becomes its own attack surface.
  • Compliance frameworks require named, revocable access. A shared cert fails those controls.
  • If key material leaks, you won’t know until something breaks.
Zero Trust Principle: Never trust, always verify. Every Kubernetes API request must carry a short-lived token tied to a real identity, validated on every call — not “once at login.”

The solution is OpenID Connect (OIDC). Instead of distributing static certificates, every user authenticates to a central Identity Provider (Keycloak) and receives short-lived JSON Web Tokens (JWT). The API server validates tokens cryptographically and RBAC binds permissions to Keycloak groups. Revoking access becomes: remove a user from a Keycloak group — no certificate rotation, no downtime.

What follows is the exact implementation I built for the Mirecloud bare-metal cluster running Kubernetes v1.34.2, with Keycloak deployed via Helm and Cilium Gateway API as the ingress layer.

2. Architecture Overview

Figure 1:

.
ComponentRoleWhere it lives
Client Workstation Runs kubectl + kubelogin to obtain tokens via browser login Developer laptop (Windows / macOS / Linux)
Keycloak IDP Identity provider; manages users/groups; issues signed JWTs Inside cluster (Helm), exposed at keycloak.mirecloud.com
Control Plane (kube-apiserver) Validates JWT via JWKS; enforces RBAC after authentication Bare-metal control-plane node (node-4)
Observability Stack Promtail ships audit logs to Loki; Grafana queries for investigations Inside the Kubernetes cluster
Key decision: Keycloak runs inside the cluster. That means kube-apiserver must reach Keycloak’s JWKS endpoint at startup. Cilium Gateway API with a static external IP solves this, but you must ensure DNS works from the control-plane host. In Mirecloud, the cluster DNS is 192.168.2.74.

3. Authentication Flow

Here’s the OIDC Authorization Code flow as it happens when a developer runs kubectl get nodes after token expiry:

flow — sequence
kubectl           kubelogin          Keycloak        kube-apiserver
   |                  |                 |                   |
   |-- get nodes ---->|                 |                   |
   |                  |-- open browser ->|                  |
   |                  |  localhost:8000  |                  |
   |                  |-- user login --->|                  |
   |                  |<- ------------------="" -----="" auth="" code="" exchange="" token="">|
   |                  |                 |   verify JWT      |
   |<-- -------="" decision="" pre="" rbac="" result="">
    
  • kubectl invokes exec plugin: kubeconfig uses an exec entry pointing to kubelogin.
  • kubelogin starts localhost callback: local HTTP server on :8000 + PKCE.
  • User authenticates to Keycloak: standard browser login (password/TOTP/WebAuthn, etc.).
  • Authorization code redirect: Keycloak redirects back to http://localhost:8000/.
  • Token exchange: kubelogin swaps code for an ID Token (JWT) and access token.
  • Bearer token to API server: kubectl attaches token in Authorization header.
  • kube-apiserver validates JWT: signature (JWKS), issuer, expiry, audience, claims.
  • RBAC decision: groups claim maps to ClusterRoleBindings.
Token caching: kubelogin caches tokens (typically under ~/.kube/cache/oidc-login/) and refreshes silently using refresh tokens when possible.

4. JWT Token Anatomy

A JWT is header.payload.signature. The payload claims are what Kubernetes evaluates for identity and RBAC.

PartContentUsed by kube-apiserver?
HeaderAlgorithm + kid (key ID)Yes
PayloadClaims (issuer, audience, email, groups, exp, etc.)Yes
SignatureRSA signature over header + payloadYes
json — JWT Payload (anonymized)
{
  "iss": "https://keycloak.mirecloud.com/auth/realms/mirecloud",
  "sub": "user-uuid",
  "email": "info@mirecloud.com",
  "email_verified": true,
  "groups": ["k8s-admins"],
  "aud": "kubernetes",
  "exp": 1740000000
}
Critical: email_verified
This is the #1 silent failure. If email_verified is false, the API server rejects tokens with a bare 401 Unauthorized. You’ll only see the reason in apiserver logs.

5. Phase 1 — Keycloak Configuration

Deployment note: Keycloak runs inside the cluster via Helm and is exposed via Cilium Gateway API at keycloak.mirecloud.com. OIDC client setup is the same regardless of deployment method.

Step 1.1 — Create Realm: Create a realm named mirecloud. Issuer URL becomes:

issuer
https://keycloak.mirecloud.com/auth/realms/mirecloud

Step 1.2 — Create OIDC Client: Clients → Create client. Use:

FieldValueWhy
Client typeOpenID ConnectRequired
Client IDkubernetesMust match apiserver + kubelogin config
Client authenticationOFFPublic client (PKCE), no secret
Standard flowONAuthorization Code flow
Direct access grantsONOnly for quick token testing
Valid redirect URIshttp://localhost:8000
http://localhost:18000
kubelogin callbacks
Redirect URI trap: add each URI on its own line (press Enter). Don’t space-separate values on one line.

Step 1.3 — Create Users: set a valid email; set Email verified = ON; set password; set name fields for audit readability.

Step 1.4 — Create Groups: k8s-admins, k8s-developers, k8s-viewers. Add users to groups.

Step 1.5 — Group Membership Mapper: Clients → kubernetes → Client scopes → dedicated scope → Add mapper → Group Membership.

Mapper FieldValue
Namegroups
Token Claim Namegroups
Full group pathOFF
Add to ID tokenON
Add to access tokenON
Full group path must be OFF: otherwise groups become /k8s-admins and your RBAC group oidc:k8s-admins won’t match (403 forever).


6. Phase 2 — Kubernetes API Server Configuration

Edit the static Pod manifest: /etc/kubernetes/manifests/kube-apiserver.yaml (kubeadm). Backup first.

FlagValuePurpose
--oidc-issuer-urlhttps://keycloak.mirecloud.com/auth/realms/mirecloudMust match JWT iss exactly
--oidc-client-idkubernetesJWT audience must include this
--oidc-username-claimemailKubernetes username
--oidc-username-prefixoidc:Avoid collisions
--oidc-groups-claimgroupsRBAC group extraction
--oidc-groups-prefixoidc:RBAC safety prefix
--oidc-ca-file/etc/kubernetes/pki/keycloak-ca.crtTrust Keycloak TLS (private CA)
yaml — kube-apiserver flags (excerpt)
spec:
  containers:
  - name: kube-apiserver
    command:
    - kube-apiserver
    - --oidc-issuer-url=https://keycloak.mirecloud.com/auth/realms/mirecloud
    - --oidc-client-id=kubernetes
    - --oidc-username-claim=email
    - "--oidc-username-prefix=oidc:"
    - --oidc-groups-claim=groups
    - "--oidc-groups-prefix=oidc:"
    - --oidc-ca-file=/etc/kubernetes/pki/keycloak-ca.crt
YAML trap: any flag value ending with a colon must be quoted (e.g. oidc:). Inline comments after values also corrupt strings.


7. Phase 3 — RBAC Authorization

After authentication, Kubernetes runs normal RBAC. With --oidc-groups-prefix=oidc:, a Keycloak group k8s-admins becomes oidc:k8s-admins to RBAC.

bash — RBAC bindings
kubectl create clusterrolebinding oidc-admins \
  --clusterrole=cluster-admin \
  --group=oidc:k8s-admins

kubectl create clusterrolebinding oidc-developers \
  --clusterrole=edit \
  --group=oidc:k8s-developers

kubectl create clusterrolebinding oidc-viewers \
  --clusterrole=view \
  --group=oidc:k8s-viewers
Keycloak GroupRBAC SubjectClusterRoleAllowed
k8s-viewersoidc:k8s-viewersviewread-only on most resources
k8s-developersoidc:k8s-developerseditmanage workloads; no cluster-wide RBAC control
k8s-adminsoidc:k8s-adminscluster-adminfull access

8. Phase 4 — Developer Workstation Setup

Developers need kubectl and the kubelogin plugin (v1.35.2 in this setup).

powershell — Windows
winget install int128.kubelogin
bash — macOS
brew install int128/kubelogin/kubelogin
bash — Linux (manual)
curl -LO https://github.com/int128/kubelogin/releases/download/v1.35.2/kubelogin_linux_amd64.zip
unzip kubelogin_linux_amd64.zip
sudo mv kubelogin /usr/local/bin/kubectl-oidc_login
sudo chmod +x /usr/local/bin/kubectl-oidc_login

Configure kubeconfig with exec credentials:

bash — configure OIDC exec
kubectl config set-credentials oidc-user \
  --exec-api-version=client.authentication.k8s.io/v1beta1 \
  --exec-command=kubectl \
  --exec-arg=oidc-login \
  --exec-arg=get-token \
  --exec-arg=--oidc-issuer-url=https://keycloak.mirecloud.com/auth/realms/mirecloud \
  --exec-arg=--oidc-client-id=kubernetes \
  --exec-arg=--oidc-extra-scope=email \
  --exec-arg=--oidc-extra-scope=groups \
  --exec-arg=--certificate-authority=/path/to/keycloak-ca.crt

kubectl config set-context --current --user=oidc-user
Don’t cheat: avoid --insecure-skip-tls-verify. If you’re using a private CA, pass the CA file properly.
bash — verify identity
kubectl auth whoami
bash — clear token cache with the command below.  that will set your environment on you machine. When typing a commande, for example : kubectl get node ; it will open the keycloak web page and ask you to authentify . See the video below
kubectl oidc-login clean \
  --oidc-issuer-url=https://keycloak.mirecloud.com/auth/realms/mirecloud \
  --oidc-client-id=kubernetes


9. Phase 5 

Host DNS requirement: kube-apiserver runs in host network namespace. If the host can’t resolve Keycloak, OIDC fails.

bash — /etc/hosts entry + connectivity
echo "192.168.2.204 keycloak.mirecloud.com" >> /etc/hosts
nc -zv keycloak.mirecloud.com 443
In our example, we already configure the machines with the dns server 192.168.2.74 

10. Phase 6 — Audit Logging & Grafana

With OIDC, audit logs now show a real identity (e.g. oidc:info@mirecloud.com) instead of kubernetes-admin. Ship logs to Loki via Promtail and query in Grafana.

yaml — /etc/kubernetes/audit-policy.yaml
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
  - level: RequestResponse
    users:
      - "oidc:*"

  - level: Metadata
    omitStages:
      - RequestReceived
    nonResourceURLs:
      - "/api*"
      - "/apis*"

  - level: None
    users:
      - "system:kube-proxy"
      - "system:kube-controller-manager"
      - "system:kube-scheduler"
    verbs:
      - watch
yaml — promtail pipeline stages (excerpt)
pipeline_stages:
  - json:
      expressions:
        user:       user.username
        verb:       verb
        resource:   objectRef.resource
        namespace:  objectRef.namespace
        name:       objectRef.name
        status:     responseStatus.code
        timestamp:  requestReceivedTimestamp

  - labels:
      user:
      verb:
      resource:
      namespace:
      status:

  - timestamp:
      source: timestamp
      format: RFC3339Nano
logql — useful queries
{job="k8s-audit"} | json | user="oidc:info@mirecloud.com"
{job="k8s-audit"} | json | verb=~"delete|patch|update" | user=~"oidc:.*"
{job="k8s-audit"} | json | user=~"oidc:.*" | status=~"4..|5.."

11. Troubleshooting Reference

SymptomRoot CauseFix
lookup keycloak.mirecloud.com: no such host Host DNS can’t resolve Keycloak; apiserver can’t use CoreDNS Add /etc/hosts entry on nodes; verify with nc -zv
OIDC discovery fails / resource not found --oidc-issuer-url doesn’t match discovery issuer exactly Compare discovery issuer character-for-character
Redirect URI is not valid (Keycloak) Redirect URIs entered incorrectly (one line, spaces) Add each URI on its own line (press Enter)
unauthorized_client (Keycloak) Client ID mismatch or Standard flow disabled Fix client ID; enable Standard flow
state does not match Old login link / wrong session / tunnel dropped Clear cache; redo login; keep tunnel stable
Silent 401 Unauthorized Inline YAML comment corrupted value OR CA not mounted Move comments above; verify CA volume mount & file presence
email not verified in apiserver logs Keycloak user has Email Verified OFF Toggle ON; clear cache; re-login
xdg-open not found on headless server No GUI browser installed Use SSH tunnel and open localhost URL on workstation
Works once, then 401 later Token expired; refresh token expired/revoked Clear cache; login again; tune realm token lifetime if needed

12. End-to-End Checklist

  • Realm mirecloud created; issuer matches apiserver flag
  • Client kubernetes: public (no secret), Standard Flow ON, redirect URIs correct
  • Users have Email Verified = ON
  • Users are in Keycloak groups (k8s-admins/k8s-developers/k8s-viewers)
  • Group mapper configured; Full group path OFF; claim name groups
  • JWT payload verified: email_verified=true and groups without leading slash
  • Apiserver has all --oidc-* flags; no inline comments; colons quoted
  • Keycloak CA present and mounted at /etc/kubernetes/pki/keycloak-ca.crt
  • ClusterRoleBindings created for oidc:k8s-* groups
  • kubectl auth whoami shows correct username + groups
  • Audit log shipped to Loki; Grafana shows named user actions

13. Key Takeaways

1Static certificates are a liability. Migrating to OIDC costs an afternoon; a cert compromise costs forever.
2email_verified is the silent killer. Check it first.
3YAML is a minefield. Quote anything ending with :. No inline comments after values.
4Full group path OFF is mandatory. Leading slashes break RBAC matching silently.
5SSH tunneling solves headless login cleanly — no GUI required.
6Keycloak-in-cluster requires host-level DNS for apiserver and host kubectl usage.
7OIDC turns audit logs into intelligence: “who did what, when” in seconds.
EC

Emmanuel Catin

Platform Engineer — Mirecloud

Stack: Kubernetes · Keycloak · Vault · Cilium · Grafana

Comments

Popular posts from this blog

FastAPI Instrumentalisation with prometheus and grafana Part1 [Counter]

welcome to this hands-on lab on API instrumentation using Prometheus and FastAPI! In the world of modern software development, real-time API monitoring is essential for understanding usage patterns, debugging issues, and ensuring optimal performance. In this lab, we’ll demonstrate how to enhance a FastAPI-based application with Prometheus metrics to monitor its behavior effectively. We’ve already set up the lab environment for you, complete with Grafana, Prometheus, and a PostgreSQL database. While FastAPI’s integration with databases is outside the scope of this lab, our focus will be entirely on instrumentation and monitoring. For those interested in exploring the database integration or testing , you can review the code in our repository: FastAPI Monitoring Repository . What You’ll Learn In this lab, we’ll walk you through: Setting up Prometheus metrics in a FastAPI application. Instrumenting API endpoints to track: Number of requests HTTP methods Request paths Using Grafana to vi...

Join Ubuntu 20.04 to Active Directory with SSSD and SSH Access

Join Ubuntu 20.04 to Active Directory with SSSD and SSH Access  Overview This guide walks you through joining an Ubuntu 20.04 machine to an Active Directory domain using SSSD, configuring PAM for AD user logins over SSH, and enabling automatic creation of home directories upon first login. We’ll also cover troubleshooting steps and verification commands. Environment Used Component Value Ubuntu Client       ubuntu-client.bazboutey.local Active Directory FQDN   bazboutey.local Realm (Kerberos)   BAZBOUTEY.LOCAL AD Admin Account   Administrator Step 1: Prerequisites and Package Installation 1.1 Update system and install required packages bash sudo apt update sudo apt install realmd sssd libnss-sss libpam-sss adcli \ samba-common-bin oddjob oddjob-mkhomedir packagekit \ libpam-modules openssh-server Step 2: Test DNS and Kerberos Configuration Ensure that the client can resolve the AD domain and discover services. 2.1 Test domain name resol...

Observability with grafana and prometheus (SSO configutation with active directory)

How to Set Up Grafana Single Sign-On (SSO) with Active Directory (AD) Grafana is a powerful tool for monitoring and visualizing data. Integrating it with Active Directory (AD) for Single Sign-On (SSO) can streamline access and enhance security. This tutorial will guide you through the process of configuring Grafana with AD for SSO. Prerequisites Active Directory Domain : Ensure you have an AD domain set up. Domain: bazboutey.local AD Server IP: 192.168.170.212 Users: grafana (for binding AD) user1 (to demonstrate SSO) we will end up with a pattern like this below Grafana Installed : Install Grafana on your server. Grafana Server IP: 192.168.179.185 Administrator Privileges : Access to modify AD settings and Grafana configurations. Step 1: Configure AD for LDAP Integration Create a Service Account in AD: Open Active Directory Users and Computers. Create a user (e.g., grafana ). Assign this user a strong password (e.g., Grafana 123$ ) and ensure it doesn’t expire. Gather Required AD D...