EKS + Okta OIDC: SSO for kubectl That You Can Actually Reason About
— eks, aws, kubernetes, okta, oidc, security, authentication — 10 min read
Your team runs several EKS clusters. Engineers share IAM credentials or assume the same role, audit logs show generic principals, and when someone leaves you revoke access by hand across every cluster. The fix is to let people sign in with your identity provider, Okta, and have Kubernetes recognize them as themselves with the right groups. This post explains how that works on EKS, end to end, and gives you the exact setup. I have written it to teach the mechanism first, because once the flow is clear the configuration is short.
One thing to get straight up front, because it is the most common source of confusion: on EKS you do not configure OIDC by editing API server flags. The control plane is managed, so you cannot set --oidc-issuer-url and friends. Instead you associate an OIDC identity provider through the EKS API, and AWS wires it into the control plane for you.
Two ways to authenticate to EKS, one way to authorize
EKS has two authentication paths, and they answer different questions.
IAM authentication is the default and is always on. It proves "which AWS principal are you," using your IAM identity, and maps that principal to Kubernetes groups through the aws-auth ConfigMap or the newer access entries. It is the right tool for CI, controllers, and anything that already has an AWS identity.
OIDC authentication is what you add for humans. It proves "which Okta user are you," using an OpenID Connect ID token, and maps the token's claims to a Kubernetes username and groups. It is the right tool for engineers running kubectl, because they already log in to Okta and you already manage their group membership there.
The important part is that both paths feed the same authorization layer. Kubernetes RBAC does not care whether a request was authenticated by IAM or by OIDC. It only sees a username and a set of groups, and it checks those against your Roles and RoleBindings.
IAM identity ─┐ ├─▶ authenticated ─▶ (username, groups) ─▶ RBAC ─▶ allow / deny Okta ID token ─┘Keep this picture in mind. You are adding a second front door, not replacing the authorization system, and you should keep an IAM path working as a break-glass route in case Okta is unavailable.
OIDC in five minutes
OpenID Connect is a thin identity layer on top of OAuth 2.0. For our purposes it gives you one thing that matters: a signed ID token, which is a JWT that says who the user is.
A JWT has three parts joined by dots: a header, a payload, and a signature, each Base64URL-encoded. The payload is a set of claims. The ones Kubernetes cares about are:
iss, the issuer. This must match the issuer URL you told EKS about.aud, the audience. This must match your client ID.sub, a stable unique ID for the user.exp, the expiry. Tokens are short-lived, which is the point.email(or another claim), which you map to the Kubernetes username.groups, which you map to Kubernetes groups.
You never validate the signature by hand. The issuer publishes a discovery document at /.well-known/openid-configuration, which points to a JWKS (JSON Web Key Set) URL holding the public keys. A verifier fetches those keys and checks the token's signature against them. This is why the issuer must be publicly reachable: EKS has to fetch that JWKS to verify anything.
What actually happens when you run kubectl
Here is the full flow with Okta and EKS. Nothing about it is EKS-specific except the last hop, which is the point of the managed integration.
1. kubectl needs a token, so it runs the kubelogin exec plugin. 2. kubelogin opens a browser to Okta (OAuth2 authorization code + PKCE). 3. You log in to Okta; Okta redirects back with a code. 4. kubelogin exchanges the code for an id_token (a JWT) and caches it. 5. kubectl sends the request to the EKS API server with the id_token as a bearer token. 6. The API server (configured with your OIDC provider) verifies the token: signature against Okta's JWKS, plus iss, aud, and exp. 7. It maps the token to a user: username = usernamePrefix + email groups = groupsPrefix + each group claim 8. RBAC checks that user and groups against your Roles and Bindings.The client side, steps 1 through 4, is handled by kubelogin, the kubectl oidc-login credential plugin. The server side, steps 6 and 7, is what the EKS OIDC association turns on. PKCE (Proof Key for Code Exchange) is what lets a public client like a CLI do the code exchange safely without embedding a secret: kubelogin generates a random verifier, sends its hash on the authorization request, and proves possession of the verifier on the token exchange.
Setup, the correct way
There are four pieces: the Okta app, the EKS association, the RBAC bindings, and the kubeconfig. Do them in that order.
1. Okta: an OIDC app that emits a groups claim
Create an OIDC application in Okta for kubectl access. Use the authorization code flow with PKCE. Because kubelogin runs a local callback server, allow a loopback redirect URI such as http://localhost:8000 and http://localhost:18000.
The step people miss is the groups claim. By default the ID token does not contain the user's groups. On your authorization server (the default custom server at https://YOUR_ORG.okta.com/oauth2/default is the usual choice), add a claim named groups to the ID token, populated from the user's Okta groups, and scope it so it only includes the groups you actually use for cluster access. Okta group lists can be large, and an oversized token causes real problems, so filter with a regex rather than sending every group.
Then assign the relevant Okta groups to the application.
2. EKS: associate the identity provider
This is the line that replaces the mythical API server flags. You associate the provider through the EKS API. With the CLI:
aws eks associate-identity-provider-config \ --cluster-name my-cluster \ --oidc 'identityProviderConfigName=okta,\issuerUrl=https://YOUR_ORG.okta.com/oauth2/default,\clientId=0oaEXAMPLECLIENTID,\usernameClaim=email,\usernamePrefix=okta:,\groupsClaim=groups,\groupsPrefix=okta:'Or, preferably, in Terraform so it is reviewable and repeatable:
resource "aws_eks_identity_provider_config" "okta" { cluster_name = aws_eks_cluster.this.name
oidc { identity_provider_config_name = "okta" issuer_url = "https://YOUR_ORG.okta.com/oauth2/default" client_id = "0oaEXAMPLECLIENTID" username_claim = "email" username_prefix = "okta:" groups_claim = "groups" groups_prefix = "okta:" }}A few facts worth knowing before you run this. The issuerUrl must be HTTPS, publicly reachable, and equal to the iss claim in the tokens Okta issues. The clientId is the token's aud. Associating a provider is a cluster update: the cluster goes to UPDATING for a few minutes, and the change is not instant, so do not panic if the first login does not work for a moment. Each provider's issuerUrl and name must be unique on the cluster; you cannot associate the same provider twice. EKS now supports associating more than one external OIDC provider per cluster (up to ten), which is useful when employees, contractors, and CI use different identity sources.
3. RBAC: bind Okta groups to permissions
Now grant permissions. Because you set groupsPrefix: okta:, an Okta group named platform-admins shows up to Kubernetes as the group okta:platform-admins. Bind that.
apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRoleBindingmetadata: name: okta-platform-adminssubjects: - kind: Group name: okta:platform-admins # groupsPrefix + Okta group name apiGroup: rbac.authorization.k8s.ioroleRef: kind: ClusterRole name: cluster-admin # or a custom, narrower role apiGroup: rbac.authorization.k8s.ioFor most engineers you want least privilege, not cluster-admin. Prefer a namespaced Role plus RoleBinding so a group only gets access inside the namespaces it owns:
apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata: namespace: team-payments name: developerrules: - apiGroups: ["", "apps"] resources: ["pods", "deployments", "services", "configmaps"] verbs: ["get", "list", "watch", "create", "update", "patch"] # Note: no "secrets", no "delete" on workloads, scoped to one namespace.---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata: namespace: team-payments name: payments-developerssubjects: - kind: Group name: okta:team-payments apiGroup: rbac.authorization.k8s.ioroleRef: kind: Role name: developer apiGroup: rbac.authorization.k8s.ioThe prefixes are a security feature, not decoration. They namespace your OIDC identities so an Okta group can never accidentally collide with, or impersonate, a built-in Kubernetes group like system:masters.
4. kubeconfig: wire up kubelogin
Install the plugin (kubectl krew install oidc-login), then add a credential that runs it. kubectl calls this exec plugin whenever it needs a token, and kubelogin handles the browser login and token caching:
kubectl config set-credentials okta \ --exec-api-version=client.authentication.k8s.io/v1beta1 \ --exec-command=kubectl \ --exec-arg=oidc-login \ --exec-arg=get-token \ --exec-arg=--oidc-issuer-url=https://YOUR_ORG.okta.com/oauth2/default \ --exec-arg=--oidc-client-id=0oaEXAMPLECLIENTID \ --exec-arg=--oidc-extra-scope=email \ --exec-arg=--oidc-extra-scope=groupsPoint your context at that user, run any kubectl command, and a browser opens for the first login. After that, kubelogin serves a cached token and silently refreshes it until the refresh token expires, so day-to-day use has no prompts.
Operational reality: the parts that bite
Token lifetime and refresh. ID tokens are short-lived by design, often an hour. You do not write a refresh controller for this; kubelogin caches the token and uses the refresh token to get a new one. If logins start prompting constantly, check the refresh token lifetime on your Okta authorization server.
Keep IAM as break-glass. OIDC sits alongside IAM, it does not replace it. If Okta or the issuer is unreachable, OIDC logins fail, and you still need in. Keep a small, tightly controlled IAM access entry (or aws-auth mapping) for emergencies, and audit its use.
The groups claim is a real constraint. If a user is in hundreds of Okta groups and you send them all, the token can grow past header size limits and requests fail in confusing ways. Filter the groups claim to a curated set.
Association is a cluster update, and it is eventually consistent. After associate-identity-provider-config, give it a few minutes before the first login works, and expect the cluster to report UPDATING.
Audit it. Turn on the EKS control plane authenticator and audit logs to CloudWatch. Now every action is attributable to a real person (okta:jane@company.com) instead of a shared role, which was the entire reason for doing this.
Troubleshooting
Most failures are one of a few mismatches. Decode the token first to see what Okta actually sent:
# Grab the token kubelogin is using and decode its payload:kubectl oidc-login get-token \ --oidc-issuer-url=https://YOUR_ORG.okta.com/oauth2/default \ --oidc-client-id=0oaEXAMPLECLIENTID | jq -r '.status.token' \ | cut -d. -f2 | base64 -d 2>/dev/null | jq .Then match it against the common causes:
- Authenticated but forbidden. The token is valid but RBAC denies you. Check that the binding's group name includes the prefix (
okta:team-payments, notteam-payments), and that thegroupsclaim in the decoded token actually lists that group. audmismatch. The token'sauddoes not equal theclientIdyou associated. You are probably using a different Okta app than the one in the EKS config.- No groups at all. The decoded token has no
groupsclaim. The claim is not configured on the authorization server, or the scope is not requested. Add--oidc-extra-scope=groupsand configure the claim in Okta. issmismatch. TheissuerUrlin the EKS association does not exactly match the token'siss. The default custom auth server (/oauth2/default) and the org auth server have different issuers; pick one and use it in both places.- Clock skew. If server and client clocks drift far apart,
expornbfchecks fail. Keep NTP healthy.
Takeaways
- On EKS you associate an OIDC provider through the EKS API (
associate-identity-provider-configor Terraform), not through API server flags. The control plane is managed. - IAM and OIDC are two authenticators that both feed one authorizer. RBAC maps the resulting username and groups to permissions, and does not care how you authenticated.
- The client side is kubelogin (
kubectl oidc-login), a credential exec plugin that runs the browser login and caches tokens. - Configure a filtered
groupsclaim in Okta, keep group and username prefixes, and bind Okta groups to least-privilege namespaced Roles. - Keep an IAM break-glass path, turn on audit logs, and remember that association takes a few minutes to apply.