johanneskueber.com

OIDC authentication for Kubernetes on Talos

Every kubeconfig for my clusters used to be the same thing: a client certificate for system:masters, generated by talosctl kubeconfig, valid for a year. That certificate is effectively root on the cluster. It has no name attached to it, it cannot be revoked without rotating the cluster CA, and it ends up on every device I want to administrate from. Once I copied it to my phone I knew this was the wrong path. And since I already run a Keycloak for every other application in my homelab, I wantedto simply log in with the same SSO account as everything else.

Talos setup is surprisingly clean - if you avoid the legacy way of doing it.

The idea

Before starting, I wrote down the following considerations for the setup:

  • Login with my existing Keycloak account - no new credentials
  • Access is granted to a group, not to individual users
  • Anyone who is not in the right group is rejected before RBAC even sees them
  • No client secrets stored on laptops or phones (PKCE only)
  • Everything declarative through my talhelper-managed machine configs
  • The certificate kubeconfig stays in a drawer as break-glass access

The flow at the end looks like this: kubectl calls the kubelogin plugin, which opens a browser to Keycloak. After the login, Keycloak issues a JWT containing my username and groups. The API server validates the token against the Keycloak realm, maps the claims to a Kubernetes user, and RBAC takes over from there.

Note: Kubernetes has carried --oidc-issuer-url and friends as API server flags for many years, and most guides still use them. Do not. The structured AuthenticationConfiguration file replaces all of the flags, supports multiple issuers, and adds CEL validation rules the flags never could. The API server even refuses to start when both styles are mixed, so pick the file and be done with it.

The API server part

The API server needs two things: the authentication configuration as a file on disk, and a flag pointing at it. On Talos there is no SSH and no host filesystem to just drop files on - but the machine config can do both declaratively. One patch, applied to all control planes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
machine:
  files:
    - path: /var/kubernetes/oidc-auth-config.yaml
      permissions: 0o644
      op: create
      content: |
        apiVersion: apiserver.config.k8s.io/v1beta1
        kind: AuthenticationConfiguration
        jwt:
          - issuer:
              url: "https://keycloak.example.com/realms/homelab"
              audiences:
                - kubernetes
            claimMappings:
              username:
                claim: preferred_username
                prefix: "oidc:"
              groups:
                claim: groups
                prefix: "oidc:"
            userValidationRules:
              - expression: "'oidc:/homelab/k8s/talos' in user.groups"
                message: "user must be a member of /homelab/k8s/talos"

cluster:
  apiServer:
    extraArgs:
      authentication-config: /var/kubernetes/oidc-auth-config.yaml
    extraVolumes:
      - hostPath: /var/kubernetes/oidc-auth-config.yaml
        mountPath: /var/kubernetes/oidc-auth-config.yaml
        readonly: true

Some details:

  • machine.files: Talos writes the file onto the node. /var/ is the writable location for this; most other paths are part of the immutable OS image.
  • audiences: This must contain the OIDC client id. Keycloak sets the token audience to the client that requested it, and the API server rejects every token whose audience does not match. If these two disagree, every login fails with a rather unhelpful 401.
  • claimMappings: preferred_username becomes the Kubernetes username, groups become the Kubernetes groups. The oidc: prefix is important - without it, a Keycloak user or group could collide with built-in identities like system:masters. Never map external identities without a prefix.
  • userValidationRules: A CEL expression evaluated after the token is validated. Anyone who authenticates against the realm but is not in /homelab/k8s/talos is rejected here, with a clear message, before authorization even starts. Fail closed.
  • extraVolumes: The API server runs as a static pod and cannot see the host filesystem by itself - the file has to be explicitly mounted into it.

In my talhelper setup the patch lands in talconfig.yaml for the control planes only:

1
2
3
controlPlane:
  patches:
    - "@./patch-oidc.yaml"

After talhelper genconfig the patch is applied with the regular apply command. No reboot required - Talos restarts the kube-apiserver static pods and that is it.

The Keycloak part

On the Keycloak side I need a client and a group. The client is created in the realm with the following settings:

  • Client ID: kubernetes - remember, this must equal the audiences entry
  • Client authentication: off - it is a public client, there is no secret to leak
  • PKCE: S256, which is what makes the public client safe
  • Valid redirect URIs: http://localhost:8000 and http://localhost:18000 - kubelogin spins up a local listener on these ports to catch the callback

Then a Group Membership mapper is added to the client (or its dedicated scope): token claim name groups, Full group path enabled, added to the ID token. Full path matters - my validation rule checks for /homelab/k8s/talos, and without the flag Keycloak would send just talos.

Finally the group /homelab/k8s/talos is created and my own user becomes a member. Access to the cluster is now a Keycloak group membership: adding an account to the group grants access, removing it revokes access within token lifetime. No certificates involved.

The RBAC part

Authentication only established who I am - oidc:johnny with a set of oidc:-prefixed groups. Authorization is plain Kubernetes RBAC against exactly these strings:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: oidc-k8s-production-admin
subjects:
  - kind: Group
    name: "oidc:/homelab/k8s/talos"
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: cluster-admin
  apiGroup: rbac.authorization.k8s.io

Yes, this binds cluster-admin - for the admin group of a homelab that is the honest choice. The nice part is that nothing stops me from adding a second Keycloak group with a read-only ClusterRole, or a namespace-scoped RoleBinding for a single application. The granularity problem has moved from certificate management to group management, where it belongs.

The kubectl part

The client side is handled by kubelogin (the kubectl oidc-login plugin, installable via krew or brew). The kubeconfig gets a second user next to the certificate one:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
- name: oidc@talos
  user:
    exec:
      apiVersion: client.authentication.k8s.io/v1
      command: kubectl
      args:
        - oidc-login
        - get-token
        - --oidc-issuer-url=https://keycloak.example.com/realms/homelab
        - --oidc-client-id=kubernetes
        - --oidc-pkce-method=S256
      interactiveMode: IfAvailable

Some details:

  • exec plugin: kubectl runs kubelogin, which opens the browser on first use and caches the token afterwards - one login per token lifetime, not per command.
  • no client secret: The PKCE challenge replaces it. There is nothing in this file that needs protecting; it can live in every dotfiles repo.
  • interactiveMode: IfAvailable: Scripts and CI that cannot open a browser fail gracefully instead of hanging on a login prompt.

The proof:

1
2
3
4
$ kubectl --context oidc@talos auth whoami
ATTRIBUTE   VALUE
Username    oidc:johnny
Groups      [oidc:/homelab/k8s/talos system:authenticated]

The API server now knows who is talking to it. Audit logs show oidc:johnny instead of an anonymous admin certificate, and that alone was worth the effort.

Conclusion

The whole setup is three small pieces - a machine config patch, a Keycloak client, and a ClusterRoleBinding - and none of them hurt. My daily kubectl access now goes through the same SSO as every other application I host, access is granted and revoked in Keycloak group memberships, and the almighty certificate kubeconfig is retired to break-glass duty for the day Keycloak itself is down (do not delete it - when the identity provider lives inside the cluster, you will want that drawer).

The same pattern is repeated on my second cluster with its own group, so /homelab/k8s/talos and /homelab/k8s/edge can have different members. And because the API server simply accepts standard OIDC tokens now, other clients work too: mobile dashboards like kubenav authenticate against the same realm with nothing but their own client id. But that is a story for another post.


stat /posts/2026-08-23-oidc-kubernetes-talos/

2026-08-23: Initial publication of the article