Kubernetes for beginners. Part 29: How do authentication and RBAC work?

All parts of the series “Kubernetes for beginners”

So far we have worked with the cluster as the administrator from the admin.conf file kubeadm created in part 4, and never got a refusal. In the previous part the controller needed permissions on greetings and greetings/status, and the PostgreSQL operator on Secrets in all namespaces, and we postponed the question of where these permissions come from and why they are dangerous. Today — how kube-apiserver learns who came to it and decides what that subject may do. TLS, certificates and chains of trust are covered in part 23 of the networking series; here only one thing matters: all communication with the apiserver goes over TLS, and the client certificate can serve as the client's credential.

Everything in this part was tested on the lab cluster: certificates and CSR, RBAC, ServiceAccount tokens, constrained impersonation and AuthenticationConfiguration — with a test OIDC provider issuing signed tokens. Left untested are kubelogin and login through a real provider (Keycloak, Dex), webhook authentication, the static token file and the cleanup of old token Secrets — those are described from the documentation.


The path of a request

Every API request goes through the same chain:

 client (kubectl, kubelet, controller, pod)
        │  HTTPS :6443
        ▼
 ┌─────────────────────┐
 │  authentication     │  who is it? → name, UID, groups, extra attributes
 │  (authenticators)   │  not recognised → system:anonymous or 401
 └──────────┬──────────┘
            ▼
 ┌─────────────────────┐
 │  authorization      │  may it use this verb on this resource?
 │  (Node, RBAC, ...)  │  no → 403 Forbidden
 └──────────┬──────────┘
            ▼
 ┌─────────────────────┐
 │  admission          │  change or reject the object by rules
 │  (mutating,         │  (quotas, Pod Security, webhooks — part 30)
 │   validating)       │
 └──────────┬──────────┘
            ▼
         etcd

Authentication answers "who", authorization — "is it allowed", admission — "is such an object allowed". Today — the first two links.


Authentication: there are no users

Kubernetes has no "user" object. There is no kubectl create user, no list of users, no passwords in etcd. A user is a string the apiserver extracted from the credential presented with the request, and which it trusts because the credential was signed by someone the apiserver itself trusts: its own certificate authority, its own token signing key, an external provider.

The result of authentication is a set of attributes:

Attribute Example
username alice, system:serviceaccount:demo:hello, system:node:k8s-node-1
UID a unique identifier, if the source provides one
groups platform-admins, system:authenticated
extra additional key-value pairs (for example, the pod name of a ServiceAccount token or a certificate fingerprint)

Everyone who passes authentication automatically gets the system:authenticated group. A request without a credential becomes a request of the user system:anonymous in the system:unauthenticated group — anonymous access is enabled by default, but default RBAC gives it almost nothing besides the health checks (/healthz, /livez, /readyz) and /version. The system: prefix is reserved for Kubernetes itself, and such names must never be given to people.

There are several authenticators; the apiserver tries them in turn, and the first one that recognises the request wins. Let's go through each.


X.509 client certificates

If the client presented a certificate during the TLS handshake, signed by the CA from --client-ca-file (in kubeadm — /etc/kubernetes/pki/ca.crt), the apiserver takes:

  • the subject's CN (Common Name) — as the username;
  • each O (Organization) — as a group.

A certificate CN=alice, O=platform-admins, O=developers is the user alice in the groups platform-admins, developers and system:authenticated. That's it. The apiserver doesn't look anything up in a database: the CA signature is valid, the certificate hasn't expired — so it's alice.

This is how the cluster's own components authenticate: the kubelet of each node with a certificate CN=system:node:k8s-node-1, O=system:nodes, kube-scheduler and kube-controller-manager with their own. And the administrator from admin.conf:

user@k8s-master-1:~$ sudo grep client-certificate-data /etc/kubernetes/admin.conf | awk '{print $2}' \
    | base64 -d | openssl x509 -noout -subject -enddate
subject=O=kubeadm:cluster-admins, CN=kubernetes-admin
notAfter=Oct 24 10:31:34 2027 GMT

The kubeadm:cluster-admins group gets the cluster-admin role through a ClusterRoleBinding kubeadm created during installation. Next to it lies super-admin.conf:

user@k8s-master-1:~$ sudo grep client-certificate-data /etc/kubernetes/super-admin.conf | awk '{print $2}' \
    | base64 -d | openssl x509 -noout -subject
subject=O=system:masters, CN=kubernetes-super-admin

The system:masters group bypasses authorization entirely: a request from its member is allowed before RBAC is consulted, and no change to roles can restrict it. That's why kubeadm split the two files in version 1.29: admin.conf is for work, super-admin.conf is for emergencies (for example, if RBAC got broken), and it should stay on the master and not be copied to laptops. Never issue anyone a certificate with O=system:masters.


Tokens

The second way is the Authorization: Bearer <token> header. There are several kinds of tokens.

