Back to Home

Homelab Rebuild Part 20: Headlamp SSO, or Teaching kube-apiserver to Trust Cloudflare Access

| October 15, 2026 | 7 min to read

Intro

After Part 19, OpenBao had a proper login: Google, through Cloudflare Access, with short-lived tokens. Headlamp, my Kubernetes UI, still wanted a pasted ServiceAccount token from kubectl create token. I wanted the same Google login there.

It looks like the same job, but it isn’t. OpenBao checks the ID token itself. Headlamp doesn’t: it forwards your token to the Kubernetes API server, so kube-apiserver is the one that has to trust Cloudflare Access.

TL;DR: A second Cloudflare Access SaaS app (OIDC) for Headlamp, a structured AuthenticationConfiguration on kube-apiserver that trusts that app’s tokens and maps my email to user oidc:<email>, and a ClusterRoleBinding for that user. The API server change went out to the three k0s controllers one at a time, with etcd, lease and controller count checks in between. SSO works, and the token login is still there for when Cloudflare or Google isn’t.

The Situation

  • k0s v1.36.4 with three controllers. kube-apiserver runs as a host process managed by k0s, not as a static pod, and its flags come from the k0s ClusterConfig (spec.api.extraArgs)
  • Headlamp 0.45.0 (chart 0.45.0), reachable on the tailnet at https://headlamp.<tailnet>.ts.net, plus a LAN hostname
  • Login before: a short-lived token for the headlamp-admin ServiceAccount, bound to cluster-admin
  • Cloudflare Access already set up with Google as an identity provider (Part 19)

Setup

How the token flows

browser -> Headlamp -> Cloudflare Access (OIDC app "Headlamp") -> Google
browser <- Headlamp <- ID token (aud = Headlamp client ID, email = me)
Headlamp -> kube-apiserver: Authorization: Bearer <ID token>
kube-apiserver: issuer and audience match? -> user oidc:<email> -> RBAC

I created a second SaaS app in Cloudflare Access rather than reusing OpenBao’s. Each app has its own client ID, which becomes the token’s audience, so a token issued for OpenBao can’t be replayed against the API server. The app has one redirect URL, https://headlamp.<tailnet>.ts.net/oidc-callback, because Headlamp accepts a single callback URL. SSO therefore works on the tailnet hostname, which reaches it from anywhere anyway.

kube-apiserver: structured authentication

Kubernetes now has a config file for this (--authentication-config), instead of the old --oidc-* flags:

apiVersion: apiserver.config.k8s.io/v1
kind: AuthenticationConfiguration
jwt:
  - issuer:
      url: https://<team>.cloudflareaccess.com/cdn-cgi/access/sso/oidc/<client-id>
      audiences:
        - <client-id>
    claimMappings:
      username:
        claim: email
        prefix: "oidc:"

The oidc: prefix keeps these users apart from anything else named after an email address. Certificates and ServiceAccount tokens are untouched: this only adds a trusted issuer.

In the repo, k0sctl.yaml gets the flag and uploads the file to the three controllers:

spec:
  k0s:
    config:
      spec:
        api:
          extraArgs:
            authentication-config: /etc/k0s/authentication-config.yaml
  hosts:
    - role: controller
      files:
        - name: apiserver-authentication-config
          src: k0s/authentication-config.yaml
          dstDir: /etc/k0s
          perm: "0644"

RBAC

The user name the API server sees is oidc:<my email>. One binding gives it the same rights the old token had:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: headlamp-oidc-admin
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
  - apiGroup: rbac.authorization.k8s.io
    kind: User
    name: oidc:<my email>

Giving a new identity cluster-admin and changing how the API server authenticates are the two parts of this I treated as needing an explicit yes. The change was ready and staged, and only went out after I approved it.

Headlamp: settings from OpenBao

The chart can read its OIDC settings from an existing Secret. The client ID, secret, issuer and callback URL went into OpenBao at secret/headlamp/oidc (the client secret straight from the clipboard into stdin, never printed), and an ExternalSecret renders the keys Headlamp expects:

target:
  name: headlamp-oidc
  template:
    data:
      OIDC_CLIENT_ID: "{{ .client_id }}"
      OIDC_CLIENT_SECRET: "{{ .client_secret }}"
      OIDC_ISSUER_URL: "{{ .issuer }}"
      OIDC_SCOPES: "openid,email,profile"
      OIDC_CALLBACK_URL: "{{ .callback_url }}"

