Kubernetes OIDC Authentication and kubectl Access Control
Problem
Most Kubernetes clusters authenticate human users via static kubeconfig files containing long-lived client certificates. These certificates share several structural problems:
- No identity binding. A certificate CN might say
alice, but there is no link to the identity provider. When Alice leaves the organisation, her certificate remains valid until expiry (often 1 year). - No revocation. Kubernetes has no native CRL or OCSP for client certificates. Revoking access requires rotating the CA (affecting all users) or waiting for expiry.
- Shared certificates. Teams share admin kubeconfigs stored in wikis, password managers, or Slack. The certificate holder is “admin”, not a real person.
- No MFA. Client certificates cannot enforce MFA; possession of the certificate file is sufficient.
- Audit trail by cert CN, not identity. Kubernetes audit logs show
user: alicebut this is the certificate CN, not verified to be the real Alice.
OIDC authentication replaces this with tokens issued by an external identity provider (IdP) — Okta, Azure Entra, Google, Keycloak, Dex. Users authenticate to the IdP (with MFA), receive a short-lived JWT, and present it to the Kubernetes API server. The API server verifies the JWT signature against the IdP’s public keys. Token lifetime is typically 1 hour. When a user leaves, disabling their IdP account immediately blocks kubectl access — no certificate rotation required.
The specific gaps this article addresses: API server OIDC configuration, mapping IdP groups to Kubernetes RBAC roles, kubelogin credential plugin for transparent token refresh, and distributing kubeconfigs without embedding credentials.
Target systems: Kubernetes 1.28+ (OIDC authenticator stable); any OIDC-compliant IdP (Okta, Azure Entra ID, Google Workspace, Keycloak 22+, Dex 2.37+); kubelogin v0.1.4+ (kubectl credential plugin).
Threat Model
- Adversary 1 — Exfiltrated static kubeconfig: A developer’s laptop is compromised. Their kubeconfig contains a long-lived certificate granting cluster-admin. The attacker has permanent cluster access until the certificate expires or the CA is rotated.
- Adversary 2 — Departed employee retains access: An employee leaves but their client certificate remains valid. They continue to access the cluster for months.
- Adversary 3 — Shared admin kubeconfig: The cluster admin kubeconfig is stored in a shared wiki. An attacker with wiki access has cluster-admin. No audit trail identifies which human performed which action.
- Adversary 4 — Token theft after OIDC: An attacker steals a valid OIDC JWT from the user’s local token cache. The token is valid for its remaining lifetime (typically < 1 hour), then expires without refresh (refresh requires IdP re-authentication, which requires MFA).
- Adversary 5 — Group membership manipulation: An attacker compromises the IdP and adds themselves to a Kubernetes-mapped group (e.g.,
kubernetes-admins). They gain RBAC permissions tied to that group. - Access level: Adversaries 1–3 have the credential file. Adversary 4 has filesystem access on the user’s machine. Adversary 5 has IdP admin access.
- Objective: Execute arbitrary Kubernetes API operations, exfiltrate secrets, modify workloads, escalate privileges.
- Blast radius: Static kubeconfig theft = persistent cluster access. OIDC token theft = access limited to token lifetime (≤1h). OIDC with MFA enforcement = token theft requires MFA bypass to refresh.
Configuration
Step 1: Configure the API Server for OIDC
Add OIDC flags to the kube-apiserver configuration:
# /etc/kubernetes/manifests/kube-apiserver.yaml (kubeadm-managed cluster)
spec:
containers:
- name: kube-apiserver
command:
- kube-apiserver
# Existing flags ...
# OIDC configuration.
- --oidc-issuer-url=https://accounts.google.com
- --oidc-client-id=kubernetes-cluster-prod
- --oidc-username-claim=email
- --oidc-username-prefix=oidc:
- --oidc-groups-claim=groups
- --oidc-groups-prefix=oidc:
- --oidc-required-claim=hd=example.com # Restrict to your org's domain (Google).
For managed clusters (EKS, GKE, AKS), configure via the cluster API:
# EKS: configure OIDC provider.
eksctl utils associate-iam-oidc-provider \
--cluster prod-cluster \
--approve
# For kubectl OIDC (not IAM): use the API server OIDC flags via a custom config.
# EKS supports this via --oidc-issuer-url etc. in the cluster config.
eksctl create cluster \
--name prod-cluster \
--kubernetes-network-config apiServerConfig.oidc.issuerURL=https://your-idp.internal \
--kubernetes-network-config apiServerConfig.oidc.clientID=kubernetes
For Keycloak as the IdP:
# Create a Keycloak client for Kubernetes.
kcadm.sh create clients -r master \
-s clientId=kubernetes \
-s 'redirectUris=["http://localhost:8000"]' \
-s publicClient=false \
-s standardFlowEnabled=true \
-s directAccessGrantsEnabled=false
# Configure groups claim in the client mapper.
kcadm.sh create clients/{client-id}/protocol-mappers/models -r master \
-s name=groups \
-s protocol=openid-connect \
-s protocolMapper=oidc-group-membership-mapper \
-s 'config."claim.name"=groups' \
-s 'config."full.path"=false' \
-s 'config."access.token.claim"=true'
API server flags for Keycloak:
- --oidc-issuer-url=https://keycloak.internal/realms/master
- --oidc-client-id=kubernetes
- --oidc-username-claim=preferred_username
- --oidc-username-prefix=oidc:
- --oidc-groups-claim=groups
- --oidc-groups-prefix=oidc:
Step 2: Map IdP Groups to Kubernetes RBAC
With --oidc-groups-prefix=oidc:, group names from the JWT appear in Kubernetes as oidc:group-name. Bind them to roles:
# clusterrolebinding-devs.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: oidc-developers
subjects:
- kind: Group
name: "oidc:kubernetes-developers" # Maps to IdP group "kubernetes-developers".
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: view
apiGroup: rbac.authorization.k8s.io
---
# Namespace-scoped: developers can deploy in their team namespace.
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: oidc-team-a-deploy
namespace: team-a
subjects:
- kind: Group
name: "oidc:team-a"
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: edit
apiGroup: rbac.authorization.k8s.io
---
# SRE team: read-only cluster-wide + exec into pods in production.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: oidc-sre-readonly
subjects:
- kind: Group
name: "oidc:sre-team"
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: view
apiGroup: rbac.authorization.k8s.io
Custom ClusterRole for the SRE exec capability:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: pod-exec
rules:
- apiGroups: [""]
resources: ["pods/exec", "pods/portforward"]
verbs: ["create"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: oidc-sre-exec
subjects:
- kind: Group
name: "oidc:sre-team"
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: pod-exec
apiGroup: rbac.authorization.k8s.io
Step 3: Install and Configure kubelogin
kubelogin (also called kubectl-oidc_login) is a kubectl credential plugin that handles the OIDC browser-based authentication flow and token caching transparently:
# Install kubelogin.
# Linux.
curl -LO https://github.com/int128/kubelogin/releases/latest/download/kubelogin_linux_amd64.zip
unzip kubelogin_linux_amd64.zip && mv kubelogin /usr/local/bin/kubectl-oidc_login
# macOS via Homebrew.
brew install int128/kubelogin/kubelogin
# Verify.
kubectl oidc-login --help
Step 4: Distribute kubeconfig Without Embedded Credentials
Generate a kubeconfig that uses the credential plugin instead of embedding a certificate or token:
# kubeconfig-prod.yaml
apiVersion: v1
kind: Config
clusters:
- name: prod
cluster:
server: https://api.prod.k8s.example.com
certificate-authority-data: <base64-encoded-CA>
contexts:
- name: prod
context:
cluster: prod
user: oidc-user
current-context: prod
users:
- name: oidc-user
user:
exec:
apiVersion: client.authentication.k8s.io/v1beta1
command: kubectl
args:
- oidc-login
- get-token
- --oidc-issuer-url=https://accounts.google.com
- --oidc-client-id=kubernetes-cluster-prod
- --oidc-extra-scope=email
- --oidc-extra-scope=groups
- --grant-type=auto # Browser flow for interactive; device flow for CI.
env: null
interactiveMode: IfAvailable
This kubeconfig contains no credentials. When a user runs kubectl get pods, kubelogin opens a browser, the user authenticates to the IdP (with MFA), and the resulting JWT is cached locally with a 1-hour TTL. Subsequent kubectl commands within that hour use the cached token silently.
Distribute this kubeconfig via an internal portal, not as a personal certificate:
# Users download the kubeconfig from an internal portal.
# No per-user customisation needed — OIDC maps identity at the IdP level.
curl -s https://cluster-portal.internal/kubeconfig/prod > ~/.kube/config
chmod 600 ~/.kube/config
Step 5: Device Flow for CI/CD Pipelines
CI pipelines cannot perform interactive browser login. For automated contexts, use a service account token (separate from human OIDC) or the OIDC device flow with a dedicated CI client:
# For CI pipelines: use a dedicated Kubernetes service account with narrow RBAC.
# Do NOT use OIDC device flow with human credentials in CI.
apiVersion: v1
kind: ServiceAccount
metadata:
name: ci-deployer
namespace: team-a
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ci-deployer-edit
namespace: team-a
subjects:
- kind: ServiceAccount
name: ci-deployer
namespace: team-a
roleRef:
kind: ClusterRole
name: edit
apiGroup: rbac.authorization.k8s.io
Generate a short-lived token for CI:
# Generate a token with 1-hour expiry for the CI service account.
kubectl create token ci-deployer --namespace team-a --duration=3600s
This token is scoped to the team-a namespace and expires after 1 hour.
Step 6: Structured Audit Logging with OIDC Identities
With OIDC, audit log entries contain the real user identity from the IdP:
{
"apiVersion": "audit.k8s.io/v1",
"kind": "Event",
"user": {
"username": "oidc:alice@example.com",
"groups": ["oidc:kubernetes-developers", "oidc:team-a", "system:authenticated"]
},
"verb": "get",
"objectRef": {"resource": "secrets", "namespace": "production", "name": "db-password"},
"responseStatus": {"code": 200}
}
This is the key audit improvement: alice@example.com is a verified IdP identity, not just a certificate CN.
# kube-apiserver audit policy.
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
# Log all secret access at RequestResponse level.
- level: RequestResponse
resources:
- group: ""
resources: ["secrets"]
# Log all write operations on RBAC resources.
- level: RequestResponse
resources:
- group: "rbac.authorization.k8s.io"
resources: ["clusterrolebindings", "rolebindings"]
# Log all pod exec operations.
- level: RequestResponse
resources:
- group: ""
resources: ["pods/exec", "pods/portforward"]
# Minimal logging for read-only operations on non-sensitive resources.
- level: Metadata
verbs: ["get", "list", "watch"]
- level: None
users: ["system:kube-proxy"]
verbs: ["watch"]
resources:
- group: ""
resources: ["endpoints", "services"]
Step 7: Token Rotation and Session Management
# Clear cached tokens (force re-authentication at next kubectl command).
kubelogin clean
# List cached tokens.
ls ~/.kube/cache/oidc-login/
# Configure shorter token lifetime in the IdP.
# Keycloak: access token lifespan = 1 hour (default is 5 minutes in some configs).
kcadm.sh update realms/master -s accessTokenLifespan=3600
# Revoke a user's access: disable their IdP account.
# The current token remains valid for its remaining lifetime (<= 1h).
# Immediate revocation: add the token to a denylist via the API server's
# TokenReview webhook (advanced; requires a custom webhook).
For immediate revocation without a TokenReview webhook: reduce token lifetime to 5 minutes in the IdP. The cost is more frequent browser re-authentication for users.
Step 8: Telemetry
apiserver_authentication_attempts_total{result, authenticator} counter
apiserver_audit_event_total counter
oidc_token_cache_hit_total{user} counter
oidc_token_refresh_total{user, result} counter
kubectl_oidc_login_duration_seconds{user} histogram
Alert on:
apiserver_authentication_attempts_total{result="error", authenticator="oidc"}spike — OIDC auth failures; possible misconfiguration or token tampering attempt.- Static certificate CN appearing in audit logs — someone using an old kubeconfig with a cert instead of OIDC; track down and rotate.
- Unexpected group in RBAC binding — a new
oidc:group-namesubject appearing in a RoleBinding; review whether the group was intentionally created.
Expected Behaviour
| Signal | Static kubeconfig | OIDC authentication |
|---|---|---|
| Employee leaves | Certificate valid until expiry (up to 1 year) | Disable IdP account; next token refresh fails within 1 hour |
| Audit log identity | Certificate CN (unverified string) | Verified IdP email + group memberships |
| Stolen credential validity | Until certificate expiry | Token TTL remaining (≤ 1 hour) |
| MFA enforcement | Not possible with certificates | Enforced by IdP at every re-authentication |
| Credential rotation | Distribute new cert to each user | IdP handles; kubeconfig unchanged |
| First kubectl of the day | Instant (cert always present) | Browser opens; user authenticates; cached for 1 hour |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| OIDC browser flow | MFA; real identity in audit logs | Browser required for first daily auth | Acceptable for interactive use; use service accounts for CI. |
| 1-hour token lifetime | Short window of exposure if stolen | Re-authentication required each hour | kubelogin handles refresh silently if the IdP issues refresh tokens; only re-prompts when refresh expires. |
| IdP as dependency | Centralised access control | kubectl fails if IdP is unreachable | Maintain a break-glass static kubeconfig in a secure location for emergencies; never use day-to-day. |
| Group-based RBAC | Easy access control via IdP group management | IdP group changes propagate only on next token issue | Acceptable; token TTL bounds the lag. |
| kubelogin credential plugin | Transparent to kubectl usage | Requires installation on every developer workstation | Distribute via internal tooling or Homebrew tap. |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| IdP unreachable | kubectl fails with OIDC token fetch error |
User reports; monitoring on IdP availability | Use break-glass static kubeconfig; restore IdP; token cache may carry users for remaining TTL. |
| Clock skew between API server and IdP | JWT validation fails (iat or exp claim mismatch) |
apiserver_authentication_attempts_total{result="error"} spike |
Sync NTP on API server; typical leeway in kubelogin is 10s. |
| Group claim missing from JWT | User authenticates but has no RBAC permissions | 403 on all kubectl operations; missing group in kubectl auth whoami |
Add the groups mapper to the IdP client; re-authenticate to get a token with groups. |
| kubelogin not installed | kubectl: exec: "kubectl-oidc_login": executable file not found |
User reports; check which kubectl-oidc_login |
Distribute kubelogin as part of the developer onboarding tooling installation. |
| Token cache corrupted | Repeated browser prompts; or stale token used | kubelogin errors; kubelogin clean resolves |
kubelogin clean clears cache; user re-authenticates. |
| OIDC issuer URL changed | All OIDC auth fails; API server rejects tokens | Mass auth failure across all users | Update --oidc-issuer-url on API server; restart API server; users re-authenticate. |