ServiceAccount tokens. A ServiceAccount is the only "account" object Kubernetes has, and it is meant for programs, not people. Its full name is system:serviceaccount:<namespace>:<name>, its groups are system:serviceaccounts and system:serviceaccounts:<namespace>. The token is a JWT (JSON Web Token: three base64 parts separated by dots — header, set of claims and signature) signed with the private key /etc/kubernetes/pki/sa.key. The apiserver checks the signature with the public key sa.pub, the expiry, the audience and that the objects the token is bound to still exist.

Tokens are issued by the TokenRequest API — the serviceaccounts/token subresource. Each token is:

  • time-limited (exp): kubectl create token gives an hour by default and won't issue less than 10 minutes;
  • audience-limited (aud): who it was issued for. The apiserver accepts only tokens with its own audience (by default the address https://kubernetes.default.svc.cluster.local), while a token issued for an external system (kubectl create token --audience=vault.example.com) won't pass at the apiserver, and vice versa;
  • bound to an object: a token issued to a pod contains the pod's name and UID and stops being accepted as soon as the pod is deleted.

A token gets into a pod through a projected volume kube-api-access-…: the kubelet requests the token through TokenRequest itself, puts it into /var/run/secrets/kubernetes.io/serviceaccount/token together with ca.crt (from the kube-root-ca.crt ConfigMap present in every namespace) and the namespace file, and refreshes it before expiry — after 80% of its lifetime. Such a token lives about an hour, but for compatibility with old client libraries that can't re-read the file, the apiserver by default extends it to exactly one year and tracks such "stale" uses in metrics and in the audit log (the apiserver's record of who made which request).

Before 1.24, a Secret with a non-expiring token bound to nothing was created automatically for every ServiceAccount. Now such Secrets are not created, and unused old ones are first marked invalid and then deleted. You can still create a non-expiring token by hand (a Secret of type kubernetes.io/service-account-token), but do it only for an external system that can't work otherwise, and with a separate least-privilege ServiceAccount.

Bootstrap tokens are short-lived tokens of the form abcdef.0123456789abcdef in Secrets in kube-system, used by new nodes to authenticate during kubeadm join (part 4) in order to request a certificate.

Static token file (--token-auth-file) — a CSV with tokens and names, read when the apiserver starts. It exists, but is no good for people: the tokens never expire, and changing them means restarting the apiserver.

Webhook. The apiserver sends the token to an external HTTPS service as a TokenReview object and gets back the name and groups. This is how cloud providers and home-grown systems integrate.


OIDC: people log in through a provider

