How to Fix Kubernetes RBAC Forbidden: User Cannot List Resource (2026)

Oct 31, 2026 · 13 min · Ajay Kumar

This guide is for anyone who just hit Error from server (Forbidden): pods is forbidden: User "..." cannot list resource "pods" in API group "" in the namespace "...", whether from kubectl, a CI job, a controller or an AI agent's tool call. By the end you will know which identity was refused and which rule is missing, and you will have a least-privilege Role and RoleBinding that fixes it, plus the EKS, GKE and AKS steps for when the cluster is not the only authorizer. Commands match the Kubernetes v1.37 documentation (latest patch v1.37.2, released 28 October 2026) and the kubectl v1.32.3 help output I checked; the RBAC v1 API is identical on every supported version. You need kubectl access to the cluster, jq for Step 4, and for Step 3 an identity allowed to impersonate, which cluster admins usually are. For the concepts behind all this, read the companion Kubernetes RBAC explainer.

Five checks for: forbidden: User cannot list resource 1. Read error verb, resource, API group, ns 2. whoami Who does the API server see? 3. can-i --as Does RBAC say no as well? no 4. Fix RBAC Role + RoleBinding in that namespace 5. Verify can-i says yes, retry the request yes, still 403 Not RBAC inside the cluster Wrong context or token, an admission webhook denial, or the cloud layer: EKS access entries, GKE IAM, AKS Azure RBAC fix there
The order I debug RBAC denials in; each box maps to a step below. Commands come from the Kubernetes v1.37 docs and the EKS, GKE and AKS references linked in each step.

Step 1: Read the forbidden error message

The API server builds every authorization denial from one template in its source, so the message already names the missing rule:

Error from server (Forbidden): deployments.apps is forbidden: User "system:serviceaccount:ci:deployer" cannot list resource "deployments" in API group "apps" in the namespace "prod"

Read it field by field:

  • User "..." is the identity that was refused, and the subject your binding needs.
  • cannot list is the verb. kubectl get on a collection is list, on one object get, and -w adds watch. kubectl apply needs get, plus create for new objects and patch for existing ones.
  • resource "deployments" goes in resources. Subresources appear as pods/log or pods/exec.
  • in API group "apps" goes in apiGroups. An empty "" is the core group: pods, services, ConfigMaps, Secrets.
  • in the namespace "prod" means you need a RoleBinding in prod. at the cluster scope means a cluster-scoped resource or an all-namespaces request, which needs a ClusterRoleBinding.
  • cannot get path "/metrics" is a non-resource URL, granted with nonResourceURLs in a ClusterRole.

Two suffixes change the diagnosis. The RBAC authorizer appends its evaluation errors, so : RBAC: role.rbac.authorization.k8s.io "deployer" not found means a binding points at a role that does not exist. On GKE, a message ending requires one of ["container.roles.list"] permission(s). means neither RBAC nor IAM granted the action, per Google's troubleshooting page. And error: You must be logged in to the server (Unauthorized) is not RBAC at all: authentication failed before authorization ran.

Step 2: Confirm which identity the API server sees

A lot of RBAC debugging turns out to be fixing the wrong identity. Ask the API server rather than reading your kubeconfig:

# Which context is kubectl using?
kubectl config current-context

# Who the API server thinks you are (SelfSubjectReview API, stable since v1.28)
kubectl auth whoami

Output is similar to:

ATTRIBUTE   VALUE
Username    jane@example.com
Groups      [developers system:authenticated]

For a workload, check which service account the pod runs as. A pod without serviceAccountName runs as default:

kubectl get pod web-7d4b9c -n payments -o jsonpath='{.spec.serviceAccountName}{"\n"}'

For a token a CI job or agent uses, pass it explicitly:

kubectl auth whoami --token="$TOKEN"

On EKS the username comes from the access entry; by default it is the assumed-role STS ARN with {{SessionName}} filled in at login, and the groups are the entry's Kubernetes groups. If the identity is not the one you expected, fix the context, AWS profile or pod spec first and stop there.

Step 3: Reproduce the denial with kubectl auth can-i

kubectl auth can-i sends a SelfSubjectAccessReview, the same question the API server asked, and works whatever authorizers are configured. With --as you ask it as the failing identity:

SA=system:serviceaccount:payments:ci-deployer

# The exact check from the error. Prints yes or no.
kubectl auth can-i list pods -n payments --as="$SA"

# Group-qualified resources and subresources
kubectl auth can-i patch deployments.apps -n payments --as="$SA"
kubectl auth can-i get pods --subresource=log -n payments --as="$SA"
kubectl auth can-i create pods --subresource=exec -n payments --as="$SA"

