Skip to content
HoginHogin
Go back

Keycloak in Kubernetes: one OIDC provider for kubectl, Argo CD and Grafana — self-hosted SSO

9 мин чтения

Count the login forms in your internal hosting: kubectl — client certificates, Argo CD — a built-in admin, Grafana — its own database, and every second internal service — Basic Auth from a ConfigMap. Separate passwords, separate “forgot password” flows, MFA nowhere. The reason to sort this out now is Keycloak 26.8.1, released October 13, 2026: it closed 23 CVEs, including masked client secrets leaking through the Admin REST API (CVE-2026-15943) and forged logout tokens (CVE-2026-18569). The 26.x line is actively moving — 26.7 brought a new client registration REST API — so it’s a good moment to get current and collapse the whole zoo into one OIDC provider.

Table of contents

Open Table of contents

Five admin panels — five passwords

The zoo grows quietly. First the cluster — access via a client certificate issued for a year and copied who-knows-where. Then Argo CD with its built-in admin and a password in a secret. Then Grafana, where every dashboard viewer gets an account in SQLite. Intermediate services get Basic Auth, because “it’s faster”.

Every point is a separate authentication surface with its own rules:

Five admin panels with separate passwords vs one Keycloak as the single entry point

The problem isn’t “weak passwords” — it’s that there are five authentication surfaces, and each needs its own ops.

What a single OIDC provider changes

Keycloak is an open source IAM server: realms (isolated user domains), clients (applications), groups and roles, built-in TOTP/WebAuthn, audit events. Anything that speaks OIDC plugs into it over the standard flow — authorization code + PKCE.

Concretely:

OIDC flow: the app redirects to Keycloak, the token with groups comes back, the app maps a role

Important: OIDC does not replace RBAC. The application (or apiserver) still decides what the admins role may do — Keycloak only answers “who is this”.

The zoo vs Keycloak

Basic Auth + local accountsDex + external IdPKeycloak
User storagenone / in ConfigMapnone, needs upstreamrealm: users, groups, policies
Password policy and rotationnoupstream’s problembuilt in
MFA (TOTP/WebAuthn)nono (or external)built in
kubectl without client certsnoyesyes
Disable one personN operations1 (in upstream)1
Login auditnopartialrealm events
Components in cluster01 small + LDAP/IdP1 JVM + Postgres
RAM for the stack~0~100 MB + upstream~1–2 GB

Dex is honest about being a connector — “OIDC in front, LDAP/Google/GitHub behind” — but it stores no users of its own: passwords and MFA live somewhere else anyway. Keycloak covers the whole chain by itself.

What you need to stand up SSO on your own cluster

Assumptions: your own cluster (kubeadm/Cluster-API/VMs — somewhere you can edit kube-apiserver flags), an ingress with TLS, the domain sso.example.com. We work in the keycloak namespace with the quay.io/keycloak/keycloak:26.8.1 image.

1. Postgres on CloudNativePG. Keycloak in production mode requires an external database; the dev-mode H2 is not for production. If CloudNativePG isn’t installed yet — covered in an earlier post; two instances are enough for SSO:

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: keycloak-db
  namespace: keycloak
spec:
  instances: 2
  storage:
    size: 10Gi
  bootstrap:
    initdb:
      database: keycloak
      owner: keycloak

2. Keycloak via the helm chart. The core values (secrets come from External Secrets, as described here):

production: true            # start --optimized, strict hostname
replicaCount: 2
image:
  tag: "26.8.1"
auth:
  adminUser: admin
  existingSecret: keycloak-admin
externalDatabase:
  host: keycloak-db-rw.keycloak.svc.cluster.local
  port: 5432
  database: keycloak
  user: keycloak
  existingSecret: keycloak-db-app
  existingSecretPasswordKey: password
postgresql:
  enabled: false            # the database is CNPG, not the nested chart
proxy: edge                 # TLS terminates at the ingress
ingress:
  enabled: true
  hostname: sso.example.com
  ingressClassName: nginx
  tls: true
helm upgrade --install keycloak oci://registry-1.io/bitnami/keycloak \
  --namespace keycloak --create-namespace -f values.yaml

After startup, create the homelab realm and enable the built-in groups client scope on your clients — without it the groups claim never lands in the token and group mapping fails silently. That’s the classic trap.

3. kube-apiserver. Flags on the control plane (kubeadm — /etc/kubernetes/manifests/kube-apiserver.yaml):

- --oidc-issuer-url=https://sso.example.com/realms/homelab
- --oidc-client-id=kubectl
- --oidc-username-claim=preferred_username
- --oidc-username-prefix=oidc:
- --oidc-groups-claim=groups
- --oidc-groups-prefix=oidc:

The oidc: prefixes prevent a realm group from impersonating system:masters. The apiserver must reach the issuer over TLS with a certificate it trusts; for a private CA add --oidc-ca-file. On managed clusters (EKS/GKE) these flags aren’t directly available — EKS has its own associate-identity-provider-config API — but this post is about your own iron. Worth noting: the classic --oidc-* flags are in maintenance mode; the new path is structured authentication (--authentication-config with a JWT authenticator), same fields.

The client in Keycloak: kubectl, public, redirect URI http://localhost:8000/* — that’s where kubelogin lands.

