Lesson 54 of 66 · 18 min read · Updated
ServiceAccounts and Pod Identity in Kubernetes
On this page
In the previous lesson you used RBAC to decide what a person may do in the cluster. Your applications need the same thing. A .NET API that watches Pods, a job that reads ConfigMaps, an operator that creates Deployments: each of them talks to the Kubernetes API server, and the API server must know who is calling and what that caller is allowed to do.
People authenticate with certificates or an identity provider. Pods authenticate with a ServiceAccount. This lesson shows what Kubernetes gives every Pod by default, how to give a workload its own identity with exactly the permissions it needs, and how the short-lived tokens behind that identity actually work.
We will cover the following topics:
- What a ServiceAccount is and what every Pod receives automatically
- Creating a dedicated ServiceAccount and binding RBAC permissions to it
- Testing permissions with
kubectl auth can-i --as - Bound ServiceAccount tokens: audience, expiry, and why the token in your Pod says it expires in a year
- Issuing tokens by hand with
kubectl create token - Calling the API server from a .NET 10 application with the official KubernetesClient library
- Turning token mounting off with
automountServiceAccountToken: false - Projecting a token with a custom audience for an external system such as Vault
Technical Requirements
- A Kubernetes cluster and
kubectl. The outputs in this lesson come from Docker Desktop's built-in Kubernetes with the kind provisioner (one control plane and three workers, Kubernetes v1.36). A cluster created withkind create clusterbehaves the same way. - Docker and the .NET 10 SDK to build the sample application.
- The RBAC lesson that precedes this one. Roles and RoleBindings are used here without being re-explained.
Core Concepts
Users and ServiceAccounts
Kubernetes has two kinds of identities, and they are handled very differently.
| User | ServiceAccount | |
|---|---|---|
| Who uses it | A human, or a CI system acting like one | A process running inside a Pod |
| Stored in Kubernetes | No. Users come from certificates, OIDC, or another authenticator | Yes. A namespaced object you create with kubectl |
| Scope | Cluster-wide name | Lives in one namespace |
| Full name in RBAC | Whatever the authenticator says, for example [email protected] | system:serviceaccount:<namespace>:<name> |
| Credential | Certificate or OIDC token | A signed JWT issued by the API server |
The full name matters. When you test permissions, write audit rules, or read an error message, the ServiceAccount appears as system:serviceaccount:<namespace>:<name>.
What Every Pod Gets by Default
Every namespace is created with a ServiceAccount called default. A Pod that does not name a ServiceAccount runs as default in its namespace, and Kubernetes mounts three files into it:
| File | Content |
|---|---|
/var/run/secrets/kubernetes.io/serviceaccount/token | A signed JWT that proves the Pod's identity |
/var/run/secrets/kubernetes.io/serviceaccount/ca.crt | The cluster CA certificate, used to trust the API server |
/var/run/secrets/kubernetes.io/serviceaccount/namespace | The Pod's namespace as plain text |
The default ServiceAccount has no RBAC permissions of its own. The token proves who the Pod is, but by itself it allows almost nothing: the Pod can discover the API and check its own permissions, and that is all. This is a good default. It does mean that every Pod in your cluster carries a credential it probably never uses, which is why you will learn to switch it off later.
One ServiceAccount per Workload
The pattern you want is simple:
- Create a ServiceAccount for each workload that needs to talk to the API server.
- Bind a Role (or ClusterRole) with the smallest set of verbs and resources it needs.
- Set
serviceAccountNamein the Pod template.
Sharing one ServiceAccount between unrelated applications defeats the purpose. If one of them is compromised, the attacker inherits the permissions of all of them, and the audit log can no longer tell you which application made a call.
Bound ServiceAccount Tokens
Old Kubernetes versions stored ServiceAccount tokens in Secrets. Those tokens never expired and were not tied to anything. Since Kubernetes 1.24 Pods receive bound tokens instead, requested from the TokenRequest API. A bound token:
- has an audience (
aud). The API server only accepts tokens whose audience is its own,https://kubernetes.default.svc.cluster.localby default. - has an expiry (
exp). The kubelet refreshes the file in the Pod before the token expires. - is bound to the Pod. When the Pod is deleted, the token stops being valid, even if its expiry time has not passed.
The kubelet requests a token valid for about one hour (expirationSeconds: 3607) and rewrites the file when it reaches 80 percent of that lifetime. Your application must therefore re-read the token file rather than cache it forever. The official client libraries, including the .NET one, do this for you.
Why the Token Says It Expires in a Year
When you decode the token inside a Pod you will see an expiry one year away. This is not a mistake. By default the API server extends the expiry of tokens mounted into Pods so that older client libraries, which read the token once and never again, keep working. The token then carries a warnafter claim with the real refresh time. If a client uses the token after warnafter, the API server records it in metrics and audit logs, so you can find applications that would break with strict expiry.
Tokens you request yourself, either with kubectl create token or with a projected volume that sets a custom audience, are not extended. They expire exactly when they say they do.
Projected Tokens for Other Systems
The API server is not the only system that can verify a ServiceAccount token. HashiCorp Vault, cloud identity services (workload identity on AKS, EKS and GKE), and service meshes can all validate it through the cluster's OIDC discovery endpoint. You should not hand them the token meant for the API server. Instead, project a second token with their audience:
A token for vault is useless against the API server, and the API server token is useless against Vault. If one leaks, the damage is limited to one system. The minimum expirationSeconds is 600, ten minutes.
Hands-On: Kubernetes Commands
List the ServiceAccounts in a namespace
Every namespace has at least default.
See which ServiceAccount a Pod runs as
An empty serviceAccountName in your manifest becomes default here.
Create a ServiceAccount imperatively
Handy for experiments. Use YAML for anything you keep.
Check one permission of a ServiceAccount
--as impersonates the ServiceAccount. Your own user needs the impersonate permission, which cluster admins have.
List everything a ServiceAccount may do
Useful when you inherit a cluster and want to know what a workload can really do.
Issue a short-lived token
The default lifetime is one hour, and the minimum is ten minutes.
Issue a token for another audience
The API server rejects it, which is exactly the point.
Decode a token's claims
A JWT is three base64url parts separated by dots. The middle one holds the claims. JWTs drop base64 padding, so the last closing brace is sometimes cut off; pipe the result through jq or a JWT viewer if you want it formatted. The outputs below are formatted for reading.
Find who can use a ServiceAccount's permissions
RoleBindings and ClusterRoleBindings name it as a subject.
Step-by-Step Example
The Scenario
You are building PodLister, a small .NET 10 API that shows the Pods running in its own namespace, the kind of thing an internal dashboard or a deployment checker does. It has two endpoints:
/whoamidecodes the token mounted in the Pod and shows who the Pod is./podscalls the Kubernetes API to list Pods in the Pod's namespace.
You will run it first with the default identity and watch it fail, then give it a dedicated ServiceAccount with read-only access to Pods, and finally remove and replace its token.
Step 1 — Build the Application
Create the project and add the official Kubernetes client. The package is KubernetesClient, maintained by the Kubernetes project; version 21.0.8 was current when this lesson was written.
Replace Program.cs:
KubernetesClientConfiguration.InClusterConfig() is the important line. It finds the API server through the KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT environment variables that Kubernetes injects into every container, and it uses the three mounted files for the token, the CA and the namespace. There is no kubeconfig and no secret in your code. IsInCluster() returns false when those variables or the token file are missing.
Add a Dockerfile. The USER app line runs the process as the non-root user that Microsoft's .NET images already contain:
Build the image:
Docker Desktop's Kubernetes sees images you build locally, so no registry is needed. On a cluster created with the kind CLI, load the image into the nodes first with kind load docker-image pod-lister:1.0. The first Pod that uses the image on a given node may take a minute to start while the node fetches it.
Step 2 — Run It with the Default Identity
Create the namespace:
The default ServiceAccount appeared on its own. Now run the application without naming any ServiceAccount:
Kubernetes filled in default, and it also added a volume you never wrote. This is how the token reaches the container:
One projected volume combines three sources: a token from the TokenRequest API, the cluster CA from the kube-root-ca.crt ConfigMap that exists in every namespace, and the namespace name from the downward API.
Step 3 — Ask the Application Who It Is
Forward a local port to the Pod and call /whoami:
In a second terminal:
Read it line by line:
subjectis the full ServiceAccount name, exactly what RBAC will see.audienceis the API server. Nobody else should accept this token.expiresAtis a year away, butwarnAfteris one hour away. This is the extended expiry described in Core Concepts. The kubelet replaces the token long beforewarnAfter.podshows that the token is bound to this specific Pod.
Now try to list Pods:
Authentication worked: the API server knows exactly who is calling. Authorization failed, because default has no permissions. You can confirm it without the application:
Stop the port-forward with Ctrl+C.
Step 4 — Create a Dedicated ServiceAccount with Read-Only Access
PodLister needs to read Pods in its own namespace and nothing else. That is a ServiceAccount, a Role with three verbs on one resource, and a RoleBinding between them:
Note the subject: kind: ServiceAccount with a namespace. A ServiceAccount from one namespace can be bound in another namespace, so the namespace is always required.
Step 5 — Verify the Permissions Before Deploying
Test the permissions before any code depends on them:
Read-only, Pods only, this namespace only. The full list confirms there is nothing else:
The first three rows and the non-resource URLs come from built-in ClusterRoles that every authenticated identity has. The pods row is yours.
Step 6 — Use a Token by Hand
Before the application uses the identity, use it yourself. Request a ten-minute token and decode it:
exp minus iat is exactly 600 seconds. A token you request is not extended. There is no pod claim either, because this token is not bound to a Pod. Kubernetes refuses anything shorter than ten minutes:
Call the API server directly with the token. Take the server address from your kubeconfig and skip TLS verification only because this is a local test:
This is the whole mechanism without any library in between: a bearer token in, an RBAC decision out.
Step 7 — Deploy PodLister with Its Own Identity
The only change from the first Pod is one line, serviceAccountName:
Forward a port and call both endpoints:
Same code, same image, different identity. The /pods call now succeeds because the RoleBinding grants list on Pods to system:serviceaccount:sec-identity:pod-lister.
Step 8 — Turn Off Token Mounting
Most applications never call the Kubernetes API. For them the mounted token is pure risk: anyone who gets code execution in the container can read the file and use it. Turn it off:
The application now reports the missing token for /whoami and, for /pods, that it is not running inside Kubernetes, because IsInCluster() cannot find the token file:
You can set automountServiceAccountToken: false in two places:
| Where | Effect |
|---|---|
| On the ServiceAccount | Pods using it get no token unless the Pod asks for one |
| On the Pod spec | Overrides the ServiceAccount setting, in either direction |
A sensible baseline is to set it to false on every namespace's default ServiceAccount and to enable it only on the dedicated ServiceAccounts of workloads that really call the API.
Step 9 — Project a Token for Vault
Suppose PodLister also needs a secret from HashiCorp Vault, configured with the Kubernetes auth method. Vault should receive a token meant for Vault, not the API server token. Project one with its own audience and lifetime:
Decode it:
The audience is vault, the lifetime is exactly 600 seconds with no extension, and the token is bound to the vault-client Pod. The kubelet refreshes it automatically. Because automounting is off, this Pod has no API server token at all. It has exactly one credential, for exactly one system.
Prove that the API server rejects a token with the wrong audience:
This time it is 401 Unauthorized, not 403 Forbidden. The API server did not even accept the identity, because the token was not issued for it. The lesson "HashiCorp Vault + Vault Secrets Operator on Kubernetes" in this category shows the Vault side of this setup.
Step 10 — Clean Up
Deleting the namespace removes the ServiceAccounts, the Role, the RoleBinding and the Pods. Every token bound to those Pods becomes invalid at the same moment.
Summary
- Pods authenticate to the API server as a ServiceAccount, whose RBAC name is
system:serviceaccount:<namespace>:<name>. - Every namespace has a
defaultServiceAccount with no permissions. Pods that name nothing run as it and still get a token mounted. - Give each workload that calls the API its own ServiceAccount, a minimal Role, and a RoleBinding. Check the result with
kubectl auth can-i --as=system:serviceaccount:...before you deploy. - Pod tokens are bound tokens: tied to an audience and to the Pod, refreshed by the kubelet about every hour. The one-year
expyou see is a compatibility extension;warnaftershows the real refresh time. kubectl create tokenissues short-lived tokens, ten minutes minimum, for scripts and tests.- In .NET,
KubernetesClientConfiguration.InClusterConfig()from the KubernetesClient package uses the mounted token, CA and namespace, with no secrets in your code. - Set
automountServiceAccountToken: falsefor workloads that never call the API, and project tokens with a custom audience for external systems such as Vault.
In the next lesson you will lock down what the container itself may do, with securityContext and Pod Security Admission.