Service Account Security#
Every pod in Kubernetes runs as a service account. By default, that is the default service account in the pod’s namespace, with an auto-mounted API token that never expires. This default configuration is overly permissive for most workloads. Hardening service accounts is one of the highest-impact security improvements you can make in a Kubernetes cluster.
The Default Problem#
When a pod starts without specifying a service account, Kubernetes does three things:
- Assigns the
defaultservice account in the pod’s namespace. - Mounts an API token at
/var/run/secrets/kubernetes.io/serviceaccount/token. - That token grants whatever RBAC permissions the
defaultservice account has (which may be more than you expect).
Most application pods never call the Kubernetes API. They do not need a token. Every unnecessary token is an attack vector – if the pod is compromised, the attacker gets free API access.
Disable Automounting#
The first and most impactful change is disabling automatic token mounting for pods that do not need API access.
Per Pod#
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-frontend
spec:
template:
spec:
automountServiceAccountToken: false
containers:
- name: app
image: frontend:1.0.0Per Service Account#
Disable automounting on the default service account in every namespace:
kubectl patch serviceaccount default -n production \
-p '{"automountServiceAccountToken": false}'To do this across all namespaces at once:
for ns in $(kubectl get namespaces -o jsonpath='{.items[*].metadata.name}'); do
kubectl patch serviceaccount default -n "$ns" \
-p '{"automountServiceAccountToken": false}' 2>/dev/null
doneAfter this, only pods that explicitly set automountServiceAccountToken: true or specify a different service account will get tokens.
Dedicated Service Accounts with Minimal RBAC#
For pods that do need API access, create a dedicated service account with the minimum required permissions.
apiVersion: v1
kind: ServiceAccount
metadata:
name: config-watcher
namespace: production
labels:
app: config-watcher
automountServiceAccountToken: true
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: configmap-reader
namespace: production
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: config-watcher-binding
namespace: production
subjects:
- kind: ServiceAccount
name: config-watcher
namespace: production
roleRef:
kind: Role
name: configmap-reader
apiGroup: rbac.authorization.k8s.ioReference the service account in the pod spec:
spec:
serviceAccountName: config-watcher
containers:
- name: app
image: config-watcher:1.0.0The principle: one service account per workload role, not one service account per namespace or per team. A deployment that reads ConfigMaps gets a different service account than one that manages Jobs.
Projected Token Volumes (Bound Service Account Tokens)#
Kubernetes 1.20+ supports projected service account tokens that are time-limited, audience-scoped, and bound to the pod. These replace the legacy non-expiring tokens that were stored as Secrets.
How Projected Tokens Work#
When automountServiceAccountToken: true is set, Kubernetes 1.20+ clusters automatically mount a projected token instead of a legacy Secret-based token. The projected token:
- Expires after 1 hour (configurable) and is automatically rotated by the kubelet.
- Is bound to the pod – the token becomes invalid when the pod is deleted.
- Has an audience claim that limits where the token can be used.
Explicit Projected Volume Configuration#
For fine-grained control, define the projected volume explicitly:
apiVersion: v1
kind: Pod
metadata:
name: api-consumer
spec:
serviceAccountName: config-watcher
automountServiceAccountToken: false # disable default mount
containers:
- name: app
image: api-consumer:1.0.0
volumeMounts:
- name: sa-token
mountPath: /var/run/secrets/tokens
readOnly: true
volumes:
- name: sa-token
projected:
sources:
- serviceAccountToken:
path: token
expirationSeconds: 3600 # 1 hour
audience: "https://my-api.example.com"The audience field restricts which services will accept this token. A token issued for audience https://my-api.example.com will be rejected by a service expecting audience https://kubernetes.default.svc. This prevents token reuse across unrelated services.
Creating Short-Lived Tokens for External Use#
For CI/CD pipelines, scripts, or external systems that need temporary cluster access:
# Create a token valid for 10 minutes
kubectl create token ci-deploy -n production --duration=10m
# Create a token with a specific audience
kubectl create token ci-deploy -n production \
--audience=https://my-cluster.example.com --duration=1h
# Use the token in a kubeconfig
kubectl config set-credentials ci-deploy \
--token="$(kubectl create token ci-deploy -n production --duration=1h)"These tokens are far safer than the legacy pattern of creating a service account Secret, which produced a token that never expired.
Legacy Token Cleanup#
Older clusters may have long-lived service account token Secrets. Find and remove them:
# Find all service account token secrets
kubectl get secrets --all-namespaces -o json | \
jq -r '.items[] | select(.type == "kubernetes.io/service-account-token") |
"\(.metadata.namespace)/\(.metadata.name) sa=\(.metadata.annotations["kubernetes.io/service-account.name"])"'For each secret found, determine whether anything still uses it. If the workload can use projected tokens (Kubernetes 1.20+), delete the secret and update the workload to use kubectl create token or projected volumes instead.
Workload Identity Federation#
Workload identity federation maps Kubernetes service accounts to cloud IAM identities. Instead of storing cloud credentials as Kubernetes Secrets, the pod uses its Kubernetes identity to authenticate directly with cloud APIs. This eliminates static credentials entirely.
GKE Workload Identity#
# Enable Workload Identity on the cluster
gcloud container clusters update my-cluster --zone us-central1-a \
--workload-pool=my-project.svc.id.goog
# Enable on the node pool
gcloud container node-pools update default-pool \
--cluster my-cluster --zone us-central1-a \
--workload-metadata=GKE_METADATA
# Create a Google service account
gcloud iam service-accounts create app-gsa --project=my-project
# Grant it permissions (e.g., Cloud Storage access)
gcloud projects add-iam-policy-binding my-project \
--member="serviceAccount:app-gsa@my-project.iam.gserviceaccount.com" \
--role="roles/storage.objectViewer"
# Bind the Kubernetes SA to the Google SA
gcloud iam service-accounts add-iam-policy-binding \
app-gsa@my-project.iam.gserviceaccount.com \
--role="roles/iam.workloadIdentityUser" \
--member="serviceAccount:my-project.svc.id.goog[production/app-ksa]"
# Annotate the Kubernetes service account
kubectl annotate serviceaccount app-ksa -n production \
iam.gke.io/gcp-service-account=app-gsa@my-project.iam.gserviceaccount.comEKS IAM Roles for Service Accounts (IRSA)#
# Create an OIDC provider for the cluster (one-time setup)
eksctl utils associate-iam-oidc-provider --cluster my-cluster --approve
# Create an IAM role with a trust policy for the Kubernetes SA
eksctl create iamserviceaccount \
--name app-ksa \
--namespace production \
--cluster my-cluster \
--attach-policy-arn arn:aws:iam::123456789012:policy/S3ReadOnly \
--approve
# eksctl automatically creates the Kubernetes SA and annotates it:
# eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/...AKS Workload Identity#
# Enable workload identity on the cluster
az aks update --resource-group myRG --name myCluster \
--enable-oidc-issuer --enable-workload-identity
# Create a managed identity
az identity create --name app-identity --resource-group myRG
# Get the OIDC issuer URL
OIDC_ISSUER=$(az aks show --resource-group myRG --name myCluster \
--query "oidcIssuerProfile.issuerUrl" -o tsv)
# Create the federated credential
az identity federated-credential create \
--name app-federated-cred \
--identity-name app-identity \
--resource-group myRG \
--issuer "$OIDC_ISSUER" \
--subject "system:serviceaccount:production:app-ksa" \
--audience "api://AzureADTokenExchange"
# Create and annotate the Kubernetes SA
CLIENT_ID=$(az identity show --name app-identity --resource-group myRG \
--query clientId -o tsv)
kubectl create serviceaccount app-ksa -n production
kubectl annotate serviceaccount app-ksa -n production \
azure.workload.identity/client-id="$CLIENT_ID"Using Workload Identity in Pods#
Once configured, pods using the annotated service account automatically receive cloud credentials. No Secret mounting required:
apiVersion: apps/v1
kind: Deployment
metadata:
name: storage-reader
namespace: production
spec:
template:
spec:
serviceAccountName: app-ksa
containers:
- name: app
image: storage-reader:1.0.0
# No env vars or secret mounts for cloud credentials.
# The SDK automatically picks up the workload identity token.Auditing Service Account Usage#
Regularly audit which service accounts exist, what permissions they have, and which pods use them:
# List all service accounts and their automount settings
kubectl get serviceaccounts --all-namespaces -o json | \
jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name) automount=\(.automountServiceAccountToken // "not set (defaults to true)")"'
# Find pods running as the default service account
kubectl get pods --all-namespaces -o json | \
jq -r '.items[] | select(.spec.serviceAccountName == "default" or .spec.serviceAccountName == null) |
"\(.metadata.namespace)/\(.metadata.name)"'
# Check effective permissions for a service account
kubectl auth can-i --list -n production \
--as=system:serviceaccount:production:config-watcherService Account Security Checklist#
- Disable
automountServiceAccountTokenon thedefaultservice account in every namespace. - Set
automountServiceAccountToken: falseon every pod that does not call the Kubernetes API. - Create dedicated service accounts per workload role with minimal RBAC.
- Use projected tokens with explicit expiration and audience instead of legacy token Secrets.
- Delete legacy
kubernetes.io/service-account-tokenSecrets from the cluster. - Use workload identity federation instead of storing cloud credentials as Secrets.
- Use
kubectl create tokenwith--durationfor temporary access instead of long-lived credentials. - Audit service account usage quarterly – find overprivileged accounts and pods using
default.