One chart quirk: with an external secret, the chart only passes -oidc-callback-url if config.oidc.callbackURL is non-empty in the values, even though the actual value comes from the Secret. So the values hold a dummy string just to switch the flag on.

Rolling it out without losing the API

kube-apiserver only reads --authentication-config at start, so each controller’s k0s needs a restart. With three controllers and etcd on each, restarting them together would take the API down. One at a time, it never goes away. Per controller:

  1. Write /etc/k0s/authentication-config.yaml.
  2. Back up /etc/k0s/k0s.yaml, then add the extraArgs entry under api:.
  3. systemctl restart k0scontroller.
  4. Check that kube-apiserver runs with the flag and answers:
--authentication-config=/etc/k0s/authentication-config.yaml
apiserver http 401

401 is the healthy answer here: anonymous access is off, so an unauthenticated /readyz is refused by a running server.

  1. Before moving on, check that k0s counts three controllers again, etcd is healthy, and all controller leases are fresh:
count=3
[+]etcd ok
[+]etcd-readiness ok

Controller-01 at 04:42 UTC, controller-02 at 04:43, controller-03 at 04:44. The rollback for each one was a single command: restore the backup and restart.

Issue 1: The rollout was interrupted by a host going down

Symptom

Writing the Headlamp settings into OpenBao failed before anything was written:

error: unable to upgrade connection: error dialing backend:
dial tcp 192.168.101.51:10250: connect: no route to host

Cause

That address is a worker on pve-02, and pve-02 had lost power. That’s Part 21.

Fix

Wait. The API server change needs all three controllers healthy, and controller-02 lives on pve-02. Once pve-02 was back and controller-02 had renewed its lease, the rollout went ahead.

Result

The Headlamp login page now has Sign In (Cloudflare Access, then Google) and Use A Token side by side. Signing in lands in Headlamp as me, with the Flux view from Part 17.

Break-glass is intact on three levels:

If this failsStill works
Cloudflare or GoogleHeadlamp’s Use A Token with a headlamp-admin ServiceAccount token (checked: the API server still returns 200 for one)
Headlampkubectl through the Tailscale API proxy
Tailscalekubectl on the LAN to the API VIP with the admin client certificate

Lessons

  1. Know who validates the token. OpenBao validates OIDC tokens itself; Headlamp passes them on, so the API server has to trust the issuer.
  2. One app per audience. A separate Cloudflare Access app gives Headlamp’s tokens their own audience, so they can’t be used anywhere else.
  3. Structured authentication beats --oidc-* flags. One file, versioned in the repo, with room for more issuers later.
  4. Restart quorum members one at a time and check between. The controller count, etcd health and leases told me when it was safe to touch the next one.
  5. Changes that grant power deserve an explicit yes. Staging everything and approving the last step separately cost a few minutes and nothing else.
  6. Keep a way in that doesn’t depend on the new way in. SSO is the daily path; the token, Tailscale and the LAN certificate are the fallbacks.

Series

This post is part of Homelab Rebuild:

  1. Part 1: Upgrading Proxmox VE 8 to 9
  2. Part 2: Starting Over When the IaC Is Broken
  3. Part 3: When Tailscale Breaks Your Cilium Gateway
  4. Part 4: Living on the Tailnet
  5. Part 5: Secrets Without Secrets in Git
  6. Part 6: Certificates, DNS and a Private CA
  7. Part 7: Self-Hosted CI and Getting Back to IaC
  8. Part 8: Stateful Workloads on Unequal Hardware
  9. Part 9: Going Public Safely with Cloudflare Tunnel
  10. Part 10: When the Hypervisor Went Quiet
  11. Part 11: Hardening the Cluster in Five Layers
  12. Part 12: Observability with Coroot, and Losing pve-01
  13. Part 13: The Host That Kept Losing Power
  14. Part 14: Observability, Take Two with OpenTelemetry
  15. Part 15: Requests and Limits for Everything
  16. Part 16: Handing the Cluster to FluxCD
  17. Part 17: Making the Guardrails Earn Their Keep
  18. Part 18: When the API Server Outgrew Its VM
  19. Part 19: Logging In to OpenBao with Cloudflare Access and Google
  20. Part 20: Headlamp SSO, or Teaching kube-apiserver to Trust Cloudflare Access (this post)
  21. Part 21: pve-02 Loses Power, and Pi-hole Has Nowhere to Go
  22. Part 22: Turning Four Days of Fixes into AI Runbooks
  23. Part 23: microVM Sandboxes on Proxmox

References