It's 403, not 401 — authentication succeeded, so why were you blocked?
Five minutes before a deploy, this single line in the CI pipeline log makes your stomach drop.
Error from server (Forbidden): pods is forbidden: User "system:serviceaccount:dev:ci-deployer" cannot list resource "pods" in API group "" in the namespace "prod"The most common accident at this moment is "just slap on cluster-admin and ship the deploy." And that binding is almost never revoked. The point of this post is to break that habit. Read one error line, decide in 30 seconds who / what / where was blocked, narrow it to one of five causes, and recover with least privilege.
First, draw a hard line: 401 and 403 are different problems
| Distinction | 401 Unauthorized | 403 Forbidden |
|---|---|---|
| Question | "Who are you?" (authentication) | "Are you allowed to do that?" (authorization) |
| Typical message | error: You must be logged in to the server (Unauthorized) | Error from server (Forbidden): ... cannot list resource ... |
| Cause axis | Expired/missing token, expired cert, kubeconfig exec plugin failure | Missing RBAC Role/Binding, scope or apiGroup mismatch |
| Where to fix | kubeconfig, token issuance, IAM auth path | Role / ClusterRole / RoleBinding / ClusterRoleBinding |
If the message is in the Unauthorized family, this post is the wrong one — start with kubectl Unauthorized: 3-minute cause-by-cause diagnosis and recovery runbook (EKS reissue) or 6 ways to fix kubectl Unauthorized (You must be logged in) 401. If authentication never succeeds, authorization never even starts.
If the API server is unreachable entirely and you get The connection to the server localhost:8080 was refused, that belongs in kubectl localhost:8080 refused: 30-second diagnosis and recovery runbook.
Where this sits in the series
A Kubernetes API request flows Authentication → Authorization (RBAC) → Admission.
kubectl / SDK
│
├─ ① Authn : who is the subject of this request → fail → 401
├─ ② Authz : is that subject allowed to do this → fail → 403 (this post)
└─ ③ Admission: should this object spec be allowed → PSA violation → create deniedPart 2 of the series covered Pod Security Admission — step ③, "what are we allowed to run." This post is part 3, step ②, "who can do what." Even though both use the word Forbidden, a PSA denial includes a phrase like violates PodSecurity "restricted:latest", so you can tell them apart.
Anatomy of the raw error: the answer is already in that one line
Scope: Kubernetes v1.24+ (RBAC v1 API), kubectl 1.24+, common to EKS, GKE, AKS, and kubeadm clusters. Sample output assumes a typical Linux environment.
Three raw variants
① ServiceAccount subject (most common from CI / inside a pod)
Error from server (Forbidden): pods is forbidden: User "system:serviceaccount:dev:ci-deployer" cannot list resource "pods" in API group "" in the namespace "prod"② Anonymous subject (the token was never sent)
Error from server (Forbidden): nodes is forbidden: User "system:anonymous" cannot get path "/api/v1/nodes"③ EKS IAM subject (never mapped to a K8s identity)
Error from server (Forbidden): deployments.apps is forbidden: User "arn:aws:iam::123456789012:role/eks-dev-role" cannot create resource "deployments" in API group "apps" at the cluster scopeField-by-field anatomy table
Each fragment of the message maps 1:1 to a field in RBAC YAML.
| Message fragment | Meaning | Corresponding YAML location |
|---|---|---|
User "..." | Request subject (User / Group / ServiceAccount) | Binding subjects[].kind + name (+ namespace for SA) |
cannot list | Denied verb | Role/ClusterRole rules[].verbs |
resource "pods" | Target resource (plural, lowercase) | rules[].resources |
pods/log form | Subresource | Must be listed separately as pods/log in rules[].resources |
in API group "" | API group; "" is core | rules[].apiGroups (core is [""]) |
in the namespace "prod" | Namespace-scoped request | Role + RoleBinding (in that ns) |
at the cluster scope | Cluster-scoped request | ClusterRole + ClusterRoleBinding |
One-line rule: if the message ends with in the namespace X, create a RoleBinding in that namespace; if it says at the cluster scope, you need a ClusterRoleBinding. Following just this line eliminates half of the wasted effort.
30-second triage table — branch on the subject prefix
| Subject form | Identity | Check immediately |
|---|---|---|
system:serviceaccount:<ns>:<sa> | In-pod or CI token | Binding subjects namespace matches the SA's actual ns |
system:anonymous | Token never delivered | Missing user block / empty token in kubeconfig. Effectively an authn problem → also follow the 401 runbook |
arn:aws:iam::...:role/... | EKS failed to map to a K8s identity | aws-auth ConfigMap or EKS Access Entry mapping |
kubernetes-admin, dev@corp.com, etc. | Regular User subject | kubectl config current-context is the context you intended |
system:node:<hostname> | kubelet | Node authorizer / NodeRestriction territory — not something humans should touch |
If you see system:anonymous and you never opened anonymous access, this is a credential-delivery problem, not a permission-grant problem. Creating Roles will never fix it.
Five cause branches: your Forbidden is one of these five
(a) Scope mismatch — the most common cause
There are four Role/ClusterRole × RoleBinding/ClusterRoleBinding combinations, and each covers a different range.
| Combination | Coverage | Typical use |
|---|---|---|
| Role + RoleBinding | Namespace-scoped resources in that namespace only | Per-team developer access |
| ClusterRole + ClusterRoleBinding | All namespaces + cluster-scoped resources | Monitoring agents, cluster operators |
| ClusterRole + RoleBinding | Apply the ClusterRole's rules limited to that ns | Define one shared role, reuse it per team ns (recommended pattern) |
| Role + ClusterRoleBinding | Impossible (only a RoleBinding can reference a Role) | — |
Two easy misses:
- Cluster-scoped resources such as
nodes,persistentvolumes,namespaces,storageclasses, andclusterrolescannot be reached no matter how you bind them with a RoleBinding. - "I want to list pods across all namespaces" also needs a ClusterRoleBinding. Pods themselves are namespaced, but
kubectl get pods -Ais evaluated as a cluster-scoped request.
(b) Wrong apiGroups
The in API group "" string in the error must match apiGroups in the Role YAML exactly.
| Resource | apiGroup value | Notes |
|---|---|---|
| pods, services, configmaps, secrets, nodes, namespaces, persistentvolumeclaims, serviceaccounts, events | "" | core group, empty string |
| deployments, replicasets, daemonsets, statefulsets | "apps" | |
| jobs, cronjobs | "batch" | |
| ingresses, networkpolicies | "networking.k8s.io" | |
| horizontalpodautoscalers | "autoscaling" | |
| roles, rolebindings, clusterroles, clusterrolebindings | "rbac.authorization.k8s.io" | |
| poddisruptionbudgets | "policy" | |
| customresourcedefinitions | "apiextensions.k8s.io" |
If resource-to-group mapping is fuzzy, asking the cluster is the most accurate approach.
kubectl api-resources -o wide | head -20NAME SHORTNAMES APIVERSION NAMESPACED KIND VERBS
configmaps cm v1 true ConfigMap create,delete,get,list,patch,update,watch
pods po v1 true Pod create,delete,get,list,patch,update,watch
deployments deploy apps/v1 true Deployment create,delete,get,list,patch,update,watchIf APIVERSION is v1, it is core (""); if apps/v1, it is apps. If the NAMESPACED column is false, it is a cluster-scoped resource.
(c) subjects typo — fails silently with no apply error
The nastiest RBAC trap: a typo still lets the resource create successfully. The mismatch only shows up at runtime as 403.
# Wrong — this YAML applies successfully but never matches
subjects:
- kind: ServiceAccount
name: system:serviceaccount:dev:ci-deployer # ← do not put the full name here# Correct
subjects:
- kind: ServiceAccount
name: ci-deployer # SA name only
namespace: dev # ServiceAccount subjects require namespaceSummary:
kind: ServiceAccount→nameis the SA name only;namespaceis required. Omit it and matching fails silently.kind: User/kind: Group→ do not setnamespace. In this form, putting the full namesystem:serviceaccount:dev:ci-deployerinnameis valid (referring to an SA as a User).apiGroupvalues: User/Group userbac.authorization.k8s.io; ServiceAccount uses""(omittable).
(d) You are not the subject you think you are
The permissions are correct — you just are not that subject.
kubectl config current-context
kubectl config view --minify -o jsonpath='{.contexts[0].context.user}{"\n"}'On EKS, the IAM principal must be mapped to a K8s identity. With no mapping, the error exposes the IAM ARN as-is. Check:
- Missing
mapRoles/mapUsersentries in theaws-authConfigMap - IAM Role ARN includes a path like
/aws-reserved/sso.amazonaws.com/...and fails to match (SSO roles are generally known to match only after the path is stripped — confirm against the official docs for your EKS version) - Running EKS Access Entry (newer) and aws-auth in parallel, so it is unclear which one actually applies. The center of gravity has been shifting toward Access Entry / Access Policy, so during diagnosis you must check both paths.
(e) Missing subresources
Permission on pods does not include pods/log. That is by design, not a bug.
| Parent resource | Subresource that must be listed separately | Typical failing command |
|---|---|---|
pods ("") | pods/log | kubectl logs |
pods ("") | pods/exec | kubectl exec |
pods ("") | pods/portforward | kubectl port-forward |
pods ("") | pods/attach | kubectl attach |
pods ("") | pods/ephemeralcontainers | kubectl debug |
deployments (apps) | deployments/scale | kubectl scale, HPA |
statefulsets (apps) | statefulsets/scale | kubectl scale sts |
deployments (apps) | deployments/status | controller status updates |
nodes ("") | nodes/metrics, nodes/proxy | metrics collectors |
serviceaccounts ("") | serviceaccounts/token | token request API |
Log collectors suddenly unable to read logs, or HPA unable to scale without deployments/scale, are among the most frequently reported production cases.
Copy-paste diagnosis → least-privilege recovery → impersonation verification
Step 1: Dump the subject's current permissions
The --as flag lets you query as if you were another subject (impersonation). Your own account running this command must have the impersonate permission.
# Full permissions a given ServiceAccount has in the prod namespace
kubectl auth can-i --list \
--as=system:serviceaccount:dev:ci-deployer \
-n prodHealthy-looking (but actually empty) sample:
Resources Non-Resource URLs Resource Names Verbs
selfsubjectaccessreviews.authorization.k8s.io [] [] [create]
selfsubjectrulesreviews.authorization.k8s.io [] [] [create]
[/api/*] [] [get]
[/healthz] [] [get]If you only see selfsubject* and /healthz, this SA effectively has no permissions (you are only seeing system:basic-user and system:discovery, which every authenticated subject gets). Suspect cause (c) subjects typo, or a missing binding altogether.
Step 2: Check individual verbs
kubectl auth can-i list pods --as=system:serviceaccount:dev:ci-deployer -n prod
# → no
kubectl auth can-i create deployments.apps --as=system:serviceaccount:dev:ci-deployer -n prod
# → yes
# Subresources can be checked directly
kubectl auth can-i get pods/log --as=system:serviceaccount:dev:ci-deployer -n prod
# → no
# Check by group
kubectl auth can-i list secrets --as=dev@corp.com --as-group=platform-team -n prodIf can-i says yes but the real call still fails, RBAC passed and you were blocked at admission (PSA, ValidatingWebhook) or by another authorizer.
Step 3: Reverse-trace bindings attached to this subject
SUBJECT="ci-deployer"
SUBJECT_NS="dev"
kubectl get clusterrolebinding,rolebinding -A -o json \
| jq -r --arg n "$SUBJECT" --arg ns "$SUBJECT_NS" '
.items[]
| select(
(.subjects // [])
| any(
(.name == $n and (.kind == "ServiceAccount") and (.namespace == $ns))
or (.name == ("system:serviceaccount:" + $ns + ":" + $n))
)
)
| "\(.kind)\t\(.metadata.namespace // "-")/\(.metadata.name)\t-> \(.roleRef.kind)/\(.roleRef.name)"
' | column -tSample healthy output:
RoleBinding prod/ci-deployer-deploy -> Role/ci-deployer
ClusterRoleBinding -/metrics-reader -> ClusterRole/viewIf nothing prints, there is no binding or the subjects notation is wrong → cause (c).
Step 4: Inspect the actual rules
# ClusterRole rules in human-readable form
kubectl describe clusterrole view | head -30
# Namespace Role source
kubectl get role ci-deployer -n prod -o yamlIf describe shows pods in the Resources column but not pods/log, that confirms (e) missing subresource.
Step 5: Check EKS mapping
kubectl -n kube-system get configmap aws-auth -o yamlapiVersion: v1
kind: ConfigMap
metadata:
name: aws-auth
namespace: kube-system
data:
mapRoles: |
- rolearn: arn:aws:iam::123456789012:role/eks-node-role
username: system:node:{{EC2PrivateDNSName}}
groups:
- system:bootstrappers
- system:nodesIf eks-dev-role is missing here, that IAM role cannot be converted into a K8s identity. Also check Access Entry in parallel.
aws eks list-access-entries --cluster-name my-cluster
aws eks list-associated-access-policies --cluster-name my-cluster \
--principal-arn arn:aws:iam::123456789012:role/eks-dev-roleStep 6: Find denials in the audit log
If API server audit logs are enabled, filter on "decision":"forbid" and you get the denied request's subject, verb, and resource as-is.
# On a control-plane node that has the audit log file
jq -c 'select(.annotations["authorization.k8s.io/decision"]=="forbid")
| {user: .user.username, verb, uri: .requestURI,
reason: .annotations["authorization.k8s.io/reason"]}' \
/var/log/kubernetes/audit.log | tail -20On EKS, query the same fields in the CloudWatch Logs audit log group.
Three least-privilege recovery YAML examples
① Read-only (risk: low)
For developers inspecting the state of their team's namespace.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ns-readonly
namespace: prod
rules:
- apiGroups: [""] # core group
resources: ["pods", "services", "configmaps", "events",
"persistentvolumeclaims"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "replicasets", "statefulsets", "daemonsets"]
verbs: ["get", "list", "watch"]
# secrets intentionally omitted — a single get exposes every DB password and token.
# pods/log also omitted — logs commonly contain PII and tokens.
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ns-readonly-dev-team
namespace: prod
subjects:
- kind: Group
name: dev-team # User/Group have no namespace field
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: ns-readonly
apiGroup: rbac.authorization.k8s.io② Debugging (risk: high — time-box it)
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ns-debugger
namespace: prod
annotations:
security.internal/expires-at: "2026-08-05T00:00:00Z" # explicit revocation deadline
security.internal/ticket: "OPS-1234"
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get"]
- apiGroups: [""]
resources: ["pods/exec", "pods/portforward"]
verbs: ["create"] # exec/portforward use the create verb
# delete omitted — prevents a debug session from deleting pods and making the outage worse
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ns-debugger-oncall
namespace: prod
subjects:
- kind: User
name: oncall@corp.com
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: ns-debugger
apiGroup: rbac.authorization.k8s.ioWhy this is dangerous: pods/exec is effectively a shell inside the container. You can read Secret files and the ServiceAccount token mounted in that pod, which is equivalent to inheriting the pod's SA permissions. Always grant it with an expiry ticket and leave an audit trail.
③ CI deploy (risk: medium)
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ci-deployer
namespace: prod
rules:
- apiGroups: ["apps"]
resources: ["deployments", "replicasets"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: ["apps"]
resources: ["deployments/scale"] # kubectl scale / HPA
verbs: ["get", "update", "patch"]
- apiGroups: [""]
resources: ["services", "configmaps"]
verbs: ["get", "list", "create", "update", "patch"]
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list"] # to inspect rollout failures
# create secrets omitted — creating an SA token Secret opens a privilege-escalation
# path to steal another SA's permissions. Deliver secrets via External Secrets or similar.
# delete deployments omitted — rollback via update/patch is enough.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: ci-deployer
namespace: dev
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ci-deployer-prod
namespace: prod # ns where the permission applies
subjects:
- kind: ServiceAccount
name: ci-deployer
namespace: dev # ns where the SA actually lives (required)
roleRef:
kind: Role
name: ci-deployer
apiGroup: rbac.authorization.k8s.ioA common point of confusion: RoleBinding metadata.namespace (where the permission applies) and the subject's namespace (where the SA lives) can differ. The example above is a valid setup that grants a SA in dev permissions in prod.
Verification loop
kubectl apply -f rbac.yaml
kubectl auth can-i --list \
--as=system:serviceaccount:dev:ci-deployer -n prod
kubectl auth can-i update deployments.apps/scale \
--as=system:serviceaccount:dev:ci-deployer -n prodBranch on the result:
| Result | Go back to |
|---|---|
| Still only default permissions | (c) subjects typo — recheck kind/name/namespace |
no on some resources only | (b) apiGroups or (e) missing subresource |
Only -A queries fail | (a) scope — need ClusterRoleBinding |
can-i is yes but the real call fails | Admission (PSA, etc.) or subject mismatch (d) |
To verify with the real token from inside a pod:
kubectl exec -it <pod> -n dev -- sh -c '
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token);
curl -s -o /dev/null -w "%{http_code}\n" \
--cacert /var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
-H "Authorization: Bearer $TOKEN" \
https://kubernetes.default.svc/api/v1/namespaces/prod/pods
'200 is healthy, 403 is missing RBAC, 401 is a token problem (bound-token expiry or audience mismatch). Recent clusters default to bound ServiceAccount tokens with a lifetime and audience, so an old CI script using a cached token can produce 401 rather than 403.
What not to do
Taboo 1 — temporary cluster-admin
# Never do this
kubectl create clusterrolebinding temp-fix \
--clusterrole=cluster-admin \
--serviceaccount=dev:ci-deployerThe typical incident path: grant in a hurry → deploy succeeds → no revocation ticket → left for months → CI runner container compromised, SA token leaked → Secrets readable in every namespace → full cluster takeover. If you did grant it, record the expiry in both an annotation and a ticket, and audit regularly with:
kubectl get clusterrolebinding -o json \
| jq -r '.items[]
| select(.roleRef.name=="cluster-admin")
| "\(.metadata.name)\t\((.subjects // []) | map(.kind + ":" + .name) | join(","))"' \
| column -tTaboo 2 — wildcards
verbs: ["*"], resources: ["*"], apiGroups: ["*"] are frequently flagged in ISMS-P, SOC 2, and similar audits as least-privilege violations (confirm the exact criteria against the relevant audit standard). Manage these verbs separately in particular.
| verb | Risk |
|---|---|
escalate | Can create a Role that exceeds your own permissions |
bind | Can bind an arbitrary ClusterRole to yourself |
impersonate | Can issue requests as another user or group |
create on serviceaccounts/token | Issue another SA's token → privilege theft |
create on pods | Launch a pod with a chosen SA and escalate |
Response checklist
- Split the raw error into five fields: subject / verb / resource / apiGroup / scope
- 30-second triage by subject prefix (SA / anonymous / IAM ARN / User)
- Narrow to one of the five causes (scope · apiGroup · subjects · identity · subresource)
- Add only a least-privilege Role/Binding (no wildcards, no cluster-admin)
- Verify with impersonation via
kubectl auth can-i --list --as=... - Record the grant history and expiry in Git (RBAC as Code)
Next in the series
Part 4 covers Secrets and ServiceAccount token management: blocking unnecessary token mounts with automountServiceAccountToken: false, bound-token lifetime and audience, and wiring external secrets via External Secrets Operator. "Why we omitted create secrets from the CI Role" in this post is the starting point of part 4.
Official references: Kubernetes docs "Using RBAC Authorization" and "Authorization Overview", plus the cluster access management (Access Entry) section of the AWS EKS user guide.
FAQ
Q. I ran kubectl auth can-i --list --as=... and my account says cannot impersonate.
A. Impersonation is itself a separate permission. You need the impersonate verb on the users, groups, and serviceaccounts resources. That permission is powerful enough to impersonate other users, so prefer limiting it to audited admin accounts, or verify directly with the target SA's token via --token.
Q. I created a RoleBinding but kubectl get pods -A is still Forbidden.
A. -A (all namespaces) is evaluated as a cluster-scoped request, so a RoleBinding cannot cover it. You need ClusterRole + ClusterRoleBinding. If you only need one namespace, it is much safer from a permissions standpoint to change the script to call with -n <ns>.
Q. I set up an EKS Access Entry but the error still prints the IAM ARN as-is.
A. If aws-auth ConfigMap and Access Entry are both in use, which one actually applies depends on the cluster authentication mode (API, API_AND_CONFIG_MAP, CONFIG_MAP). Check accessConfig.authenticationMode with aws eks describe-cluster first, and also compare whether the ARN string includes an SSO path (/aws-reserved/...).
Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
Comments
Be the first to comment.