# Everything this identity may do in the namespace
kubectl auth can-i --list -n payments --as="$SA"

# Exit code only (0 = yes, 1 = no) for CI gates
kubectl auth can-i patch deployments.apps -n payments --as="$SA" -q && echo allowed

The --list output looks like this (abbreviated) for a service account with no grants beyond the defaults:

Resources                                       Non-Resource URLs   Resource Names   Verbs
selfsubjectreviews.authentication.k8s.io        []                  []               [create]
selfsubjectaccessreviews.authorization.k8s.io   []                  []               [create]
                                                [/api/*]            []               [get]

Three caveats. --as needs the impersonate permission, and for human users you may need --as-group too. The API reference warns that --list "may be incomplete depending on the server's authorization mode". And on EKS, AWS documents that --list omits access-policy permissions and that --as forces RBAC-only evaluation, so an access-policy user can be told no and still succeed for real.

If can-i says no, continue to Step 4. If it says yes and the real request still fails, jump to Steps 6 to 8.

Step 4: Find the bindings that already exist

Before writing anything new, find out what the identity already has. This lists every binding that names the service account, in any namespace:

kubectl get rolebindings,clusterrolebindings -A -o json | jq -r \
  --arg ns payments --arg sa ci-deployer '
  .items[]
  | select(any(.subjects[]?; .kind == "ServiceAccount" and .name == $sa and .namespace == $ns))
  | "\(.kind)/\(.metadata.name) ns=\(.metadata.namespace // "cluster-wide") -> \(.roleRef.kind)/\(.roleRef.name)"'

Against my test fixture (jq 1.7.1) it prints one line per binding:

RoleBinding/ci-deployer ns=payments -> Role/deployer

For a human, match .kind == "User" or "Group" instead. Then read what the role actually grants:

kubectl get role deployer -n payments -o yaml
kubectl describe rolebinding ci-deployer -n payments

If you have krew, kubectl rbac-tool policy-rules -e '^system:serviceaccount:payments:ci-deployer$' from rbac-tool flattens every rule for a subject, and kubectl who-can list pods -n payments answers the reverse question. The usual findings: the binding is in the wrong namespace; the ServiceAccount subject has no namespace; roleRef.kind says Role for something that is a ClusterRole (the built-in view, edit and admin are ClusterRoles, so --role=view points at nothing); or the role has get but not list, or "" where it needed apps.

Step 5: Write a least-privilege Role and RoleBinding

Grant exactly what the error asked for, in the namespace it named:

# payments-deployer.yaml (rbac.authorization.k8s.io/v1, Kubernetes 1.30 to 1.37)
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: deployer
  namespace: payments
rules:
- apiGroups: [""]                   # core group: the error said API group ""
  resources: ["pods", "pods/log"]
  verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
  resources: ["deployments"]
  verbs: ["get", "list", "watch", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ci-deployer
  namespace: payments               # where access is granted
subjects:
- kind: ServiceAccount
  name: ci-deployer
  namespace: payments               # where the service account lives; required
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: deployer
kubectl apply -f payments-deployer.yaml
kubectl auth can-i list pods -n payments --as=system:serviceaccount:payments:ci-deployer
# yes

To generate YAML instead of writing it, use --dry-run=client. kubectl create role validates resource names against the live API, so it needs cluster access; kubectl create rolebinding does not:

kubectl create role deployer -n payments --verb=get,list,watch --resource=pods,pods/log \
  --dry-run=client -o yaml > role.yaml
kubectl create rolebinding ci-deployer -n payments --role=deployer \
  --serviceaccount=payments:ci-deployer --dry-run=client -o yaml

The second command printed this with kubectl v1.32.3 and an empty kubeconfig, which shows it never contacts the cluster:

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  creationTimestamp: null
  name: ci-deployer
  namespace: payments
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: deployer
subjects:
- kind: ServiceAccount
  name: ci-deployer
  namespace: payments

Three variations cover most other cases:

# Reuse a built-in ClusterRole inside one namespace (note --clusterrole, not --role)
kubectl create rolebinding ci-view -n payments --clusterrole=view \
  --serviceaccount=payments:ci-deployer

# Access to another namespace: the binding lives in the target namespace,
# the subject keeps its home namespace
kubectl create rolebinding reports-reader -n analytics --clusterrole=view \
  --serviceaccount=payments:ci-deployer

# Cluster-scoped resources or -A: ClusterRole plus ClusterRoleBinding
kubectl create clusterrole node-reader --verb=get,list,watch --resource=nodes
kubectl create clusterrolebinding ci-node-reader --clusterrole=node-reader \
  --serviceaccount=payments:ci-deployer

Two errors can appear while applying. user "..." (groups=[...]) is attempting to grant RBAC permissions not currently held is escalation prevention: you can only grant permissions you hold, so have someone who holds them apply it, and do not solve it by handing out escalate or bind. And roleRef is immutable, so changing a binding's role fails with field is immutable; delete and recreate the binding, or use kubectl auth reconcile -f, which does that for you.

Step 6: Fix service account and token problems

If the error names system:serviceaccount:<namespace>:default, the pod has no serviceAccountName. Create a dedicated account, bind it, and set it in the pod template:

kubectl create serviceaccount web -n payments
kubectl patch deployment web -n payments \
  -p '{"spec":{"template":{"spec":{"serviceAccountName":"web"}}}}'

If an in-cluster client fails with open /var/run/secrets/kubernetes.io/serviceaccount/token: no such file or directory, the token was never mounted: automountServiceAccountToken: false is set on the pod or its service account. Set it to true on the pod spec for workloads that genuinely call the API.

For CI jobs and agents outside the cluster, mint a short-lived token with the TokenRequest API and test it before handing it over:

TOKEN=$(kubectl create token ci-deployer -n payments --duration=15m)
kubectl auth whoami --token="$TOKEN"
kubectl auth can-i patch deployments.apps -n payments --token="$TOKEN"

The kubectl help notes the server may return a longer or shorter lifetime than requested. Better still for CI is no Kubernetes token at all: authenticate with the CI provider's OIDC token to the cloud, as in GitHub Actions OIDC with AWS, GCP and Azure, and map that role in Step 7 or 8. Old Secret-based tokens cause a different symptom: since v1.24 none are created automatically, and the legacy token cleaner invalidates unused ones after a year, which returns Unauthorized, not Forbidden.

One upgrade trap: since v1.35, WebSocket exec, attach and port-forward need create on the subresource, where get used to be enough. If kubectl exec broke after an upgrade, add this rule:

- apiGroups: [""]
  resources: ["pods/exec"]
  verbs: ["create"]          # enforced for WebSocket upgrades since v1.35

Step 7: Fix forbidden errors on EKS with access entries

On EKS, an IAM principal reaches Kubernetes through an access entry (the aws-auth ConfigMap is deprecated). Check what the cluster knows about the principal:

# Which IAM principal is kubectl using? (returns the assumed-role STS ARN)
aws sts get-caller-identity --query Arn --output text

# Access entries need authenticationMode API or API_AND_CONFIG_MAP
aws eks describe-cluster --name my-cluster \
  --query 'cluster.accessConfig.authenticationMode' --output text

# Entry and attached policies use the IAM role ARN, not the STS ARN
aws eks describe-access-entry --cluster-name my-cluster \
  --principal-arn arn:aws:iam::111122223333:role/ci-deployer
aws eks list-associated-access-policies --cluster-name my-cluster \
  --principal-arn arn:aws:iam::111122223333:role/ci-deployer

Then grant access one of two ways, using AWS CLI 2.12.3 or later as the access policy docs require:

# Option A: an AWS-managed access policy, scoped to one namespace
aws eks create-access-entry --cluster-name my-cluster \
  --principal-arn arn:aws:iam::111122223333:role/ci-deployer --type STANDARD
aws eks associate-access-policy --cluster-name my-cluster \
  --principal-arn arn:aws:iam::111122223333:role/ci-deployer \
  --access-scope type=namespace,namespaces=payments \
  --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy

# Option B: map the role to a Kubernetes group and bind your own Role (Step 5)
aws eks create-access-entry --cluster-name my-cluster \
  --principal-arn arn:aws:iam::111122223333:role/ci-deployer \
  --type STANDARD --kubernetes-groups payments-deployers
kubectl create rolebinding payments-deployers -n payments --role=deployer \
  --group=payments-deployers

The gotchas from the access entry docs: EKS does not check that your group names match any binding, so a typo fails silently; entries are eventually consistent and can take several seconds; and if an IAM role is deleted and recreated with the same ARN, the old entry no longer matches and must be recreated. Turning access entries on with aws eks update-cluster-config --name my-cluster --access-config authenticationMode=API_AND_CONFIG_MAP cannot be reversed to ConfigMap-only mode. If IAM itself is refusing the AWS calls, my guide to AWS AccessDenied errors covers that layer.

Step 8: Fix forbidden errors on GKE and AKS

On GKE, RBAC is checked first, and if no RBAC policy allows the action, IAM is checked. The container.clusters.get permission only lets you authenticate. The cleanest fix is a small IAM role to get in plus RBAC for the actions, with the Google account email as the user name:

gcloud projects add-iam-policy-binding my-project \
  --member=user:jane@example.com --role=roles/container.clusterViewer
kubectl create rolebinding jane-view -n payments --clusterrole=view \
  --user=jane@example.com

For groups, Google Groups for RBAC requires each group to be a member of gke-security-groups. If you grant through IAM instead, note that roles/container.viewer excludes Secrets and roles/container.developer includes them.

On AKS with Azure RBAC for Kubernetes authorization (preconfigured on AKS Automatic), the grant is an Azure role assignment, which can be scoped to a namespace:

AKS_ID=$(az aks show --resource-group my-rg --name my-aks --query id --output tsv)
az role assignment create --role "Azure Kubernetes Service RBAC Reader" \
  --assignee jane@example.com --scope "$AKS_ID/namespaces/payments"

Microsoft says new assignments can take up to five minutes to propagate, so wait before retesting. RBAC Reader excludes Secrets; RBAC Writer can read them.

Troubleshooting

Symptom Likely cause Fix
cannot list resource "pods" in API group "" in the namespace "x" No binding grants list on pods in x to that identity Role with apiGroups: [""], resources: ["pods"], verbs: ["list"] plus a RoleBinding in x
... at the cluster scope -A, a cluster-scoped resource, or a missing -n ClusterRole plus ClusterRoleBinding, or pass -n
User is system:serviceaccount:<ns>:default Pod has no serviceAccountName Set serviceAccountName and bind the dedicated account
Binding exists, can-i still says no Wrong namespace, subject missing namespace, or roleRef.kind: Role for a ClusterRole such as view Recreate the binding with --clusterrole and the full subject
Ends in RBAC: role.rbac.authorization.k8s.io "x" not found Binding points at a deleted or misspelled role Create the role or fix roleRef.name
attempting to grant RBAC permissions not currently held You lack the permissions you are granting Apply as an identity that holds them; do not grant escalate or bind
roleRef: ... field is immutable Editing a binding's role Delete and recreate it, or kubectl auth reconcile -f
kubectl exec forbidden after upgrading to v1.35 WebSocket exec now needs create on pods/exec Add create on pods/exec
You must be logged in to the server (Unauthorized) Authentication, not RBAC: expired token, no EKS access entry, invalidated legacy token Refresh credentials, create the access entry, or use kubectl create token
GKE: requires one of ["container.pods.list"] permission(s) Neither RBAC nor IAM grants the action RoleBinding for the Google account or group, or an IAM role

FAQ

What does API group "" mean in a Kubernetes forbidden error?

It is the core API group, which holds pods, services, ConfigMaps, Secrets, namespaces and nodes. In a Role you write it as apiGroups: [""]. Resources from other groups name their group, such as apps for deployments or batch for jobs.

Why does kubectl auth can-i say yes when my request is still forbidden?

Usually because you checked a different identity or context from the one that failed, so run kubectl auth whoami with the same kubeconfig, token or pod. Also check the subresource: kubectl logs needs get on pods/log, not just on pods. If both match, the denial is coming from outside RBAC: an admission webhook, or the EKS, GKE or AKS layer.

How do I give a service account access to another namespace?

Create the RoleBinding in the namespace being accessed and name the service account with its home namespace in the subject. kubectl create rolebinding reader -n target --clusterrole=view --serviceaccount=source:my-sa lets my-sa from source read most objects in target, with no ClusterRoleBinding.

Do I need a ClusterRoleBinding for kubectl get pods -A?

Yes. Listing across all namespaces is a cluster-scope request, so it needs a ClusterRole bound with a ClusterRoleBinding. If the person only needs a few namespaces, bind per namespace and have them pass -n.

The first thing I check in production is kubectl auth whoami, because a correct permission on the wrong identity is the quickest way to lose an afternoon here. After that I keep every Role and RoleBinding in Git beside the workload, generate them with --dry-run=client, and add kubectl auth can-i -q checks to CI so a missing verb fails the pipeline, not the deploy. The official references are the RBAC documentation, checking API access, the RBAC good practices, EKS access entries, GKE RBAC and AKS Entra ID authorization.

Related: How to Use GitHub Actions OIDC With AWS, GCP and Azure (2026), How to Enforce Pod Security Standards with Kyverno 1.19 (2026) and How to Write Kubernetes NetworkPolicies: Default Deny to Egress (2026).

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related