Lesson 54 of 66 · 18 min read · Updated

ServiceAccounts and Pod Identity in Kubernetes

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:

  1. What a ServiceAccount is and what every Pod receives automatically
  2. Creating a dedicated ServiceAccount and binding RBAC permissions to it
  3. Testing permissions with kubectl auth can-i --as
  4. Bound ServiceAccount tokens: audience, expiry, and why the token in your Pod says it expires in a year
  5. Issuing tokens by hand with kubectl create token
  6. Calling the API server from a .NET 10 application with the official KubernetesClient library
  7. Turning token mounting off with automountServiceAccountToken: false
  8. Projecting a token with a custom audience for an external system such as Vault

Technical Requirements

  1. 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 with kind create cluster behaves the same way.
  2. Docker and the .NET 10 SDK to build the sample application.
  3. 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.

UserServiceAccount
Who uses itA human, or a CI system acting like oneA process running inside a Pod
Stored in KubernetesNo. Users come from certificates, OIDC, or another authenticatorYes. A namespaced object you create with kubectl
ScopeCluster-wide nameLives in one namespace
Full name in RBACWhatever the authenticator says, for example [email protected]system:serviceaccount:<namespace>:<name>
CredentialCertificate or OIDC tokenA 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:

FileContent
/var/run/secrets/kubernetes.io/serviceaccount/tokenA signed JWT that proves the Pod's identity
/var/run/secrets/kubernetes.io/serviceaccount/ca.crtThe cluster CA certificate, used to trust the API server
/var/run/secrets/kubernetes.io/serviceaccount/namespaceThe 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:

  1. Create a ServiceAccount for each workload that needs to talk to the API server.
  2. Bind a Role (or ClusterRole) with the smallest set of verbs and resources it needs.
  3. Set serviceAccountName in 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:

  1. has an audience (aud). The API server only accepts tokens whose audience is its own, https://kubernetes.default.svc.cluster.local by default.
  2. has an expiry (exp). The kubelet refreshes the file in the Pod before the token expires.
  3. 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:

volumes:
- name: vault-token
projected:
sources:
- serviceAccountToken:
audience: vault
expirationSeconds: 600
path: vault-token

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.

kubectl get serviceaccounts -n sec-identity

See which ServiceAccount a Pod runs as

An empty serviceAccountName in your manifest becomes default here.

kubectl get pod <pod-name> -n sec-identity -o jsonpath='{.spec.serviceAccountName}'

Create a ServiceAccount imperatively

Handy for experiments. Use YAML for anything you keep.

kubectl create serviceaccount pod-lister -n sec-identity

Check one permission of a ServiceAccount

--as impersonates the ServiceAccount. Your own user needs the impersonate permission, which cluster admins have.

kubectl auth can-i list pods -n sec-identity --as=system:serviceaccount:sec-identity:pod-lister

List everything a ServiceAccount may do

Useful when you inherit a cluster and want to know what a workload can really do.

kubectl auth can-i --list -n sec-identity --as=system:serviceaccount:sec-identity:pod-lister

Issue a short-lived token

The default lifetime is one hour, and the minimum is ten minutes.

kubectl create token pod-lister -n sec-identity --duration=10m

Issue a token for another audience

The API server rejects it, which is exactly the point.

kubectl create token pod-lister -n sec-identity --audience=vault

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.

kubectl create token pod-lister -n sec-identity | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo

Find who can use a ServiceAccount's permissions

RoleBindings and ClusterRoleBindings name it as a subject.

kubectl get rolebindings,clusterrolebindings -A -o wide | grep pod-lister

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:

  1. /whoami decodes the token mounted in the Pod and shows who the Pod is.
  2. /pods calls 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.

dotnet new web -n PodLister
cd PodLister
dotnet add package KubernetesClient --version 21.0.8

Replace Program.cs:

using System.Text;
using System.Text.Json;
using k8s;
using k8s.Autorest;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

const string TokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token";
const string NamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace";

