Prologue
For most GlueOps clusters, kube-apiserver sits behind a private network and the only way in is through our internal tooling. That works well until the moment a customer says: “I just want to kubectl get pods on my own cluster.”
At that point we have three bad options: ship them a long-lived ServiceAccount token (a credential we can never really rotate), give them a shared admin kubeconfig (auditing nightmare), or ask them to VPN in every time (friction we don’t want to charge them for).
What we actually want is boring and correct: the customer logs in with their existing SSO identity, kubectl gets a short-lived token, kube-apiserver validates it, and RBAC decides what they’re allowed to touch. No shared secrets, no long-lived credentials, and the public surface is small enough that we can sleep at night.
This post walks through how we wired that up on top of our existing GlueOps stack — Traefik as the ingress on the edge, Dex as the OIDC provider federating the customer’s identity, and kubeadm driving the api-server configuration. Every piece is boring on its own; the interesting part is how they fit together.
Opening the Gate: A TCP Passthrough to kube-apiserver
The first instinct when exposing anything through Traefik is to reach for an IngressRoute and let Traefik terminate TLS for us. That instinct is wrong here.
kube-apiserver speaks TLS with its own certificate — the same certificate that kubectl, kubelets and every controller in the cluster already trust. If Traefik terminated TLS and re-encrypted, we’d either have to teach every client to trust Traefik’s cert, or run api-server without TLS behind the proxy. Both are worse than what we started with.
The right primitive is IngressRouteTCP with tls.passthrough: true. Traefik becomes a dumb L4 forwarder that routes by SNI and hands the raw TLS stream to kube-apiserver. The api-server’s own certificate remains the one the client validates, and nothing about the client-side trust story changes.
Before we open the gate, we bolt on an IP allowlist. Even though every request must still authenticate through OIDC and pass RBAC, we don’t want the public internet spraying login attempts and CVE probes at the api-server. MiddlewareTCP gives us a simple source-range filter:
| |
Replace x.x.x.x/32 with whatever CIDRs the customer actually connects from — you can list as many as you need. Then the IngressRouteTCP that ties it all together:
| |
Two subtle points worth calling out:
- The
HostSNImatch works precisely because we settls.passthrough: true. Traefik peeks at the ClientHello, sees the SNI, and routes accordingly — it never breaks the TLS envelope open. - The
Servicein this snippet has no selector. In our clusters we point it at the api-server’s endpoints; if you’re running vanilla kubeadm, you can either add a selector matching the api-server pods or manage theEndpoints/EndpointSliceobject directly.
At this point, the door is open — but there’s nothing behind it yet that knows how to check IDs.
A Herald at the Door: Registering kubectl in Dex
Dex is already the OIDC broker for the rest of our platform (Argo CD, Grafana, Pomerium, etc.). All we need to do is teach it that kubectl is a legitimate client that will come knocking.
In our Dex helm values:
| |
The public: true bit is the important one. kubectl is a native binary running on the user’s laptop — there’s no server-side place to safely store a client secret. Marking it public tells Dex to accept the OAuth 2.0 device-code flow for this client and skip the client secret check. That’s the same flow the kubelogin plugin uses: pop open a browser, complete the login, and drop a short-lived ID token back into kubectl’s credential exec.
No callback URLs, no client secret to rotate — the herald is announced.
Teaching the Guard to Read the Herald’s Seal: Kubeadm Authentication Config
The api-server now needs two things: it has to be reachable at the public hostname, and it has to know how to validate the JWTs Dex hands out.
The certificate SAN
Since we’re about to serve TLS on kube-api.domain, the api-server’s serving certificate must include that name. Otherwise every kubectl call will fail the SNI/hostname check before authentication even runs. In the kubeadm ClusterConfiguration, we extend certSANs:
| |
The AuthenticationConfiguration
Historically you’d configure OIDC on the api-server with a pile of --oidc-* CLI flags. Starting from AuthenticationConfiguration (beta since 1.30), Kubernetes lets you point the api-server at a single YAML file that declares one or more JWT issuers, along with claim mappings and validation rules. We use it because it composes better with our GitOps flow and because the CEL validation rules let us enforce things like email_verified without writing a webhook.
Here’s the config we drop in — parameterized because Ansible fills in domain_name per environment:
| |
A few things worth noting:
audiences: [kubectl]mirrors theclient_idwe registered in Dex. If a token was issued for another client — say, Argo CD — the api-server will reject it. This scoping matters.- The
oidc:prefix onusernameandgroupsis what shows up in RBAC bindings later. It also prevents any collision with local ServiceAccount or system group names. - The CEL
claimValidationRulescleanly refuses tokens whereemail_verifiedisn’ttrue. If someone can register a fresh account in your IdP and hit the api-server before they’ve confirmed their email, that’s a real hole — this closes it.
Wiring the file into the api-server
There’s a small operational catch: kube-apiserver runs as a static pod, and it can only read files that live inside a directory that’s already mounted into the pod. The two directories kubeadm mounts by default are /etc/kubernetes/pki and /etc/kubernetes. To keep the config close to the other trust material and avoid touching the static-pod manifest by hand, we drop the file at /etc/kubernetes/pki/authentication-config.yaml.
Then we tell the api-server about it via an extraArgs entry in the kubeadm config:
| |
On a fresh kubeadm init, that’s all you need — the resulting static-pod manifest will already include the flag and the mount. On an existing cluster, the config change alone isn’t enough; we still need the api-server certificate to be regenerated so the new SAN takes effect. That’s the next section.
The Ceremony of Rotation: Reissuing Certs on an Existing Cluster
If you’re standing up a brand-new cluster, skip this section. kubeadm init will honor the new certSANs and extraArgs on the first pass.
If you’re modifying a cluster that’s already running — which is the usual case for us — kubeadm won’t retroactively add SANs to a cert that already exists on disk. You have to force a regeneration. Because we use Ansible to manage our kubeadm clusters, we wrapped the whole ceremony in a role. The full task file lives here: rotate-certs-with-config.yaml. The shape of it is:
| |
The order matters more than it looks. In particular:
- We delete
apiserver.crt/apiserver.keybefore runningkubeadm init phase certs apiserver. Kubeadm otherwise sees an existing cert and no-ops. upload-config kubeadmruns on exactly one master (groups['masters'][0]). Without that guard, three masters race to write the samekubeadm-configConfigMap.- The manifest is moved out of
/etc/kubernetes/manifestsand then back in. The kubelet watches that directory as its trigger for static-pod restart; a plainrestartof the container won’t pick up a new mounted file. Moving the manifest out kills the pod, moving it back in recreates it against the updated config.
Once /readyz returns ok, the api-server is running with the new SAN, the new authentication-config, and the OIDC gate is live.
The Scroll of Permissions: RBAC for OIDC Groups
At this point a validly-authenticated Dex user can reach the api-server, but they can’t do anything — no bindings, no permissions. That’s intentional. RBAC is where per-customer policy actually lives.
Because our claimMappings.groups mapped Dex groups to oidc:<group>, and we bind against those group names directly. The default set we ship gives read-only access, plus targeted exec and port-forward on pods in the default namespace:
| |
Three separate groups, three separate RoleBindings, all namespaced to default. That gives us fine-grained knobs — a customer that only needs to kubectl logs -f a pod gets slotted into ...-kubectl-reader alone, and can’t suddenly exec into a pod because someone forgot to remove them from another group.
We use ClusterRole even for namespaced permissions because it’s reusable across bindings (the built-in view ClusterRole is the canonical example); the RoleBinding is what actually scopes the grant to the default namespace.
For customers that need broader access, we template out variations of this file — more namespaces, additional verbs, or a ClusterRoleBinding where they genuinely need cluster-wide read. The mental model stays the same: Dex groups in, RBAC subjects out.
Handing the Key to the Customer: The Kubeconfig
The last piece is the artifact the customer actually uses. We generate it once on the cluster and hand it over — no shared secret embedded, no static token, just a pointer to the OIDC flow they’ll complete on their own machine.
First, they need kubectl with the oidc-login plugin (installed via krew):
| |
Then, on the cluster, we grab the CA data from an existing admin kubeconfig and emit a customer-facing one:
| |
Two things fall out of this shape:
- The
execcredential plugin meanskubectlshells out tokubectl oidc-login get-tokenon every call, caches the token, and refreshes it via the OIDC flow when it expires. No renewals to babysit. --grant-type=device-codematches thepublic: trueclient we registered in Dex. The user sees a URL and a short code, completes the login in their browser, and control returns to their terminal.- The
--oidc-extra-scope=groupsis not cosmetic — without it, Dex won’t include thegroupsclaim, and every RBAC binding we set up in the previous section would silently miss.
The kubeconfig.yaml we send the customer contains no secrets. If they lose it, no one gains any access — the actual gate is still the Dex login. That’s the whole point.
Summary
The story of exposing kube-apiserver for our customers came down to five moving parts, each doing exactly one job:
- Traefik +
IngressRouteTCPwith TLS passthrough — the door. Traefik forwards raw TLS by SNI, so the api-server’s own cert stays the trust anchor. AMiddlewareTCPipAllowListkeeps the public surface small. - Dex
staticClientforkubectl— the herald. Apublicclient that speaks the OAuth device-code flow, no secrets to share. - Kubeadm
AuthenticationConfiguration+ extendedcertSANs— the guard. The api-server learns to trust JWTs from Dex, mapped tooidc:usernames and groups, with a CEL rule that enforcesemail_verified. - RBAC
RoleBindings tooidc:...groups — the scroll. Per-customer permissions are just group memberships in Dex; RBAC decides what each group is allowed to touch. - A customer-facing kubeconfig using
oidc-login— the key. No embedded credentials; the customer authenticates through their own browser every time their token expires.
Every credential in this chain is short-lived, every permission is declarative, and the customer never holds a secret we can’t revoke by removing them from a group. That was the whole goal — and now kubectl get pods on a customer cluster just works, safely.
Referenced repositories and files:
- GlueKube: https://github.com/GlueOps/GlueKube
- Cert rotation Ansible task:
ansible/roles/master/tasks/rotate-certs-with-config.yaml kubelogin(kubectl oidc-login): https://github.com/int128/kubelogin- Kubernetes
AuthenticationConfiguration: https://kubernetes.io/docs/reference/access-authn-authz/authentication/#using-authentication-configuration
