Dex is an OpenID Connect (OIDC) identity broker. It federates a GitHub login into standard OIDC ID tokens that the Kubernetes API server trusts. Combined with the existing Cloudflare Tunnel and the kubelogin (kubectl oidc-login) plugin, a single kubectl command triggers a GitHub browser login and only verified identities ever reach the home network.
The API server itself stays private: only the Dex OIDC endpoint is published through the tunnel. No router ports are opened.
How the pieces fit#
flowchart LR
subgraph local["Workstation"]
K[kubectl]
KL[kubelogin]
B[Browser]
end
subgraph cf["Cloudflare edge"]
T[(Tunnel: dex.mi-homes.org)]
end
subgraph cluster["Home cluster (private)"]
CFD[cloudflared]
D[Dex]
API[k3s API server]
end
GH[(GitHub OAuth)]
K -->|exec credential plugin| KL
KL -->|opens| B
B -->|OIDC authorize| T --> CFD --> D
D -->|federate| GH
GH -->|code| D
D -->|ID token| KL
KL -->|bearer token| API
API -->|verify iss/JWKS| T
kubectlinvokeskubeloginas a client-go credential plugin.kubeloginopens the browser to Dex athttps://dex.mi-homes.org(reached only through the tunnel).- Dex federates the login to GitHub; GitHub returns the user’s org/team membership.
- Dex mints an OIDC ID token (with
emailandgroupsclaims) and hands it back tokubelogin. kubectlsends the ID token to the API server. The API server validatesissand the signature against Dex’s discovery/JWKS endpoints (also fetched through the tunnel), then applies RBAC.
Cloudflare Tunnel as the stealth bridge#
The cluster already runs cloudflared (namespace cloudflare-tunnel) as a token-managed tunnel, so routing is configured in the Cloudflare Zero Trust dashboard, not in Git. Add a Public Hostname for Dex:
- Subdomain:
dex - Domain: your domain (
mi-homes.org) - Type:
HTTP - URL:
dex.dex.svc.cluster.local:5556
Cloudflare terminates TLS at the edge with a valid public certificate, so the API server needs no oidc-ca-file. Do not put Cloudflare Access in front of dex.mi-homes.org; the OIDC endpoints must be reachable directly for the discovery, token, and JWKS calls.
GitHub OAuth App#
Create a GitHub OAuth App (org mi-homes → Settings → Developer settings → OAuth Apps):
- Homepage URL:
https://dex.mi-homes.org - Authorization callback URL:
https://dex.mi-homes.org/callback
Store the credentials in Vault so External Secrets Operator can sync them into the dex namespace:
vault kv put secret/homelab/dex \
GITHUB_CLIENT_ID=<oauth-client-id> \
GITHUB_CLIENT_SECRET=<oauth-client-secret>The ExternalSecret in homelabs/dex/manifests/externalsecret.yaml materializes these into a Kubernetes Secret named dex-github, which the Dex Helm release reads via envVars. Dex expands $GITHUB_CLIENT_ID / $GITHUB_CLIENT_SECRET in its connector config at startup.
Dex deployment (GitOps)#
Dex is an Argo CD application (clusters/home-prod/argocd/applications/dex.yaml) using the upstream chart plus a public, shareable template base in homelabs/dex/ and a private per-environment overlay in homelabs-private at clusters/home-prod/overlays/dex/:
- Public base (
homelabs/dex/): genericvalues.yaml(placeholder issuerdex.example.com, orgyour-github-org),manifests/for the Namespace andExternalSecret, andrbac.yaml.templateas a starting point. - Private overlay (
homelabs-private/clusters/home-prod/overlays/dex/): the realvalues.yaml(issuerhttps://dex.mi-homes.org, GitHub orgmi-homes) and the realrbac.yamlgroup→role bindings.
The merged configuration:
- Issuer:
https://dex.mi-homes.org(must match the API serveroidc-issuer-urlexactly). - Storage:
kubernetes(CRD-backed) so refresh tokens survive restarts. - Connector: GitHub, scoped to the
mi-homesorg,teamNameField: slugso groups are emitted asorg:team(e.g.mi-homes:k8s-admins). - Static client:
kubernetes, declared public sokubeloginuses PKCE (no client secret on the workstation).
Create the GitHub teams that map to access levels:
k8s-admins→ cluster-admink8s-readonly→ read-only
Kubernetes API server (k3s)#
OIDC flags are set in the k3s server args (kubernetes/k3s-ansible/inventory/my-cluster/group_vars/all.yml):
--kube-apiserver-arg=oidc-issuer-url=https://dex.mi-homes.org
--kube-apiserver-arg=oidc-client-id=kubernetes
--kube-apiserver-arg=oidc-username-claim=email
--kube-apiserver-arg=oidc-username-prefix=oidc:
--kube-apiserver-arg=oidc-groups-claim=groups
--kube-apiserver-arg=oidc-groups-prefix=oidc:Re-run the playbook (or edit /etc/rancher/k3s/config.yaml and sudo systemctl restart k3s on each control-plane node). The oidc: prefixes namespace OIDC identities so they cannot collide with built-in users or groups; RBAC subjects therefore use names like oidc:mi-homes:k8s-admins.
Keep the legacy client-cert kubeconfig as a break-glass path until the OIDC flow is confirmed.
RBAC mapping#
homelabs-private/clusters/home-prod/overlays/dex/rbac.yaml binds the prefixed GitHub-team groups to cluster roles (the public base ships rbac.yaml.template as an example only):
| GitHub team (group claim) | RBAC subject | ClusterRole |
|---|---|---|
mi-homes:k8s-admins | oidc:mi-homes:k8s-admins | cluster-admin |
mi-homes:k8s-readonly | oidc:mi-homes:k8s-readonly | view |
Workstation: kubelogin#
Install the plugin so kubectl can find it:
kubectl krew install oidc-login # or download kubelogin and rename to kubectl-oidc_login on PATHAdd an OIDC user and context to your kubeconfig (keep the existing cert context as break-glass):
users:
- name: oidc-dex
user:
exec:
apiVersion: client.authentication.k8s.io/v1
command: kubectl
args:
- oidc-login
- get-token
- --oidc-issuer-url=https://dex.mi-homes.org
- --oidc-client-id=kubernetes
- --oidc-pkce-method=S256
- --oidc-extra-scope=email
- --oidc-extra-scope=groups
contexts:
- name: home-oidc
context:
cluster: default
user: oidc-dexkubelogin listens on http://localhost:8000 for the OAuth callback, which is why that URL is registered as a redirect URI on the kubernetes static client.
The login flow#
kubectl config use-context home-oidc
kubectl oidc-login clean # clear any cached token
kubectl get nodesRunning kubectl get nodes opens the browser to Dex, which redirects to GitHub. After you approve, Dex returns an ID token, kubelogin caches it, and kubectl reaches the private API server through the tunnel. Subsequent commands reuse the cached token until it expires, then silently refresh.
Confirm the claims and your effective access:
kubectl oidc-login setup --oidc-issuer-url=https://dex.mi-homes.org --oidc-client-id=kubernetes --oidc-pkce-method=S256
kubectl auth whoami
kubectl auth can-i '*' '*' --all-namespacesVerify and troubleshoot#
# Discovery reachable through the tunnel
curl -s https://dex.mi-homes.org/.well-known/openid-configuration | jq .issuer
# Dex pod and synced secret
kubectl -n dex get pods
kubectl -n dex get externalsecret dex-github
kubectl -n dex get secret dex-githubUnauthorized: the API serveroidc-issuer-urlmust match Dex’sissuerbyte-for-byte; the cluster must have egress to fetch discovery/JWKS.no groups: the user must be a member of the configured GitHub org/team and approve org access during the GitHub consent screen.- Forbidden after login: authentication worked but no RBAC binding matches the prefixed group; check the names in
rbac.yaml.