For people, the right mechanism is OpenID Connect (OIDC): a layer on top of OAuth 2.0 (the protocol by which an application gets access from a provider on behalf of a user without seeing the user's password), where the user logs in at a third-party identity provider and receives an ID token: a JWT signed by the provider with claims about who they are (sub, email) and which groups they belong to (groups). The provider can be Keycloak (an open-source single sign-on server) or Dex — a lightweight intermediary that itself checks the user against GitHub, Active Directory or any LDAP directory (a protocol for reading a directory of users and groups over the network: the client connects to the directory server, binds and searches entries by a filter). The apiserver verifies the ID token's signature with the provider's public keys, which it downloads itself from the address in the /.well-known/openid-configuration document, and takes the name and groups from the claims.

Advantages over certificates: passwords and the second factor live at the provider, the token lives for minutes, a departing employee is disabled in one place, groups come from the directory.

There are two ways to configure it. The old one is the flags --oidc-issuer-url, --oidc-client-id, --oidc-username-claim, --oidc-groups-claim and so on: one provider, changes mean a restart. The new one is structured authentication configuration: an AuthenticationConfiguration file passed with the --authentication-config flag. It is stable since 1.34, allows several providers, claim checks in CEL (Common Expression Language — an expression language without loops or side effects that the apiserver evaluates itself) and is reloaded on the fly when the file changes. It can't be combined with the --oidc-* flags: an apiserver with both won't start.

# /etc/kubernetes/auth/authentication-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: AuthenticationConfiguration
jwt:
  - issuer:
      url: https://sso.example.com/realms/infra
      audiences:
        - kubernetes
    claimValidationRules:
      - expression: "claims.email.endsWith('@example.com')"
        message: only @example.com addresses are allowed
    claimMappings:
      username:
        claim: email
        prefix: "oidc:"
      groups:
        claim: groups
        prefix: "oidc:"
anonymous:
  enabled: true
  conditions:
    - path: /livez
    - path: /readyz
    - path: /healthz

What happens here:

  • url must match the iss claim in the token, and audiences — aud: a token the provider issued for Grafana won't work with the apiserver. If the provider's certificate is issued by an internal CA, its PEM goes into issuer.certificateAuthority.
  • When the name is taken from email, the apiserver checks the email_verified claim itself: with false the token is rejected, with the claim absent it's accepted. You don't need your own rule for that.
  • claimValidationRules are additional CEL checks over the token's claims; here we let in only addresses of our own domain.
  • The oidc: prefix on names and groups protects against collisions: an admins group from the provider won't accidentally turn into some internal one, and a user with an address that looks like system:… becomes oidc:system:… and gets no privileges of a system name. There are also userValidationRules — CEL checks over the resulting name and groups; for example, the rule !user.username.startsWith('system:') is needed by those who do without a prefix.
  • The anonymous block also narrows anonymous access to the three health check paths: /version without a credential now answers 401.

The apiserver doesn't tell the client why it refused — only Unauthorized. The reason is in the apiserver log:

"Unable to authenticate the request" err="[invalid bearer token, oidc: email not verified]"
"Unable to authenticate the request" err="[invalid bearer token, oidc: verify token: oidc: expected audience \"kubernetes\" got [\"grafana\"]]"
"Unable to authenticate the request" err="[invalid bearer token, oidc: error evaluating claim validation expression: validation expression 'claims.email.endsWith('@example.com')' failed: only @example.com addresses are allowed]"

The apiserver picks up a changed file in about 15 seconds and logs reloaded authentication config. If the new file has an error (say, an unclosed parenthesis in a CEL expression), it stays on the previous configuration, logs failed to validate authentication config and increments the apiserver_authentication_config_controller_automatic_reloads_total{status="failure"} metric. At startup, however, an apiserver with a broken file won't start, so check the file before a restart.

In kubeadm the file is wired in through ClusterConfiguration:

apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
apiServer:
  extraArgs:
    - name: authentication-config
      value: /etc/kubernetes/auth/authentication-config.yaml
  extraVolumes:
    - name: auth-config
      hostPath: /etc/kubernetes/auth
      mountPath: /etc/kubernetes/auth
      readOnly: true
      pathType: Directory

On a new cluster this is part of the kubeadm init configuration. On a running one the same fields are added to the kubeadm-config ConfigMap in kube-system (kubeadm takes its settings from there during upgrades, otherwise the flag disappears when the manifest is regenerated), and the apiserver manifest is regenerated with kubeadm init phase control-plane apiserver --config <file>; with --dry-run it writes the manifest to a temporary directory, so you can compare it with the current one first.

kubectl itself doesn't talk to the provider. It gets the token from an exec plugin — an external program kubectl runs before a request, which prints the token in the ExecCredential format. For OIDC this is usually kubelogin (kubectl oidc-login): it opens a browser, performs the login, caches and refreshes the token.


kubeconfig

A kubeconfig file (~/.kube/config by default) ties together three lists:

apiVersion: v1
kind: Config
clusters:                       # where to connect
  - name: kubernetes
    cluster:
      server: https://k8s-api.example.com:6443
      certificate-authority-data: LS0tLS1CRUdJTi…   # the CA to trust when verifying the apiserver
users:                          # how to identify
  - name: alice-cert
    user:
      client-certificate-data: LS0tLS1CRUdJTi…
      client-key-data: LS0tLS1CRUdJTi…
  - name: alice-oidc
    user:
      exec:
        apiVersion: client.authentication.k8s.io/v1
        command: kubectl
        args:
          - oidc-login
          - get-token
          - --oidc-issuer-url=https://sso.example.com/realms/infra
          - --oidc-client-id=kubernetes
        interactiveMode: IfAvailable
contexts:                       # a "cluster + user" pair and the default namespace
  - name: lab-alice
    context:
      cluster: kubernetes
      user: alice-oidc
      namespace: demo
current-context: lab-alice

The cluster is called kubernetes, as in kubeadm's admin.conf. A context is just a convenient name for a pair; switching is kubectl config use-context. A user in kubeconfig is not a "cluster user" but a way to obtain a credential; the name alice-cert is only visible locally, the apiserver learns the name from the CN.

A file with client-key-data is a password: mode 600, not in Git, not in shared directories. A file with an exec plugin contains no secrets and can be handed out.


Giving an administrator access with a certificate

Sometimes you can't do without a certificate: there is no identity provider, you need emergency access, or it's a service account of an external system. You can sign a certificate by hand with the CA key, but it's better through the API: a CertificateSigningRequest (CSR) object that an administrator approves and kube-controller-manager signs with the same CA. The private key never leaves the user's machine.

$ openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
    -keyout alice.key -out alice.csr -subj "/CN=alice/O=platform-admins"
apiVersion: certificates.k8s.io/v1
kind: CertificateSigningRequest
metadata:
  name: alice
spec:
  request: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURSBSRVFVRVNULS0tLS0K…   # base64 -w0 alice.csr
  signerName: kubernetes.io/kube-apiserver-client
  expirationSeconds: 604800        # a week
  usages:
    - client auth

signerName: kubernetes.io/kube-apiserver-client is the built-in signer of client certificates for the apiserver. expirationSeconds is the desired lifetime; without it the controller's --cluster-signing-duration flag applies, a year by default.

Why the lifetime matters so much: an issued certificate cannot be revoked. The apiserver doesn't check revocation lists (CRL) and doesn't query OCSP — the online certificate status service. The certificate is valid until it expires or the CA is replaced. What's left if alice's key leaks:

  • delete RoleBindings and ClusterRoleBindings for the user alice — but if permissions are granted to the platform-admins group, which is written into the certificate itself, you'd have to take them away from the whole group;
  • replace the cluster CA — reissuing all certificates of all components and all kubeconfigs;
  • wait for expiry.

Hence the practice: OIDC for people; certificates only for service and emergency accounts, with a short lifetime and permissions through a RoleBinding to the username, not to a group from the certificate. And never O=system:masters.


Authorization: RBAC

After authentication a request is described by attributes: user and groups, verb (get, list, create…), API group, resource and subresource, namespace, object name. Authorizers are listed in the --authorization-mode flag; kubeadm sets Node,RBAC. Node is a special authorizer for the kubelet: a node may read Secrets and ConfigMaps only of the pods scheduled to it and change the status only of its own Node object. Everything else is decided by RBAC (role-based access control).

RBAC consists of four object types in the rbac.authorization.k8s.io group:

Object Scope What it does
Role namespace a set of rules for resources of this namespace
ClusterRole cluster a set of rules: for cluster resources (Node, PV, CRD), for URLs outside resources (/metrics) or as a template for many namespaces
RoleBinding namespace grants a Role or a ClusterRole to subjects — within its own namespace
ClusterRoleBinding cluster grants a ClusterRole to subjects in all namespaces

A rule is apiGroups + resources + verbs, optionally resourceNames:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: hello-deployer
  namespace: demo
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "patch", "update"]
  - apiGroups: ["apps"]
    resources: ["deployments/scale"]
    verbs: ["patch", "update"]
  - apiGroups: [""]                   # "" is the core group: pods, services, configmaps…
    resources: ["pods", "pods/log"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["configmaps"]
    resourceNames: ["hello-config"]   # only this object
    verbs: ["get", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ci-hello-deployer
  namespace: demo
subjects:
  - kind: ServiceAccount
    name: ci
    namespace: demo
  - kind: Group
    name: oidc:hello-team
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: hello-deployer
  apiGroup: rbac.authorization.k8s.io

Details that matter in practice:

  • Allow only. RBAC has no denies: permissions add up from all bindings, and "everything except Secrets" can't be expressed — only listing everything that is allowed.
  • Verbs. For resources: get, list, watch, create, update, patch, delete, deletecollection. Special ones: use (PodSecurityPolicy in the past, some CRDs now), bind and escalate (for roles), impersonate, approve and sign (for CSRs). list and watch return objects in full — list on Secrets equals reading all of them.
  • Subresources are separate resources for RBAC: pods/log, pods/exec, pods/portforward, pods/eviction, deployments/scale, services/proxy, greetings/status. Permission on pods doesn't give permission on pods/exec.
  • resourceNames restricts a rule to specific objects, but doesn't work with create (there's no name yet) and with list/watch without a name filter.
  • RoleBinding to a ClusterRole is the main way of reuse: one ClusterRole hello-deployer and a RoleBinding in each namespace that needs it.
  • roleRef is immutable: to change the role, the binding is deleted and created again.

Built-in roles and aggregation

The cluster already has roles for people:

ClusterRole What it gives
cluster-admin everything on everything; through a ClusterRoleBinding — on the whole cluster, through a RoleBinding — full control of one namespace
admin almost everything in a namespace, including Roles and RoleBindings within it; quotas (resourcequotas) are read-only, the namespace itself can't be changed
edit read and write most objects, including Secrets, pods/exec and issuing tokens of any ServiceAccount of the namespace; no roles or bindings
view read most objects except Secrets; no pods/exec

admin, edit and view are aggregated roles: their rules are not written in them, but collected by a controller from all ClusterRoles with certain labels. This is how the CRD from the previous part becomes available to holders of the standard roles without editing the roles themselves:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: greetings-view
  labels:
    rbac.authorization.k8s.io/aggregate-to-view: "true"
    rbac.authorization.k8s.io/aggregate-to-edit: "true"
    rbac.authorization.k8s.io/aggregate-to-admin: "true"
rules:
  - apiGroups: ["demo.example.com"]
    resources: ["greetings"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: greetings-edit
  labels:
    rbac.authorization.k8s.io/aggregate-to-edit: "true"
    rbac.authorization.k8s.io/aggregate-to-admin: "true"
rules:
  - apiGroups: ["demo.example.com"]
    resources: ["greetings", "greetings/scale"]
    verbs: ["create", "update", "patch", "delete", "deletecollection"]

Good operators install such roles themselves; when assessing an operator (part 28) it's worth checking that it doesn't add reading of something sensitive to view.

Besides the roles for people, there are dozens of system:* roles for components (system:kube-scheduler, system:node, system:controller:*): on the lab cluster 66 of 84 ClusterRoles are system ones. Leave them alone: the apiserver restores them on every start.


Dangerous permissions

RBAC looks as if permissions were independent. In fact some of them are a stepping stone to others, and granting one equals granting more.

get, list, watch on secrets. Direct reading of all passwords, keys and tokens of the namespace (or the cluster, if through a ClusterRoleBinding). list is enough — get by name isn't needed.

create on pods (and on everything that creates pods: deployments, jobs, cronjobs, daemonsets, statefulsets). Whoever creates a pod chooses what to mount into it and under which ServiceAccount to run it. So they can:

  • mount any Secret of the namespace as a volume and read it — they need no permission on secrets, the kubelet reads the Secret itself;
  • run a pod as any ServiceAccount of the namespace and get its token — that is, all its permissions;
  • if Pod Security doesn't forbid it, create a privileged pod with hostPath: / and become root on the node (part 30).

So create pods in a namespace effectively means the permissions of all ServiceAccounts and access to all Secrets of that namespace.

create on pods/exec, pods/attach. Running commands in existing pods — with their tokens, their files and their network access. kubectl exec opens a WebSocket connection — a protocol that starts with an ordinary HTTP GET request with an Upgrade: websocket header and, after the 101 Switching Protocols response, turns the same TCP connection into a two-way channel for the input and output streams. That's why, historically, roles granting only get on pods/exec effectively allowed exec. Since 1.35 (beta, enabled by default) the apiserver also requires create for such a connection. User bob, whose Role grants get and list on pods and only get on pods/exec, gets a refusal:

error: unable to upgrade connection: pods "ci-runner-8695fbd774-jdh5k" is forbidden: User "bob" cannot create resource "pods/exec" in API group "" in the namespace "demo"

Still, treat roles with get on pods/exec as dangerous: on clusters older than 1.35 they allow exec.

escalate and bind. RBAC is protected against privilege escalation: you can create a Role or RoleBinding only if you already hold all the permissions it grants. Even with permission to create roles, alice is refused if the role contains something she doesn't have herself:

$ kubectl -n demo create role sneaky --verb=get --resource=secrets
Error from server (Forbidden): roles.rbac.authorization.k8s.io "sneaky" is forbidden: user "alice" (groups=["platform-admins" "system:authenticated"]) is attempting to grant RBAC permissions not currently held:
{APIGroups:[""], Resources:["secrets"], Verbs:["get"]}

escalate on roles/clusterroles lifts this protection when creating roles, bind — when binding roles whose permissions you don't have. Either of them is a path to cluster-admin.

impersonate on users, groups, serviceaccounts. Allows making requests under someone else's name (headers Impersonate-User, Impersonate-Group; in kubectl — --as, --as-group). Whoever can pretend to be the kubeadm:cluster-admins group is an administrator. Since 1.36 constrained impersonation is enabled by default (beta): the verb impersonate:user-info on the users resource with resourceNames allows impersonating only the named users, and verbs like impersonate-on:user-info:list on ordinary resources allow only the listed actions on their behalf:

rules:                                   # ClusterRole: whom you may impersonate
  - apiGroups: ["authentication.k8s.io"]
    resources: ["users"]
    resourceNames: ["bob"]
    verbs: ["impersonate:user-info"]
---
rules:                                   # Role in demo: what you may do on their behalf
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["impersonate-on:user-info:list"]
$ kubectl -n demo get pods --as=bob
NAME                         READY   STATUS    RESTARTS   AGE
ci-runner-8695fbd774-jdh5k   1/1     Running   0          9m
hello-6b8ccbff4c-jjdqz       1/1     Running   0          9m
$ kubectl -n demo delete pod ci-runner-8695fbd774-jdh5k --as=bob
Error from server (Forbidden): pods "ci-runner-8695fbd774-jdh5k" is forbidden: User "alice" cannot impersonate-on:user-info:delete resource "pods" in API group "" in the namespace "demo"
$ kubectl -n demo get pods --as=carol
Error from server (Forbidden): users.authentication.k8s.io "carol" is forbidden: User "alice" cannot impersonate:user-info resource "users" in API group "authentication.k8s.io" at the cluster scope

The request passes both checks: bob's own permissions and alice's permission to act on his behalf. ServiceAccounts and nodes have their own verbs (impersonate:serviceaccount and so on). The old impersonate verb keeps working and gives unrestricted impersonation.

nodes/proxy. Direct access to the kubelet API on nodes through the apiserver — which means exec into any pod on the node, reading logs and metrics. In 1.36 fine-grained kubelet authorization became stable: metrics, stats and logs have separate subresources nodes/metrics, nodes/stats, nodes/log, so monitoring systems no longer need nodes/proxy. But nodes/proxy itself remains equal to root on all nodes.

create on serviceaccounts/token. Issuing tokens on behalf of any ServiceAccount of the namespace — the same as owning them. The built-in edit role grants it.

update/patch on validatingwebhookconfigurations, mutatingwebhookconfigurations. You can register a webhook that receives all created objects, including Secrets, and alters them.

approve on certificatesigningrequests together with permission to create CSRs — issuing yourself a client certificate with any CN and O.

Hence the review rule: Roles and ClusterRoles must have no * in verbs, resources or apiGroups — except cluster-admin.


Least privilege for an application

Every namespace gets a default ServiceAccount, and every pod without an explicit serviceAccountName runs as it. default has no permissions by itself, but its token is mounted into every pod. If someone once grants default permissions ("to make it work"), every pod of the namespace gets them. And a token lying in a container is the first thing an attacker who got code execution in the application looks for.

The right way:

  1. For an application that doesn't need the API (and that's almost all of them: hello, databases, web servers), don't mount the token:
apiVersion: v1
kind: ServiceAccount
metadata:
  name: hello
  namespace: demo
automountServiceAccountToken: false
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello
  namespace: demo
spec:
  template:
    spec:
      serviceAccountName: hello
      automountServiceAccountToken: false
      …

The field exists on both the ServiceAccount and the pod; the pod's value wins. Setting it in both places protects against someone changing one of them.

  1. For an application that needs the API (controller, operator, CI) — a separate ServiceAccount, a Role instead of a ClusterRole if one namespace is enough, only the necessary verbs and, where possible, resourceNames.

  2. For an external system (a CI server outside the cluster) — short-lived tokens via kubectl create token with --duration and --audience as needed, or better, identity federation: if the CI can issue OIDC tokens to its jobs, it can be added to AuthenticationConfiguration as one more provider, and not a single long-lived secret will be stored in Kubernetes.


Hands-on

The experiments in the demo namespace need two ServiceAccounts and two Deployments: hello from the earlier parts, switched to the hello ServiceAccount without a token (as in the section above), and ci-runner — busybox running as the ci ServiceAccount with the default token:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: ci
  namespace: demo
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ci-runner
  namespace: demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: ci-runner
  template:
    metadata:
      labels:
        app.kubernetes.io/name: ci-runner
    spec:
      serviceAccountName: ci
      containers:
        - name: runner
          image: busybox:1.37
          command: ["sleep", "infinity"]

Who am I:

$ kubectl auth whoami
ATTRIBUTE                                           VALUE
Username                                            kubernetes-admin
Groups                                              [kubeadm:cluster-admins system:authenticated]
Extra: authentication.kubernetes.io/credential-id   [X509SHA256=6ff9adf123f0ab47fe9d5bb2f450928eefcf619e3f52b22f46d3d85b4a5e92d2]

The command asks the apiserver (the SelfSubjectReview resource) and shows how it sees the current credential — useful when it's unclear which of the kubeconfig users was used. In Extra the apiserver puts the SHA-256 fingerprint of the client certificate: it lets you find the requests of a specific certificate in the audit log.

Anonymous access — no kubeconfig, just curl:

$ curl -sk https://k8s-api.example.com:6443/livez
ok
$ curl -sk https://k8s-api.example.com:6443/api
{
  "kind": "Status",
  "apiVersion": "v1",
  "metadata": {},
  "status": "Failure",
  "message": "forbidden: User \"system:anonymous\" cannot get path \"/api\"",
  "reason": "Forbidden",
  "details": {},
  "code": 403
}

/version also answers anonymously, everything else — 403: the apiserver assigned the name system:anonymous, but RBAC gave it nothing.

Granting alice access through a CSR:

$ kubectl apply -f alice-csr.yaml
certificatesigningrequest.certificates.k8s.io/alice created

$ kubectl get csr alice
NAME    AGE   SIGNERNAME                            REQUESTOR          REQUESTEDDURATION   CONDITION
alice   0s    kubernetes.io/kube-apiserver-client   kubernetes-admin   7d                  Pending

$ kubectl certificate approve alice
certificatesigningrequest.certificates.k8s.io/alice approved

$ kubectl get csr alice
NAME    AGE   SIGNERNAME                            REQUESTOR          REQUESTEDDURATION   CONDITION
alice   2s    kubernetes.io/kube-apiserver-client   kubernetes-admin   7d                  Approved,Issued

$ kubectl get csr alice -o jsonpath='{.status.certificate}' | base64 -d > alice.crt
$ openssl x509 -in alice.crt -noout -subject -issuer -dates
subject=O=platform-admins, CN=alice
issuer=CN=kubernetes
notBefore=Nov  1 19:54:51 2026 GMT
notAfter=Nov  8 19:54:51 2026 GMT

notBefore is five minutes before the approval: the signer backdates the start of validity in case of clock skew. Adding the user and a context to kubeconfig:

$ kubectl config set-credentials alice-cert --client-certificate=alice.crt --client-key=alice.key --embed-certs=true
User "alice-cert" set.
$ kubectl config set-context lab-alice-cert --cluster=kubernetes --user=alice-cert --namespace=demo
Context "lab-alice-cert" created.

$ kubectl --context lab-alice-cert get pods
Error from server (Forbidden): pods is forbidden: User "alice" cannot list resource "pods" in API group "" in the namespace "demo"
$ kubectl --context lab-alice-cert auth whoami
ATTRIBUTE                                           VALUE
Username                                            alice
Groups                                              [platform-admins system:authenticated]
Extra: authentication.kubernetes.io/credential-id   [X509SHA256=6c543d4596112bd6ce44b45ad6ec15db737eee77ecd758a851f3b57b34d7dab4]

Authentication succeeded — the apiserver knows her name and groups, auth whoami is allowed to anyone authenticated — but there are no permissions on pods. Grant view in demo to the username and check as the administrator through impersonation:

$ kubectl -n demo create rolebinding alice-view --clusterrole=view --user=alice
rolebinding.rbac.authorization.k8s.io/alice-view created

$ kubectl -n demo auth can-i list pods --as=alice
yes
$ kubectl -n demo auth can-i get secrets --as=alice
no
$ kubectl -n demo auth can-i create pods/exec --as=alice
no
$ kubectl auth can-i list pods --as=alice -n kube-system
no

auth can-i uses the SubjectAccessReview API: the apiserver runs the request through the authorizers without executing anything. The full list of a subject's permissions in a namespace:

$ kubectl -n demo auth can-i --list --as=system:serviceaccount:demo:hello
Resources                                       Non-Resource URLs                      Resource Names   Verbs
selfsubjectreviews.authentication.k8s.io        []                                     []               [create]
selfsubjectaccessreviews.authorization.k8s.io   []                                     []               [create]
selfsubjectrulesreviews.authorization.k8s.io    []                                     []               [create]
globalnetworkpolicies.projectcalico.org         []                                     []               [get list watch create update patch delete deletecollection]
networkpolicies.projectcalico.org               []                                     []               [get list watch create update patch delete deletecollection]
                                                [/.well-known/openid-configuration/]   []               [get]
                                                [/.well-known/openid-configuration]    []               [get]
                                                [/api/*]                               []               [get]
                                                [/api]                                 []               [get]
                                                [/apis/*]                              []               [get]
                                                [/apis]                                []               [get]
                                                [/healthz]                             []               [get]
                                                [/healthz]                             []               [get]
                                                [/livez]                               []               [get]
                                                [/livez]                               []               [get]
                                                [/openapi/*]                           []               [get]
                                                [/openapi]                             []               [get]
                                                [/openid/v1/jwks/]                     []               [get]
                                                [/openid/v1/jwks]                      []               [get]
                                                [/readyz]                              []               [get]
                                                [/readyz]                              []               [get]
                                                [/version/]                            []               [get]
                                                [/version/]                            []               [get]
                                                [/version]                             []               [get]
                                                [/version]                             []               [get]

The hello ServiceAccount has only what anyone authenticated has: learn about itself, read the list of APIs and service URLs. The paths repeat because two roles grant them: system:public-info-viewer (to everyone, anonymous included) and system:discovery (to the authenticated). /.well-known/openid-configuration and /openid/v1/jwks are the public keys that sign ServiceAccount tokens: the system:service-account-issuer-discovery role grants them to all ServiceAccounts, so that external systems can verify pod tokens.

The unexpected lines are full permissions on networkpolicies.projectcalico.org and globalnetworkpolicies.projectcalico.org. They are granted to all authenticated users by the calico-tiered-policy-passthrough ClusterRoleBinding, while the real check is done by Calico itself: Calico policies are arranged in tiers, and its API server additionally requires permissions on the tier. Without them — a refusal:

$ kubectl -n demo get networkpolicies.projectcalico.org --as=system:serviceaccount:demo:hello
Error from server (Forbidden): networkpolicies.projectcalico.org is forbidden: operation on Calico tiered policy is forbidden

Hence an important limitation of auth can-i: it shows only the RBAC decision in kube-apiserver. Aggregated APIs (kube-apiserver passes some requests on to separate API servers, like calico-apiserver) may check something else.

A ServiceAccount token and its contents:

$ kubectl -n demo create token hello --duration=10m | cut -d. -f2 | base64 -d 2>/dev/null | jq
{
  "aud": [
    "https://kubernetes.default.svc.cluster.local"
  ],
  "exp": 1793563801,
  "iat": 1793563201,
  "iss": "https://kubernetes.default.svc.cluster.local",
  "jti": "2588cc28-1335-4888-9a21-e9dec8cca842",
  "kubernetes.io": {
    "namespace": "demo",
    "serviceaccount": {
      "name": "hello",
      "uid": "fa0b64f7-c3a0-455d-b0c8-833e4c88b67e"
    }
  },
  "nbf": 1793563201,
  "sub": "system:serviceaccount:demo:hello"
}

A token can be issued for a ServiceAccount without automounting, too: automountServiceAccountToken controls only the volume in the pod. The apiserver won't issue a token shorter than 10 minutes:

$ kubectl -n demo create token hello --duration=5m
error: failed to create token: TokenRequest.authentication.k8s.io "" is invalid: spec.expirationSeconds: Invalid value: 300: may not specify a duration less than 10 minutes

And here is the token the kubelet mounted into a pod of the ci ServiceAccount (automounting isn't disabled for it) — bound to the pod and the node:

$ kubectl -n demo exec deploy/ci-runner -- cat /var/run/secrets/kubernetes.io/serviceaccount/token \
    | cut -d. -f2 | base64 -d 2>/dev/null | jq '{iat, exp, "kubernetes.io"}'
{
  "iat": 1793563172,
  "exp": 1825099172,
  "kubernetes.io": {
    "namespace": "demo",
    "node": {
      "name": "k8s-node-1",
      "uid": "60c90f02-4a49-4cce-b306-dcefb70c0b9f"
    },
    "pod": {
      "name": "ci-runner-8695fbd774-jdh5k",
      "uid": "054e17af-58e3-419e-b294-f3d716236fd3"
    },
    "serviceaccount": {
      "name": "ci",
      "uid": "8758de5d-da1d-479a-9e3a-2581c6c25214"
    },
    "warnafter": 1793566779
  }
}

exp is exactly a year after iat (the extension for old clients), while warnafter is the real lifetime, 3607 seconds: if the token is presented after it, the apiserver counts it in the stale token metric. Where the file came from is visible in the pod spec — it's a projected volume:

$ kubectl -n demo get pod -l app.kubernetes.io/name=ci-runner -o json | jq '.items[0].spec.volumes'
[
  {
    "name": "kube-api-access-q6ft5",
    "projected": {
      "defaultMode": 420,
      "sources": [
        {
          "serviceAccountToken": {
            "expirationSeconds": 3607,
            "path": "token"
          }
        },
        {
          "configMap": {
            "items": [
              {
                "key": "ca.crt",
                "path": "ca.crt"
              }
            ],
            "name": "kube-root-ca.crt"
          }
        },
        {
          "downwardAPI": {
            "items": [
              {
                "fieldRef": {
                  "apiVersion": "v1",
                  "fieldPath": "metadata.namespace"
                },
                "path": "namespace"
              }
            ]
          }
        }
      ]
    }
  }
]

The hello pod with automountServiceAccountToken: false has no such volume at all — its volume list is empty (and you can't look inside the container: the distroless hello image has neither a shell nor ls):

$ kubectl -n demo get pod -l app.kubernetes.io/name=hello -o jsonpath='{.items[0].spec.volumes}'

And how aggregation works:

$ kubectl get clusterrole view -o jsonpath='{.aggregationRule}{"\n"}'
{"clusterRoleSelectors":[{"matchLabels":{"rbac.authorization.k8s.io/aggregate-to-view":"true"}}]}

$ kubectl get clusterrole view -o jsonpath='{range .rules[*]}{.apiGroups}{.resources}{"\n"}{end}' | grep demo
["demo.example.com"]["greetings"]

The rule from greetings-view appeared in view by itself — the aggregation controller in kube-controller-manager added it. The CRD itself doesn't even have to exist in the cluster: roles refer to the resource by name.

Finally: who in the cluster has dangerous permissions. There is no built-in report, but listing the bindings to cluster-admin takes one command:

$ kubectl get clusterrolebindings -o json | jq -r '.items[] | select(.roleRef.name=="cluster-admin") | "\(.metadata.name): \([.subjects[]? | "\(.kind)/\(.name)"] | join(", "))"'
cluster-admin: Group/system:masters
kubeadm:cluster-admins: Group/kubeadm:cluster-admins

If a ServiceAccount of some chart shows up in this list, that's a reason to look into it before you have to after an incident.

Cleanup: kubectl delete csr alice, kubectl delete clusterrole greetings-view greetings-edit, the alice-cert user and context — kubectl config delete-user alice-cert and kubectl config delete-context lab-alice-cert.


Authentication and RBAC determine who can do what with API objects. But what ends up inside a pod — as whom the process runs on the node, which capabilities it has, whether it can mount a node directory — RBAC doesn't control: the create pods permission gives all or nothing.


Sources


Next — pod security: securityContext, running as non-root, capabilities, seccomp and AppArmor, user namespaces, the Pod Security standards and how Pod Security Admission enforces them, and custom admission rules in CEL.

Only registered users can leave a comment.

Sign in or register to leave a comment.