Connecting kubectl to a Private EKS Cluster Over an Internal Domain
If you've ever run kubectl get pods against a corporate EKS cluster and hit Unauthorized, a hung connection, or an x509 certificate error, the frustrating part is that the failure could be coming from any of three completely different systems: your AWS identity, your local kubeconfig, or the network path to the API server. This post walks through all three, end to end, with a focus on the case that trips people up most: reaching the cluster through an internal DNS endpoint instead of the public one AWS hands you by default.
The three planes you're actually dealing with
"Connect kubectl to EKS" sounds like one action. It's really the composition of three independent control planes, and each has its own way to fail:
- Identity plane (AWS IAM) --- who you are, in AWS's eyes.
- Cluster access plane (kubeconfig + exec auth) --- how a Kubernetes-shaped credential gets minted from that AWS identity.
- Network and trust plane (DNS, routing, TLS) --- how your packets physically reach the API server, and how you verify you're actually talking to it.
A green result on one plane tells you nothing about the others. A successful aws sts get-caller-identity does not mean kubectl get pods will work --- that's a completely separate authorization step inside Kubernetes.
#mermaid-svg-vAirXzZsGCrx7HB6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vAirXzZsGCrx7HB6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vAirXzZsGCrx7HB6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vAirXzZsGCrx7HB6 .error-icon{fill:#552222;}#mermaid-svg-vAirXzZsGCrx7HB6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vAirXzZsGCrx7HB6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vAirXzZsGCrx7HB6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vAirXzZsGCrx7HB6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vAirXzZsGCrx7HB6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vAirXzZsGCrx7HB6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vAirXzZsGCrx7HB6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vAirXzZsGCrx7HB6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vAirXzZsGCrx7HB6 .marker.cross{stroke:#333333;}#mermaid-svg-vAirXzZsGCrx7HB6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vAirXzZsGCrx7HB6 p{margin:0;}#mermaid-svg-vAirXzZsGCrx7HB6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vAirXzZsGCrx7HB6 .cluster-label text{fill:#333;}#mermaid-svg-vAirXzZsGCrx7HB6 .cluster-label span{color:#333;}#mermaid-svg-vAirXzZsGCrx7HB6 .cluster-label span p{background-color:transparent;}#mermaid-svg-vAirXzZsGCrx7HB6 .label text,#mermaid-svg-vAirXzZsGCrx7HB6 span{fill:#333;color:#333;}#mermaid-svg-vAirXzZsGCrx7HB6 .node rect,#mermaid-svg-vAirXzZsGCrx7HB6 .node circle,#mermaid-svg-vAirXzZsGCrx7HB6 .node ellipse,#mermaid-svg-vAirXzZsGCrx7HB6 .node polygon,#mermaid-svg-vAirXzZsGCrx7HB6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vAirXzZsGCrx7HB6 .rough-node .label text,#mermaid-svg-vAirXzZsGCrx7HB6 .node .label text,#mermaid-svg-vAirXzZsGCrx7HB6 .image-shape .label,#mermaid-svg-vAirXzZsGCrx7HB6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-vAirXzZsGCrx7HB6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vAirXzZsGCrx7HB6 .rough-node .label,#mermaid-svg-vAirXzZsGCrx7HB6 .node .label,#mermaid-svg-vAirXzZsGCrx7HB6 .image-shape .label,#mermaid-svg-vAirXzZsGCrx7HB6 .icon-shape .label{text-align:center;}#mermaid-svg-vAirXzZsGCrx7HB6 .node.clickable{cursor:pointer;}#mermaid-svg-vAirXzZsGCrx7HB6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vAirXzZsGCrx7HB6 .arrowheadPath{fill:#333333;}#mermaid-svg-vAirXzZsGCrx7HB6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vAirXzZsGCrx7HB6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vAirXzZsGCrx7HB6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vAirXzZsGCrx7HB6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vAirXzZsGCrx7HB6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vAirXzZsGCrx7HB6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vAirXzZsGCrx7HB6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vAirXzZsGCrx7HB6 .cluster text{fill:#333;}#mermaid-svg-vAirXzZsGCrx7HB6 .cluster span{color:#333;}#mermaid-svg-vAirXzZsGCrx7HB6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vAirXzZsGCrx7HB6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vAirXzZsGCrx7HB6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-vAirXzZsGCrx7HB6 .icon-shape,#mermaid-svg-vAirXzZsGCrx7HB6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vAirXzZsGCrx7HB6 .icon-shape p,#mermaid-svg-vAirXzZsGCrx7HB6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vAirXzZsGCrx7HB6 .icon-shape .label rect,#mermaid-svg-vAirXzZsGCrx7HB6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vAirXzZsGCrx7HB6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vAirXzZsGCrx7HB6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vAirXzZsGCrx7HB6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Enterprise SSO / IdP
Assume IAM role
aws eks get-token via kubeconfig exec
HTTPS to EKS endpoint
IAM auth on control plane
Kubernetes RBAC authorization
If any stage in this chain fails, kubectl fails. The rest of this post sets up the first four stages so the last two have a chance to succeed.
Why an internal domain at all?
By default, aws eks update-kubeconfig points your cluster entry at the public EKS API endpoint AWS publishes for the cluster. That's fine for a personal sandbox. Inside an enterprise network it's often deliberately unreachable or undesired: private EKS endpoints, split-horizon DNS, internal load balancers, or a proxy/ingress in front of the control plane are all common reasons your platform team hands you an internal hostname instead --- something like k8s.<cluster>.internal.example.com --- and expects you to point your kubeconfig at that instead of the AWS-issued endpoint.
That rewrite is simple mechanically, but it changes two things at once: the route packets take, and the certificate your client needs to trust. Getting the first right and skipping the second is the most common footgun in this whole flow --- more on that below.
Prerequisites
- An enterprise identity that's federated into AWS (SAML via your corporate IdP, or another federation method) with a role that has EKS access.
- The AWS CLI and
kubectlinstalled locally. - The internal DNS hostname for the cluster's API server, and --- ideally --- the internal CA certificate that signs it, from your platform/networking team.
Step 1 --- Establish AWS identity (identity plane)
Everything downstream depends on you holding a valid, short-lived AWS credential. In a federated enterprise setup this typically means exchanging an SSO session for STS credentials --- see assuming-an-aws-iam-role-via-federation-sts.md for the full mechanics of that exchange. Conceptually:
bash
# however your organization federates --- SAML, OIDC, or a wrapper script around it
assume-role <account-id> --role <role-name>
Verify it worked before moving on:
bash
aws sts get-caller-identity
If this fails, nothing past this point can succeed --- aws eks get-token has no identity to mint a Kubernetes token from.
Step 2 --- Wire up kubeconfig (cluster access plane)
bash
aws eks update-kubeconfig --name <cluster-name> --region <region>
This isn't just a convenience alias --- it writes three things into ~/.kube/config:
clusters--- the API endpoint URL and certificate authority data.users--- anexec-based auth plugin entry that runsaws eks get-tokenon demand, rather than storing a static token.contexts--- binding a specific user entry to a specific cluster entry.
The generated user entry looks roughly like this:
yaml
users:
- name: arn:aws:eks:<region>:<account-id>:cluster/<cluster-name>
user:
exec:
apiVersion: client.authentication.k8s.io/v1beta1
command: aws
args:
- eks
- get-token
- --cluster-name
- <cluster-name>
- --region
- <region>
Here's the part that surprises people: kubectl does not carry a static bearer token around. Every time it needs to talk to the API server, it re-executes aws eks get-token, which returns a short-lived, wrapped GetCallerIdentity request signed with your current AWS credentials:
json
{
"kind": "ExecCredential",
"apiVersion": "client.authentication.k8s.io/v1beta1",
"spec": {},
"status": {
"expirationTimestamp": "2026-07-19T12:34:56Z",
"token": "k8s-aws-v1.<base64url-encoded-presigned-sts-call>"
}
}
The EKS authentication webhook validates that signed proof and extracts your AWS identity from it. That identity still has to be mapped to Kubernetes RBAC separately (via aws-auth / access entries) --- token validity alone grants nothing.
Step 3 --- Point the cluster entry at the internal endpoint (network/trust plane)
This is the step unique to internal-domain access. Your platform team's internal DNS entry needs to replace the default public endpoint in the clusters section of your kubeconfig.
The quick-and-dirty version, which you'll see in a lot of tribal-knowledge runbooks:
bash
kubectl config set-cluster arn:aws:eks:<region>:<account-id>:cluster/<cluster-name> \
--server=https://k8s.<cluster-name>.internal.example.com \
--insecure-skip-tls-verify=true
This works, and that's exactly the problem. --insecure-skip-tls-verify=true disables certificate validation entirely --- your client will happily hand its bearer token to anything answering on that hostname, with zero guarantee it's actually your cluster's API server. Inside a trusted internal network the practical risk is lower than it sounds, but it's still throwing away the one mechanism that proves endpoint authenticity, and it's easy to forget it's set until something goes wrong.
The version worth actually using:
bash
kubectl config set-cluster arn:aws:eks:<region>:<account-id>:cluster/<cluster-name> \
--server=https://k8s.<cluster-name>.internal.example.com \
--certificate-authority=/path/to/internal-ca.pem \
--embed-certs=true
Ask your platform/networking team for the internal CA certificate that signs the internal endpoint (or the internal load balancer/ingress in front of it). Pointing --certificate-authority at it keeps TLS verification intact --- you get the internal routing you need without giving up endpoint authenticity. Treat --insecure-skip-tls-verify as a debugging-only escape hatch, never a permanent config.
Verifying the whole chain
bash
aws sts get-caller-identity # confirms identity plane only
kubectl get pods -n <namespace> # exercises all three planes end-to-end
Only the second command actually proves the full path works --- DNS resolves, TLS trusts the endpoint, the token mints correctly, and your IAM identity is mapped to Kubernetes RBAC.
What happens on a real request
EKS Kubernetes API (internal endpoint) AWS STS/IAM exec plugin (aws eks get-token) kubectl User Shell EKS Kubernetes API (internal endpoint) AWS STS/IAM exec plugin (aws eks get-token) kubectl User Shell #mermaid-svg-gwl9ACEMaGXS8mzh{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-gwl9ACEMaGXS8mzh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gwl9ACEMaGXS8mzh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gwl9ACEMaGXS8mzh .error-icon{fill:#552222;}#mermaid-svg-gwl9ACEMaGXS8mzh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gwl9ACEMaGXS8mzh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gwl9ACEMaGXS8mzh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gwl9ACEMaGXS8mzh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gwl9ACEMaGXS8mzh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gwl9ACEMaGXS8mzh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gwl9ACEMaGXS8mzh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gwl9ACEMaGXS8mzh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gwl9ACEMaGXS8mzh .marker.cross{stroke:#333333;}#mermaid-svg-gwl9ACEMaGXS8mzh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gwl9ACEMaGXS8mzh p{margin:0;}#mermaid-svg-gwl9ACEMaGXS8mzh .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-gwl9ACEMaGXS8mzh text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-gwl9ACEMaGXS8mzh .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-gwl9ACEMaGXS8mzh .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-gwl9ACEMaGXS8mzh .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-gwl9ACEMaGXS8mzh .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-gwl9ACEMaGXS8mzh #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-gwl9ACEMaGXS8mzh .sequenceNumber{fill:white;}#mermaid-svg-gwl9ACEMaGXS8mzh #sequencenumber{fill:#333;}#mermaid-svg-gwl9ACEMaGXS8mzh #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-gwl9ACEMaGXS8mzh .messageText{fill:#333;stroke:none;}#mermaid-svg-gwl9ACEMaGXS8mzh .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-gwl9ACEMaGXS8mzh .labelText,#mermaid-svg-gwl9ACEMaGXS8mzh .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-gwl9ACEMaGXS8mzh .loopText,#mermaid-svg-gwl9ACEMaGXS8mzh .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-gwl9ACEMaGXS8mzh .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-gwl9ACEMaGXS8mzh .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-gwl9ACEMaGXS8mzh .noteText,#mermaid-svg-gwl9ACEMaGXS8mzh .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-gwl9ACEMaGXS8mzh .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-gwl9ACEMaGXS8mzh .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-gwl9ACEMaGXS8mzh .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-gwl9ACEMaGXS8mzh .actorPopupMenu{position:absolute;}#mermaid-svg-gwl9ACEMaGXS8mzh .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-gwl9ACEMaGXS8mzh .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-gwl9ACEMaGXS8mzh .actor-man circle,#mermaid-svg-gwl9ACEMaGXS8mzh line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-gwl9ACEMaGXS8mzh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} kubectl get pods -n <namespace> 1 Need credentials for active context 2 Use current role session to prove AWS identity 3 Signed identity proof / token material 4 ExecCredential bearer token (short-lived) 5 HTTPS request over internal DNS + Authorization: Bearer <token> 6 Authenticate IAM identity 7 Authorize via aws-auth/access entries + RBAC 8 Kubernetes API response 9
Caption: identity is established once (step 1--2); after that, every kubectl call independently re-derives a fresh token (step 3 in the diagram) and sends it over the internal route configured in step 3 above.
Reading the failure modes
Because three independent planes are involved, the error message usually tells you which one broke:
| Symptom | Likely plane | What to check |
|---|---|---|
You must be logged in to the server (Unauthorized) |
Authentication/authorization | Token minting, IAM role, aws-auth/access-entry mapping, RBAC bindings |
Unable to connect to the server |
Network | DNS resolution of the internal hostname, VPN/routing, firewall/security groups, private endpoint access |
| x509 / certificate errors | TLS trust | Whether --certificate-authority matches what the internal endpoint actually presents; a stale or wrong internal CA is the usual culprit |
get-caller-identity succeeds, kubectl still fails |
Authorization | Your AWS identity was never mapped into the cluster's RBAC --- this is a Kubernetes-side configuration, not an AWS one |
The three-question mental model
Keep these separate --- collapsing them is where most confusion comes from:
- Identity --- "Who are you in AWS?" Established by your federated role assumption.
- Authentication --- "Can you prove that identity to the EKS API?" Performed by
aws eks get-token, invoked automatically by the kubeconfigexecplugin. - Authorization --- "What are you allowed to do inside Kubernetes?" Controlled entirely by IAM-to-Kubernetes RBAC mapping, independent of whether authentication succeeded.
A cluster reachable over an internal domain doesn't change this model --- it just adds a fourth, purely mechanical question underneath all three: can your packets and your TLS handshake actually get there? Get that one right with a trusted CA instead of a bypassed check, and the rest of the chain behaves exactly like connecting to any other EKS cluster.