Cluster auth with OIDC: every cluster trusts the same IdP (Keycloak, Okta, Azure AD). One set of credentials, mapped to k8s groups, mapped to RBAC roles. Get this right once and you never manage cluster credentials again.
Why OIDC
Without OIDC:
- Each user has a static cert/token in their kubeconfig
- Tokens expire, need rotation
- No central audit of who accessed the cluster
- No SSO, no MFA
- Service accounts use long-lived JWTs (legacy)
With OIDC:
- Users authenticate via SSO (Okta, Azure AD, Keycloak, etc.)
- Tokens are short-lived (15min-1hr), auto-refreshed
- Central audit (in your IdP)
- MFA, conditional access, etc.
- Service accounts use projected tokens (workload identity)
This is the production default. Static credentials should be a relic.
The flow
┌──────────────────────────────────────────────────────────────┐
│ │
│ 1. User runs: kubectl get pods │
│ ↓ │
│ 2. kubectl sees: kubeconfig has exec auth │
│ ↓ │
│ 3. kubectl runs the exec command (e.g. aws, gcloud, │
│ kubelogin) │
│ ↓ │
│ 4. exec command contacts IdP: │
│ - "I need a token for user alice in k8s cluster" │
│ ↓ │
│ 5. IdP authenticates alice: │
│ - Password + MFA │
│ - Group membership: alice is in "developers" │
│ ↓ │
│ 6. IdP issues a JWT signed by the IdP │
│ ↓ │
│ 7. kubectl sends the JWT to the apiserver │
│ ↓ │
│ 8. apiserver validates: │
│ - JWT signature is valid │
│ - JWT issuer matches configured OIDC issuer │
│ - JWT audience matches configured audience │
│ - JWT is not expired │
│ ↓ │
│ 9. apiserver extracts username + groups from JWT claims │
│ ↓ │
│ 10. apiserver checks RBAC: can this user do this? │
│ ↓ │
│ 11. Yes → return data │
│ No → 403 Forbidden │
│ │
└──────────────────────────────────────────────────────────────┘
The components
The IdP (Identity Provider)
Stores users, groups, credentials. Examples:
- Keycloak — open source, self-hosted
- Okta — commercial, popular
- Azure AD / Entra ID — for Azure shops
- Google Workspace — for GCP shops
- Auth0 — commercial
The IdP issues JWTs that the apiserver validates.
The OIDC client
Runs on the user’s machine (or CI runner). Handles the IdP login, token exchange, refresh.
Examples:
- kubelogin (
kubelogin) — generic OIDC client - aws (CLI) — uses AWS SSO / IAM Identity Center
- gcloud (CLI) — uses Google OIDC
- azure-cli — uses Azure AD
- Keycloak’s
kcfed— for Keycloak
The apiserver
Configured to trust the IdP. Reads the OIDC config (issuer URL, client ID, etc.), validates incoming JWTs.
The kubeconfig
Has an exec block that runs the OIDC client to get tokens.
Setting it up: Keycloak
Keycloak is the most common self-hosted IdP for k8s.
Step 1: Install Keycloak
# install via Helm
helm repo add bitnami https://charts.bitnami.com/bitnami
helm install keycloak bitnami/keycloak \
--namespace keycloak --create-namespace \
--set auth.adminUser=admin \
--set auth.adminPassword=xxxOr use the official Keycloak operator. Or use a managed Keycloak (e.g., Red Hat SSO).
Step 2: Create a realm
A realm is an isolated namespace in Keycloak. Create k8s-prod for production.
# via Keycloak admin UI or via API
curl -X POST http://keycloak:8080/admin/realms \
-H "Authorization: Bearer xxx" \
-H "Content-Type: application/json" \
-d '{"realm": "k8s-prod", "enabled": true}'Step 3: Create a client
The cluster is the client. The kube-apiserver is the audience.
# create a client in the realm
curl -X POST http://keycloak:8080/admin/realms/k8s-prod/clients \
-H "Authorization: Bearer xxx" \
-H "Content-Type: application/json" \
-d '{
"clientId": "kubernetes",
"publicClient": false,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"redirectUris": ["https://kubernetes.example.com/*"],
"webOrigins": ["*"]
}'Step 4: Create users and groups
In Keycloak:
- Create users (alice, bob, etc.)
- Create groups (developers, ops, sre)
- Add users to groups
- Map group claims to JWT
# create a group
curl -X POST http://keycloak:8080/admin/realms/k8s-prod/groups \
-H "Authorization: Bearer xxx" \
-H "Content-Type: application/json" \
-d '{"name": "k8s-developers"}'
# add user to group
curl -X PUT http://keycloak:8080/admin/realms/k8s-prod/users/<user-id>/groups/<group-id> \
-H "Authorization: Bearer xxx"Step 5: Configure the apiserver
The apiserver needs to know how to validate Keycloak-issued JWTs.
# apiserver flags
--oidc-issuer-url=https://keycloak.example.com/realms/k8s-prod
--oidc-client-id=kubernetes
--oidc-username-claim=preferred_username
--oidc-groups-claim=groups
--oidc-required-claim=hd=example.com # restrict to specific org (Google)
--oidc-signing-algs=RS256
--oidc-ca-file=/etc/ssl/certs/ca.crt # CA that signed Keycloak's certFor self-signed Keycloak certs:
--oidc-ca-file=/etc/keycloak/ca.crtStep 6: Configure RBAC
Map OIDC groups to k8s roles.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: developers-edit
subjects:
- kind: Group
name: k8s-developers # matches the Keycloak group name
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: edit
apiGroup: rbac.authorization.k8s.ioThe apiserver extracts the groups claim from the JWT and matches it to the subjects[].name in the RoleBinding.
Step 7: Configure the kubeconfig
The kubeconfig has an exec block that runs an OIDC client.
apiVersion: v1
kind: Config
clusters:
- name: prod
cluster:
server: https://api.example.com
certificate-authority-data: xxx
users:
- name: alice
user:
exec:
apiVersion: client.authentication.k8s.io/v1
command: kubelogin
args:
- get-token
- --oidc-issuer-url=https://keycloak.example.com/realms/k8s-prod
- --oidc-client-id=kubernetes
- --oidc-client-secret=xxx
- --oidc-extra-scope=email,profile,groups
contexts:
- name: prod
context:
cluster: prod
user: alice
current-context: prodkubelogin handles the OIDC dance. When kubectl runs it, it:
- Opens a browser to the IdP
- User logs in (MFA, etc.)
- IdP redirects with an auth code
- kubelogin exchanges for an ID token + refresh token
- kubelogin returns a bearer token to kubectl
- kubectl uses the token for the API call
- Token expires → kubelogin refreshes
For headless environments (CI, automation): use device-code flow or service account tokens instead.
Setting it up: cloud-managed IdP
EKS + IAM Identity Center
# enable IAM Identity Center
aws sso create-instance
# create a permission set
aws sso create-permission-set \
--name K8sAdmin \
--instance-arn <sso-instance-arn> \
--session-duration PT12H
# attach to your EKS cluster
aws eks create-access-entry \
--cluster-name my-cluster \
--principal-arn <user-or-group-arn>
# associate access policy
aws eks associate-access-policy \
--cluster-name my-cluster \
--principal-arn <user-or-group-arn> \
--access-scope cluster \
--policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicykubeconfig with aws-cli:
aws eks update-kubeconfig --name my-cluster --region us-east-1
# the resulting kubeconfig has an exec block using aws ssoGKE + Google Workspace
# create a Google group
gcloud identity groups create k8s-developers@example.com
# create a cluster with OIDC
gcloud container clusters create my-cluster \
--enable-stackdriver-kubernetes \
--enable-security-group \
--enable-legacy-authorization
# get credentials
gcloud container clusters get-credentials my-cluster
# the kubeconfig uses your Google credentialsAKS + Azure AD
# create an AKS cluster with Azure AD integration
az aks create \
--resource-group my-rg \
--name my-cluster \
--enable-aad \
--aad-admin-group-object-ids <group-id>
# get credentials
az aks get-credentials --resource-group my-rg --name my-clusterWorkload identity
For pods, not users. Pods need to authenticate to cloud APIs (S3, RDS, etc.) without static credentials.
AWS IRSA (IAM Roles for Service Accounts)
# 1. create an IAM role with a trust policy
aws iam create-role \
--role-name my-pod-role \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::xxx:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/xxx" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.us-east-1.amazonaws.com/id/xxx:sub": "system:serviceaccount:my-ns:my-sa"
}
}
}]
}'
# 2. annotate the ServiceAccount
apiVersion: v1
kind: ServiceAccount
metadata:
name: my-sa
namespace: my-ns
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::xxx:role/my-pod-roleThe pod’s ServiceAccount token (projected) is automatically exchanged for AWS credentials. No static creds.
GKE Workload Identity
# create a GCP service account
gcloud iam service-accounts create my-pod-sa \
--project my-project
# bind k8s SA to GCP SA
gcloud iam service-accounts add-iam-policy-binding \
--role roles/iam.workloadIdentityUser \
--member "serviceAccount:my-project.svc.id.goog[my-ns/my-sa]" \
my-pod-sa@my-project.iam.gserviceaccount.com
# annotate the k8s SA
apiVersion: v1
kind: ServiceAccount
metadata:
name: my-sa
namespace: my-ns
annotations:
iam.gke.io/gcp-service-account: my-pod-sa@my-project.iam.gserviceaccount.comAzure Workload Identity
# create a managed identity
az identity create --name my-pod-id --resource-group my-rg
# create a federated credential
az identity federated-credential create \
--name my-pod-fc \
--identity-name my-pod-id \
--resource-group my-rg \
--issuer $AKS_OIDC_ISSUER \
--subject system:serviceaccount:my-ns:my-sa
# annotate the k8s SA
apiVersion: v1
kind: ServiceAccount
metadata:
name: my-sa
namespace: my-ns
labels:
azure.workload.identity/client-id: <client-id-from-identity>The “kubelogin” depth
kubelogin is the most-used OIDC client for k8s.
Install:
brew install kubelogin
# or
kubectl krew install oidc-loginAuth flows:
- Interactive (browser) — opens browser, login, returns token. Default.
- Device code — prints a URL, user opens it on another device. For headless.
- Resource owner password — username/password direct. Avoid (not OIDC).
- Client credentials — service-to-service. For automation.
- Token file — pre-obtained token. For testing.
Common args:
kubelogin get-token \
--oidc-issuer-url=https://keycloak.example.com/realms/k8s-prod \
--oidc-client-id=kubernetes \
--oidc-client-secret=xxx \
--oidc-extra-scope=email,profile,groups \
--oidc-extra-scope=offline_access # for refresh tokenCommon RBAC patterns with OIDC
Developers (namespace-scoped)
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: developers
namespace: my-app
subjects:
- kind: Group
name: k8s-developers # OIDC group
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: edit # most namespace operations
apiGroup: rbac.authorization.k8s.ioSREs (cluster-wide read)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: sre-read
subjects:
- kind: Group
name: sre # OIDC group
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: view # read-only cluster-wide
apiGroup: rbac.authorization.k8s.ioPlatform admins (cluster-wide write)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: platform-admins
subjects:
- kind: Group
name: platform-admins
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.ioThe token expiration and refresh
OIDC tokens are short-lived (15min-1hr). After expiration:
- Refresh token (if
offline_accessscope is requested) lets kubelogin get a new ID token without user interaction. - No refresh token → user has to log in again.
For CI/CD: use long-lived service account tokens (legacy, deprecated) or projected tokens with explicit durations.
Common gotchas
- Group claim format varies. Keycloak uses
groups, Okta usesgroups(different default), Azure AD usesgroups(object IDs, not names). Map explicitly. - Refresh tokens require offline_access scope. Without it, the user is prompted to log in every hour.
- OIDC requires HTTPS. The issuer URL must be HTTPS. Self-signed certs need
--oidc-ca-file. - The
subclaim is the unique identifier. Don’t use email as the subject — emails change. - Group names with special characters can break RBAC matching. Stick to alphanumeric.
- Workload identity requires the cloud’s OIDC integration (EKS OIDC, GKE Workload Identity, AKS OIDC). It’s not just a config flag.
- The legacy long-lived ServiceAccount tokens are deprecated. Use projected tokens (bound to a pod, time-limited).
- The apiserver caches OIDC config. Changes to OIDC config require apiserver restart.
- Cross-tenant trust is complex. One IdP, multiple clusters is fine. Multiple IdPs, one cluster: use OIDC federation or multiple
--oidc-issuer-urlflags (not supported in all versions). - Kubelogin prints the device URL for headless auth. Make sure users know to copy it.
- The
--oidc-required-claimflag can restrict to a specific organization or tenant. Use it for multi-tenant IdPs.
A worked example
Company: mid-size SaaS, 50 engineers, 1 platform team, 2 production clusters (us, eu).
Setup:
- Keycloak (self-hosted in
identitynamespace) - One realm per environment (
k8s-prod,k8s-staging,k8s-dev) - One OIDC client per cluster (
kubernetes-prod-us,kubernetes-prod-eu, etc.) - Groups in Keycloak:
k8s-platform-admins— full cluster-admink8s-sre— read-only cluster-widek8s-developers-prod— namespace edit in prodk8s-developers-staging— namespace edit in staging
RBAC:
# SRE read
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: sre-read
subjects:
- kind: Group
name: k8s-sre
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: view
apiGroup: rbac.authorization.k8s.io
# Developers can do anything in team-a
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: team-a-developers
namespace: team-a
subjects:
- kind: Group
name: k8s-developers-prod
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: admin # namespace admin
apiGroup: rbac.authorization.k8s.ioOnboarding a new engineer:
- Platform team adds the engineer to Keycloak
- Engineer added to
k8s-sregroup - Engineer installs kubelogin
- Engineer’s kubeconfig has the OIDC config
- First
kubectl get podstriggers Keycloak login - Engineer is now in the cluster
No more “share the kubeconfig” emails.
See also
- security-baseline — auth in the security layer
- multi-tenancy — RBAC patterns
- context-switching — kubeconfig
- kubelogin
- Keycloak docs