app.MapGet("/whoami", () =>
{
if (!File.Exists(TokenPath))
return Results.Problem("No ServiceAccount token is mounted in this Pod.", statusCode: 500);

// A ServiceAccount token is a JWT. The middle part holds the claims.
var payload = File.ReadAllText(TokenPath).Split('.')[1];
payload = payload.Replace('-', '+').Replace('_', '/').PadRight(payload.Length + (4 - payload.Length % 4) % 4, '=');
using var claims = JsonDocument.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(payload)));
var root = claims.RootElement;
var k8sClaims = root.GetProperty("kubernetes.io");

return Results.Ok(new
{
subject = root.GetProperty("sub").GetString(),
audience = root.GetProperty("aud")[0].GetString(),
expiresAt = DateTimeOffset.FromUnixTimeSeconds(root.GetProperty("exp").GetInt64()),
// Present only when the API server extended the expiry; the kubelet refreshes the token before this time.
warnAfter = k8sClaims.TryGetProperty("warnafter", out var w) ? DateTimeOffset.FromUnixTimeSeconds(w.GetInt64()) : (DateTimeOffset?)null,
pod = k8sClaims.GetProperty("pod").GetProperty("name").GetString(),
@namespace = File.ReadAllText(NamespacePath)
});
});

app.MapGet("/pods", async () =>
{
if (!KubernetesClientConfiguration.IsInCluster())
return Results.Problem("Not running inside a Kubernetes Pod.", statusCode: 500);

// InClusterConfig reads the mounted token, CA certificate and namespace.
var config = KubernetesClientConfiguration.InClusterConfig();
using var client = new Kubernetes(config);

try
{
var pods = await client.CoreV1.ListNamespacedPodAsync(config.Namespace);
return Results.Ok(pods.Items.Select(p => new { name = p.Metadata.Name, phase = p.Status.Phase }));
}
catch (HttpOperationException ex)
{
return Results.Problem(ex.Response.Content, statusCode: (int)ex.Response.StatusCode);
}
});

app.Run();

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:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY PodLister.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
USER app
ENTRYPOINT ["dotnet", "PodLister.dll"]

Build the image:

docker build -t pod-lister:1.0 .

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:

apiVersion: v1
kind: Namespace
metadata:
name: sec-identity
kubectl apply -f 1-namespace.yaml
kubectl get serviceaccounts -n sec-identity
NAME AGE
default 0s

The default ServiceAccount appeared on its own. Now run the application without naming any ServiceAccount:

apiVersion: v1
kind: Pod
metadata:
name: no-identity
namespace: sec-identity
spec:
containers:
- name: pod-lister
image: pod-lister:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
kubectl apply -f 2-default-sa-pod.yaml
kubectl get pod no-identity -n sec-identity -o jsonpath='{.spec.serviceAccountName}'
default

Kubernetes filled in default, and it also added a volume you never wrote. This is how the token reaches the container:

kubectl get pod no-identity -n sec-identity -o yaml
...
volumes:
- name: kube-api-access-f6bct
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

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:

kubectl port-forward pod/no-identity 18201:8080 -n sec-identity

In a second terminal:

curl -s localhost:18201/whoami
{
"subject": "system:serviceaccount:sec-identity:default",
"audience": "https://kubernetes.default.svc.cluster.local",
"expiresAt": "2027-10-21T08:22:51+00:00",
"warnAfter": "2026-10-21T09:22:58+00:00",
"pod": "no-identity",
"namespace": "sec-identity"
}

Read it line by line:

  1. subject is the full ServiceAccount name, exactly what RBAC will see.
  2. audience is the API server. Nobody else should accept this token.
  3. expiresAt is a year away, but warnAfter is one hour away. This is the extended expiry described in Core Concepts. The kubelet replaces the token long before warnAfter.
  4. pod shows that the token is bound to this specific Pod.

Now try to list Pods:

curl -s localhost:18201/pods
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.4","title":"Forbidden","status":403,
"detail":"... pods is forbidden: User \"system:serviceaccount:sec-identity:default\" cannot list resource \"pods\" in API group \"\" in the namespace \"sec-identity\" ..."}

Authentication worked: the API server knows exactly who is calling. Authorization failed, because default has no permissions. You can confirm it without the application:

kubectl auth can-i list pods -n sec-identity --as=system:serviceaccount:sec-identity:default
no

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:

apiVersion: v1
kind: ServiceAccount
metadata:
name: pod-lister
namespace: sec-identity
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pod-reader
namespace: sec-identity
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: pod-lister-reads-pods
namespace: sec-identity
subjects:
- kind: ServiceAccount
name: pod-lister
namespace: sec-identity
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io
kubectl apply -f 3-serviceaccount-rbac.yaml
serviceaccount/pod-lister created
role.rbac.authorization.k8s.io/pod-reader created
rolebinding.rbac.authorization.k8s.io/pod-lister-reads-pods created

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:

kubectl auth can-i list pods -n sec-identity --as=system:serviceaccount:sec-identity:pod-lister
yes
kubectl auth can-i get pods -n sec-identity --as=system:serviceaccount:sec-identity:pod-lister
yes
kubectl auth can-i delete pods -n sec-identity --as=system:serviceaccount:sec-identity:pod-lister
no
kubectl auth can-i list secrets -n sec-identity --as=system:serviceaccount:sec-identity:pod-lister
no
kubectl auth can-i list deployments.apps -n sec-identity --as=system:serviceaccount:sec-identity:pod-lister
no
kubectl auth can-i list pods -n default --as=system:serviceaccount:sec-identity:pod-lister
no

Read-only, Pods only, this namespace only. The full list confirms there is nothing else:

kubectl auth can-i --list -n sec-identity --as=system:serviceaccount:sec-identity:pod-lister
Resources Non-Resource URLs Resource Names Verbs
selfsubjectreviews.authentication.k8s.io [] [] [create]
selfsubjectaccessreviews.authorization.k8s.io [] [] [create]
selfsubjectrulesreviews.authorization.k8s.io [] [] [create]
pods [] [] [get list watch]
[/.well-known/openid-configuration/] [] [get]
[/.well-known/openid-configuration] [] [get]
[/api/*] [] [get]
...

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:

TOKEN=$(kubectl create token pod-lister -n sec-identity --duration=10m)
echo $TOKEN | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
{
"aud": ["https://kubernetes.default.svc.cluster.local"],
"exp": 1792571591,
"iat": 1792570991,
"iss": "https://kubernetes.default.svc.cluster.local",
"jti": "6aa6dc2d-71e2-4863-b6ce-3e7f94f9f1ed",
"kubernetes.io": {
"namespace": "sec-identity",
"serviceaccount": {
"name": "pod-lister",
"uid": "5b24a65c-c034-44d0-97c6-db962dfd0fa0"
}
},
"nbf": 1792570991,
"sub": "system:serviceaccount:sec-identity:pod-lister"
}

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:

kubectl create token pod-lister -n sec-identity --duration=5s
error: failed to create token: TokenRequest.authentication.k8s.io "" is invalid: spec.expirationSeconds: Invalid value: 5: may not specify a duration less than 10 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:

SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
curl -sk -H "Authorization: Bearer $TOKEN" $SERVER/api/v1/namespaces/sec-identity/pods
... "items": [ { "metadata": { "name": "no-identity", ... } } ] ...
curl -sk -H "Authorization: Bearer $TOKEN" $SERVER/api/v1/namespaces/sec-identity/secrets
... "message": "secrets is forbidden: User \"system:serviceaccount:sec-identity:pod-lister\" cannot list resource \"secrets\" in API group \"\" in the namespace \"sec-identity\"", "code": 403

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:

apiVersion: apps/v1
kind: Deployment
metadata:
name: pod-lister
namespace: sec-identity
spec:
replicas: 2
selector:
matchLabels:
app: pod-lister
template:
metadata:
labels:
app: pod-lister
spec:
serviceAccountName: pod-lister
containers:
- name: pod-lister
image: pod-lister:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
kubectl apply -f 4-pod-lister-deployment.yaml
kubectl rollout status deploy/pod-lister -n sec-identity
kubectl get pods -n sec-identity -o custom-columns=NAME:.metadata.name,SA:.spec.serviceAccountName,STATUS:.status.phase
NAME SA STATUS
no-identity default Running
pod-lister-75677b4d5f-8699b pod-lister Running
pod-lister-75677b4d5f-twftp pod-lister Running

Forward a port and call both endpoints:

kubectl port-forward deploy/pod-lister 18202:8080 -n sec-identity
curl -s localhost:18202/whoami
{
"subject": "system:serviceaccount:sec-identity:pod-lister",
"audience": "https://kubernetes.default.svc.cluster.local",
"expiresAt": "2027-10-21T08:23:20+00:00",
"warnAfter": "2026-10-21T09:23:27+00:00",
"pod": "pod-lister-75677b4d5f-8699b",
"namespace": "sec-identity"
}
curl -s localhost:18202/pods
[
{ "name": "no-identity", "phase": "Running" },
{ "name": "pod-lister-75677b4d5f-8699b", "phase": "Running" },
{ "name": "pod-lister-75677b4d5f-twftp", "phase": "Running" }
]

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:

apiVersion: v1
kind: Pod
metadata:
name: no-token
namespace: sec-identity
spec:
serviceAccountName: pod-lister
automountServiceAccountToken: false
containers:
- name: pod-lister
image: pod-lister:1.0
imagePullPolicy: IfNotPresent
kubectl apply -f 5-no-token.yaml
kubectl exec no-token -n sec-identity -- ls /var/run/secrets/kubernetes.io/serviceaccount/
ls: cannot access '/var/run/secrets/kubernetes.io/serviceaccount/': No such file or directory
command terminated with exit code 2

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:

curl -s localhost:18203/whoami
{"...","status":500,"detail":"No ServiceAccount token is mounted in this Pod."}
curl -s localhost:18203/pods
{"...","status":500,"detail":"Not running inside a Kubernetes Pod."}

You can set automountServiceAccountToken: false in two places:

WhereEffect
On the ServiceAccountPods using it get no token unless the Pod asks for one
On the Pod specOverrides 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:

apiVersion: v1
kind: Pod
metadata:
name: vault-client
namespace: sec-identity
spec:
serviceAccountName: pod-lister
automountServiceAccountToken: false
containers:
- name: pod-lister
image: pod-lister:1.0
imagePullPolicy: IfNotPresent
volumeMounts:
- name: vault-token
mountPath: /var/run/secrets/tokens
readOnly: true
volumes:
- name: vault-token
projected:
sources:
- serviceAccountToken:
audience: vault
expirationSeconds: 600
path: vault-token
kubectl apply -f 6-projected-token.yaml
kubectl exec vault-client -n sec-identity -- ls -l /var/run/secrets/tokens/
lrwxrwxrwx 1 root root 18 Oct 21 08:23 vault-token -> ..data/vault-token

Decode it:

kubectl exec vault-client -n sec-identity -- cat /var/run/secrets/tokens/vault-token | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
{"aud":["vault"],"exp":1792571616,"iat":1792571016,"sub":"system:serviceaccount:sec-identity:pod-lister", "kubernetes.io":{ ... "pod":{"name":"vault-client", ...} ... }}

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:

VT=$(kubectl create token pod-lister -n sec-identity --audience=vault)
curl -sk -H "Authorization: Bearer $VT" $SERVER/api/v1/namespaces/sec-identity/pods
... "message": "Unauthorized", "code": 401

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.

kubectl delete namespace sec-identity

Summary

  1. Pods authenticate to the API server as a ServiceAccount, whose RBAC name is system:serviceaccount:<namespace>:<name>.
  2. Every namespace has a default ServiceAccount with no permissions. Pods that name nothing run as it and still get a token mounted.
  3. 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.
  4. Pod tokens are bound tokens: tied to an audience and to the Pod, refreshed by the kubelet about every hour. The one-year exp you see is a compatibility extension; warnafter shows the real refresh time.
  5. kubectl create token issues short-lived tokens, ten minutes minimum, for scripts and tests.
  6. In .NET, KubernetesClientConfiguration.InClusterConfig() from the KubernetesClient package uses the mounted token, CA and namespace, with no secrets in your code.
  7. Set automountServiceAccountToken: false for 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.