Kubernetes Service Account Token Security: Projection, Audience Binding, and Theft Prevention
Legacy Tokens vs Projected Tokens
Before Kubernetes 1.22, every service account automatically got a corresponding Secret of type kubernetes.io/service-account-token. The kubelet mounted this secret into every pod at /var/run/secrets/kubernetes.io/serviceaccount/token. These tokens have three properties that make them a persistent security liability:
No expiration. A legacy Secret-based token remains valid indefinitely. An attacker who exfiltrates the token from a compromised pod, a node filesystem snapshot, or an etcd backup can use it until the service account is deleted or the secret is manually removed. There is no TTL, no rotation, no automatic invalidation.
No audience binding. Legacy tokens carry a minimal claim set. They are valid for any service that trusts the cluster’s signing key, not just the API server. If you federate Kubernetes with an external OIDC-relying party, a stolen legacy token may be accepted there too.
Mounted into every pod regardless of need. A PostgreSQL pod gets the same auto-mounted token as your deployment controller. The database never contacts the API server, but the token gives any attacker who compromises it a valid API credential.
Kubernetes 1.22 introduced bound service account tokens via the TokenRequest API and projected volume sources. Kubernetes 1.24 stopped auto-generating Secret-based tokens for new service accounts. In clusters running 1.29+, when you create a service account, no token Secret is created automatically; tokens only exist when projected volumes or explicit kubectl create token requests produce them.
Projected tokens have fundamentally different security properties:
- They are time-limited via
expirationSeconds(minimum 600 seconds). - They are audience-bound: the
audclaim is set to the specified audience, and the API server rejects tokens whose audience does not match. - They are object-bound: the token’s
kubernetes.io/pod.uidclaim ties it to a specific pod UID. If the pod is deleted, the token becomes invalid even if the TTL has not elapsed. - The kubelet rotates them automatically when 80% of the lifetime has elapsed, before the application needs to handle expiry itself.
Token Projection: expirationSeconds, Audience, and Renewal
A projected service account token volume is declared in the pod spec under volumes.projected.sources.serviceAccountToken:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-controller
namespace: platform
spec:
replicas: 1
selector:
matchLabels:
app: api-controller
template:
metadata:
labels:
app: api-controller
spec:
serviceAccountName: api-controller
automountServiceAccountToken: false
containers:
- name: controller
image: registry.example.com/api-controller:2.4.1
volumeMounts:
- name: kube-api-token
mountPath: /var/run/secrets/tokens
readOnly: true
- name: kube-ca
mountPath: /var/run/secrets/kubernetes.io/ca
readOnly: true
volumes:
- name: kube-api-token
projected:
sources:
- serviceAccountToken:
path: token
expirationSeconds: 3600
audience: "https://kubernetes.default.svc"
- name: kube-ca
projected:
sources:
- configMap:
name: kube-root-ca.crt
items:
- key: ca.crt
path: ca.crt
expirationSeconds sets the requested lifetime. The API server may cap this at the value of --service-account-max-token-expiration (see below). The kubelet rotates the token when 80% of the lifetime has passed — for a 3600-second token, rotation happens at the 2880-second mark. Applications must read the token file on each request rather than caching it at startup. All official Kubernetes client libraries (client-go, the Python and Java clients) handle this by design: they re-read the file before each token use.
audience populates the aud JWT claim. The API server verifies that this claim matches its own issuer identity. A token projected with audience: "https://s3.amazonaws.com" will be rejected by the API server. This prevents a token stolen from one integration point from being replayed against another. For a workload that needs both API server access and AWS IRSA federation, declare two separate projected sources in the same volume or use separate volumes:
volumes:
- name: tokens
projected:
sources:
- serviceAccountToken:
path: kube-token
expirationSeconds: 3600
audience: "https://kubernetes.default.svc"
- serviceAccountToken:
path: aws-token
expirationSeconds: 3600
audience: "sts.amazonaws.com"
Each source produces a separate file under the volume’s mount path. The application reads the appropriate file for each call target.
Disabling Auto-Mounting
Set automountServiceAccountToken: false at both the ServiceAccount and the pod levels. The pod-level setting takes precedence, but setting both provides defense in depth: a future change to the ServiceAccount object does not silently re-enable mounting in all pods.
apiVersion: v1
kind: ServiceAccount
metadata:
name: web-frontend
namespace: production
automountServiceAccountToken: false
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-frontend
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: web-frontend
template:
metadata:
labels:
app: web-frontend
spec:
serviceAccountName: web-frontend
automountServiceAccountToken: false
containers:
- name: frontend
image: registry.example.com/web-frontend:5.1.0
securityContext:
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
Verify that no token is present in a running pod:
kubectl exec -n production deploy/web-frontend -- \
ls /var/run/secrets/kubernetes.io/serviceaccount/ 2>&1
Expected output is a “No such file or directory” error. If you see a token file, the pod spec or service account has automountServiceAccountToken set to true somewhere in the inheritance chain.
To find all pods currently running with auto-mounted tokens:
kubectl get pods --all-namespaces -o json | jq -r '
.items[]
| select(
.spec.automountServiceAccountToken != false
and (.spec.serviceAccountName // "default") != ""
)
| "\(.metadata.namespace)/\(.metadata.name)"
'
This output is your remediation backlog. Work through it namespace by namespace, confirming which pods actually need API server access before adding projected volumes.
Audience-Bound Tokens for OIDC Federation
OIDC federation lets a pod authenticate to an external system — AWS STS, GCP STS, Vault, an internal authorization service — using a Kubernetes-issued JWT instead of a stored credential. The external system acts as the relying party: it fetches the JWKS from the cluster’s OIDC discovery endpoint and verifies the token’s signature, issuer, audience, and expiry.
The audience field in the projected volume source is what makes this work securely. For AWS IRSA, the token must carry aud: sts.amazonaws.com. For GCP Workload Identity, it is audience: https://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID. For HashiCorp Vault’s Kubernetes auth method, the audience is whatever you configured in the Vault role.
A concrete IRSA example: the pod requests a projected token with audience: sts.amazonaws.com. The AWS SDK finds the token file via AWS_WEB_IDENTITY_TOKEN_FILE, calls sts:AssumeRoleWithWebIdentity presenting the token, and receives temporary AWS credentials valid for the role’s session duration. The Kubernetes token itself never reaches AWS services directly — STS is the only endpoint that consumes it. If that token is stolen from the pod filesystem, it is useless against the Kubernetes API server and can only be replayed against STS, which will additionally check the sub claim (the service account identity) against the trust policy on the IAM role.
This is covered in depth in the AWS IRSA and workload identity article. For SPIFFE/SPIRE-based identity federation that does not depend on a cloud provider, see SPIFFE/SPIRE workload identity.
Detecting Token Theft via Audit Logs
The API server audit log is the primary source for detecting stolen token use. Configure the API server with a policy that captures at least Metadata level for all ServiceAccount-authenticated requests:
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
- level: Request
users: ["system:serviceaccount:*"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
resources:
- group: ""
resources: ["secrets", "configmaps", "pods"]
- level: Metadata
users: ["system:serviceaccount:*"]
Token theft indicators to alert on:
Source IP anomalies. Service account tokens used from inside a cluster originate from pod CIDR ranges. A token used from an external IP — a cloud NAT gateway, a residential IP, a known threat intelligence address — is a strong indicator of exfiltration. Parse audit log sourceIPs and alert on any service account token use from outside the pod CIDR.
kubectl logs -n kube-system kube-apiserver-NODENAME | \
jq -r 'select(.user.username | startswith("system:serviceaccount"))
| select(.sourceIPs[] | test("^10\\.|^172\\.(1[6-9]|2[0-9]|3[01])\\.|^192\\.168\\.") | not)
| "\(.requestReceivedTimestamp) \(.user.username) \(.sourceIPs[]) \(.requestURI)"'
Cross-namespace anomalies. A service account from namespace-a making requests against resources in namespace-b without an explicit RBAC binding is suspicious. Audit log entries include objectRef.namespace. Alert when the authenticating service account’s namespace does not match the request’s target namespace unless there is a known ClusterRole binding justifying it.
Unusual verb/resource combinations. A web application’s service account reading secrets or listing pods cluster-wide indicates either privilege creep or compromise. Baseline the verbs and resources each service account legitimately touches and alert on deviations.
Repeated 403 responses from a service account. An attacker probing permissions with a stolen token will generate a stream of 403 Forbidden responses. Audit logs record these. Alert on more than five 403 responses from a single service account within a short window.
For detailed RBAC audit analysis and privilege escalation detection, see Kubernetes RBAC privilege escalation.
Bound Token Volume Protection: Pod UID Binding
When the kubelet requests a projected token via the TokenRequest API, it passes the pod’s UID in the BoundObjectRef field. The API server records this binding in the token’s claims:
{
"kubernetes.io/pod": {
"name": "api-controller-7d6f9b8c4-xk2nj",
"uid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
},
"kubernetes.io/serviceaccount": {
"name": "api-controller",
"uid": "f0e1d2c3-b4a5-9687-dcfe-ba9876543210"
}
}
When the pod is deleted, the API server’s token validation checks whether the bound pod UID still exists. If it does not, the token is rejected even if it is within its stated expiry window. This means a projected token exfiltrated from a pod that has since been deleted becomes immediately invalid — no need to wait for the TTL to elapse.
This protection does not apply to tokens created via kubectl create token without --bound-object-kind, or to legacy Secret-based tokens. Only tokens created by the kubelet via the projected volume mechanism carry pod UID binding.
Node-Level Token Theft and the Kubelet Credential Surface
On a compromised node, an attacker with root access can read projected token files from the kubelet’s pod volume directory at /var/lib/kubelet/pods/<pod-uid>/volumes/kubernetes.io~projected/<volume-name>/token. This is a known attack path: if node isolation fails, all pods on that node are affected.
Several CVEs have targeted the kubelet’s credential handling surface. CVE-2020-8554 allowed man-in-the-middle attacks on service IP traffic. CVE-2021-25735 bypassed node authorization for certain update operations. While these specific CVEs were patched, the general principle holds: nodes are a trust boundary, and a compromised node exposes all credentials it holds.
Projected tokens reduce the blast radius compared to legacy tokens in this scenario because:
- The stolen token expires within
expirationSeconds. An attacker has at most one hour (or whatever your configured maximum is) of API server access, not indefinite access. - Pod UID binding means the token stops working when the pod is rescheduled to a healthy node and the old pod’s UID is gone.
- Audience binding prevents lateral movement to external services using the same token.
For additional node-level hardening, ensure the kubelet read-only port (10255) is disabled and that node authorization mode is enabled, restricting each kubelet to only reading resources bound to its own node. These are covered in the kubelet security hardening article.
Third-Party Tool Audit: Helm and Controllers
Helm, monitoring agents, GitOps controllers, and service meshes frequently run with service accounts that have broader permissions than their actual operational requirements. Common patterns to audit:
Helm’s tiller (Helm 2, now deprecated) ran with cluster-admin. If you have any Helm 2 installations, the tiller service account is a critical finding. Remove it immediately.
Helm 3 post-install hooks often run Jobs with the deploying user’s credentials, but if they use a service account, verify that the service account exists only in the release namespace and has only the permissions the hook actually needs.
Prometheus and metrics-agent service accounts typically need read access to pods, nodes, and services cluster-wide. This is a ClusterRole, not a namespaced Role. Verify the RBAC binding is scoped to read-only verbs on the specific resource groups the agent actually queries:
kubectl auth can-i --list --as=system:serviceaccount:monitoring:prometheus \
| grep -v "no$" | grep -v "^Resources"
Cert-manager needs to create and modify Certificate, CertificateRequest, and Secret objects. If its service account can list or get all secrets cluster-wide, that is excessive. Review what ClusterRoles cert-manager installs and whether they can be tightened to the specific namespaces it manages.
GitOps controllers (Argo CD, Flux) need broad write access in the namespaces they manage. Verify that their service accounts cannot modify resources in the namespaces that manage the controllers themselves. This is a common privilege escalation vector: a developer who can push to a GitOps repo that Argo CD applies with cluster-admin can deploy a pod that inherits cluster-admin.
For each third-party service account, run:
kubectl get rolebindings,clusterrolebindings --all-namespaces -o json | \
jq -r '.items[] | select(.subjects[]?.name == "SERVICE_ACCOUNT_NAME") |
"\(.kind)/\(.metadata.name): \(.roleRef.name)"'
Then check whether the bound roles can be replaced with more restrictive ones covering only the actual verbs and resources required.
Rotation and Expiry: API Server Configuration
The API server flag --service-account-max-token-expiration caps the maximum lifetime of any projected token regardless of what expirationSeconds the pod spec requests. If a pod requests 86400 seconds (24 hours) but the flag is set to 3600, the issued token will expire after 3600 seconds.
Set this on the API server:
--service-account-max-token-expiration=3600s
For managed Kubernetes:
- EKS: This is controlled by AWS and set to a default of 86400 seconds. You cannot override it for the API server directly. IRSA tokens issued for STS have a separate session duration set on the IAM role (maximum 12 hours, minimum 15 minutes).
- GKE: Workload identity tokens have their own expiry managed by the GKE credential helper. The underlying Kubernetes projected tokens expire in one hour by default.
- Self-managed clusters: Set
--service-account-max-token-expirationin the API server manifest. A value of 3600s (1 hour) is a reasonable baseline. Some compliance frameworks require lower values.
For kubectl create token (used by CI/CD pipelines and scripts), explicitly pass --duration:
kubectl create token pipeline-runner \
--namespace cicd \
--duration 900s \
--audience "https://kubernetes.default.svc"
If --duration exceeds --service-account-max-token-expiration, the API server rejects the request with a validation error. This forces callers to request appropriately short-lived tokens.
Migrating from Legacy Tokens
Clusters upgraded from Kubernetes versions before 1.24 may still have legacy Secret-based tokens from the automatic generation that existed in older versions. Find them:
kubectl get secrets --all-namespaces -o json | \
jq -r '.items[]
| select(.type == "kubernetes.io/service-account-token")
| "\(.metadata.namespace)\t\(.metadata.name)\t\(.metadata.annotations["kubernetes.io/service-account.name"])"'
For each legacy token, determine its consumers before deleting it. Check for:
- External CI/CD systems (GitHub Actions, GitLab CI, Jenkins) that have the token stored as a secret variable.
- Monitoring platforms that use the token to scrape metrics or query the API.
- Hardcoded tokens in Helm values files, ConfigMaps, or application configuration.
Replace each consumer with a shorter-lived mechanism:
- CI/CD pipelines: Use
kubectl create tokenwith a short--durationat the start of each pipeline run, or use OIDC federation if the CI/CD provider supports it (GitHub Actions OIDC, GitLab CI OIDC). - External monitoring: Create a dedicated service account with minimal permissions and configure the monitoring tool to use its own kubeconfig that relies on token refresh.
- Application configuration: Replace the static token reference with a projected volume in the pod spec.
After identifying and migrating consumers, delete the legacy secrets:
kubectl delete secret <legacy-token-name> -n <namespace>
Confirm deletion does not break anything by monitoring API server audit logs for 403 or 401 responses from the service accounts that owned the deleted tokens in the 24 hours following deletion.
Legacy tokens created before Kubernetes 1.24 and still present in your cluster represent a known-quantity credential exposure: anyone with read access to the Secret object, the etcd data, or a backup containing etcd snapshots has a permanent credential. Eliminating them is not optional for any cluster that handles sensitive workloads.