4. kubeconfig with kubelogin. The plugin opens a browser, you log into Keycloak (with MFA), tokens refresh on their own:

kubectl krew install oidc-login
# kubeconfig fragment
users:
  - name: oidc
    user:
      exec:
        apiVersion: client.authentication.k8s.io/v1
        command: kubectl
        interactiveMode: IfAvailable
        args:
          - oidc-login
          - gettoken
          - --oidc-issuer-url=https://sso.example.com/realms/homelab
          - --oidc-client-id=kubectl
          - --oidc-extra-scope=email
          - --oidc-extra-scope=groups

The role is plain RBAC, the subject a prefixed group:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: oidc-admins
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
  - kind: Group
    name: oidc:admins
    apiGroup: rbac.authorization.k8s.io

5. Argo CD. In the argo/argo-cd chart values:

configs:
  cm:
    oidc.config: |
      name: Keycloak
      issuer: https://sso.example.com/realms/homelab
      clientID: argocd
      clientSecret: $oidc.keycloak.clientSecret
      requestedScopes: ["openid", "profile", "email", "groups"]
  secret:
    extra:
      oidc.keycloak.clientSecret: "<from Keycloak>"
  rbac:
    policy.csv: |
      g, admins, role:admin
      g, viewers, role:readonly

The g, admins, role:admin line takes the group from the groups claim — the same realm groups as kubectl.

6. Grafana. Make the grafana client in Keycloak confidential (client id + secret). The grafana.ini section:

[auth.generic_oauth]
enabled = true
name = Keycloak
client_id = grafana
client_secret = $__env{GRAFANA_CLIENT_SECRET}
scopes = openid profile email groups
auth_url = https://sso.example.com/realms/homelab/protocol/openid-connect/auth
token_url = https://sso.example.com/realms/homelab/protocol/openid-connect/token
api_url = https://sso.example.com/realms/homelab/protocol/openid-connect/userinfo
role_attribute_path = contains(groups[*], 'admins') && 'Admin' || 'Viewer'
allow_sign_up = true

The same admins group grants cluster-admin in kubectl and Admin in Grafana — because the source is one.

Realm as code

Realm settings created by hand in the admin console are drift, same as kubectl edit. The fix is export:

kubectl -n keycloak exec keycloak-0 -- \
  /opt/keycloak/bin/kc.sh export --realm homelab \
  --dir /tmp/export --users realm_file

The file goes into git next to the helm values; changes land through a PR: initial bootstrap — mount a ConfigMap at /opt/keycloak/data/import and start with --import-realm, later from CI — partial import via the Admin REST API (policy SKIP/OVERWRITE), no pod restarts. Strip secrets from the export and keep them in External Secrets — git holds only references. The core of the file:

{
  "realm": "homelab",
  "enabled": true,
  "clients": [
    {
      "clientId": "kubectl",
      "publicClient": true,
      "redirectUris": ["http://localhost:8000/*"],
      "defaultClientScopes": ["profile", "email", "groups"]
    },
    {
      "clientId": "argocd",
      "secret": "${ARGOCD_CLIENT_SECRET}",
      "redirectUris": ["https://argocd.example.com/auth/callback"],
      "defaultClientScopes": ["profile", "email", "groups"]
    },
    {
      "clientId": "grafana",
      "secret": "${GRAFANA_CLIENT_SECRET}",
      "redirectUris": ["https://grafana.example.com/login/generic_oauth"],
      "defaultClientScopes": ["profile", "email", "groups"]
    }
  ],
  "groups": [
    { "name": "admins", "path": "/admins" },
    { "name": "viewers", "path": "/viewers" }
  ]
}

Realm in git: PR → CI applies via the Admin API, secrets arrive from External Secrets

The bill: JVM and memory

Honest accounting: Keycloak is a JVM. A replica with ~512 MiB heap for a realm of dozens of users and a few hundred clients is normal; budget a request of ~768 MiB and a limit of ~1.5 GiB per pod, two replicas for availability. Plus two CNPG Postgres pods at ~256 MiB each. The whole SSO stack is on the order of 2–3 GB RAM.

Why it’s still cheaper than the zoo: Basic Auth costs zero RAM, but every account, rotation and “forgot password” is a manual operation — times five systems in a row. Dex itself is ~100 MB, but it stores no users — you need an upstream: Google/GitHub (your data leaves) or self-hosted LDAP, which means OpenLDAP, its ops, and a separate MFA mechanism. One Keycloak covers the directory, password policies, TOTP/WebAuthn, brokering to external IdPs and audit — a single binary with one upgrade path.

How to verify it works

curl -s https://sso.example.com/realms/homelab/.well-known/openid-configuration | jq -r .issuer
kubectl auth whoami

Bottom line

One Keycloak for the whole internal panel means fewer authentication surfaces than any Basic-Auth-plus-Dex-shim combo: one provider answers “who is this”, kubectl/Argo CD/Grafana map the same admins group to their own roles, MFA and audit get enabled in one place, and the realm lives in git. The price is two JVM pods and an honest Postgres underneath — ~2–3 GB of RAM: cheap insurance against “the Grafana password was sitting in a ConfigMap”. If you haven’t stood up Postgres under Keycloak yet — the starter CloudNativePG setup is covered here.


Share this post:

Next Post
VictoriaMetrics instead of Prometheus+Thanos: one binary instead of three — when it's honest savings