Kubernetes Lesson 39 – Services Accounts | Dataplexa
Networking, Ingress & Security · Lesson 39

Service Accounts

Every Pod that ever talks to the Kubernetes API — your CI/CD agents, your operators, your custom controllers — authenticates using a ServiceAccount. Understanding how ServiceAccounts work, how tokens are projected into Pods, and how to harden them is essential for any cluster running real workloads.

What ServiceAccounts Actually Are

A ServiceAccount is a namespaced Kubernetes object that represents an identity for processes running in Pods. When a Pod makes a request to the Kubernetes API server, it authenticates as its ServiceAccount — and RBAC uses that identity to decide what the Pod is allowed to do.

ServiceAccounts are very different from user accounts. There's no password, no home directory, no login shell. A ServiceAccount is just a name, a namespace, and a token. The token is what the Pod presents to prove its identity. Kubernetes manages the token lifecycle automatically — generation, rotation, and expiry.

The default ServiceAccount trap

Every namespace has a default ServiceAccount created automatically. Every Pod that doesn't specify a ServiceAccount uses it. By default the default ServiceAccount has no permissions — but it still gets a token mounted into every Pod. This token can be used to call the Kubernetes API. If RBAC is misconfigured and the default ServiceAccount gets permissions, every Pod in the namespace inherits them. Always create dedicated ServiceAccounts for workloads that need API access.

How Tokens Get Into Pods

When a Pod starts, the kubelet automatically mounts a service account token into the container at /var/run/secrets/kubernetes.io/serviceaccount/token. Every Kubernetes client library (the Python kubernetes package, Go client-go, Java Fabric8) automatically discovers and uses this token when running inside a Pod. You don't need to configure anything.

Two token mechanisms exist. The old one — legacy static tokens stored as Secrets — was retired because tokens never expired and were trivially readable from the Secret. The modern mechanism — projected token volumes (introduced in Kubernetes 1.12, required from 1.21) — generates short-lived tokens that auto-rotate and are audience-bound. Understanding both helps you interpret existing cluster configurations.

kubectl exec -it order-api-7f9b4d-2xkpj -n production -- \
  cat /var/run/secrets/kubernetes.io/serviceaccount/token
# The token file — a JWT signed by the Kubernetes API server
# It's automatically mounted by the kubelet for the Pod's ServiceAccount

kubectl exec -it order-api-7f9b4d-2xkpj -n production -- \
  cat /var/run/secrets/kubernetes.io/serviceaccount/namespace
# The namespace file — contains the Pod's namespace as a string
# Useful for applications that need to know which namespace they're running in

kubectl exec -it order-api-7f9b4d-2xkpj -n production -- \
  ls -la /var/run/secrets/kubernetes.io/serviceaccount/
# The full token directory: token, namespace, ca.crt
# ca.crt: the cluster's CA certificate — used to verify the API server's TLS certificate
$ kubectl exec -it order-api-7f9b4d-2xkpj -n production -- \
  ls -la /var/run/secrets/kubernetes.io/serviceaccount/
total 0
lrwxrwxrwx 1 root root 13 Mar 10 09:22 ca.crt -> ..data/ca.crt
lrwxrwxrwx 1 root root 16 Mar 10 09:22 namespace -> ..data/namespace
lrwxrwxrwx 1 root root 12 Mar 10 09:22 token -> ..data/token

$ kubectl exec -it order-api-7f9b4d-2xkpj -n production -- \
  cat /var/run/secrets/kubernetes.io/serviceaccount/namespace
production

$ kubectl get pod order-api-7f9b4d-2xkpj -n production \
  -o jsonpath='{.spec.serviceAccountName}'
default   ← using the default service account (no custom SA specified)

What just happened?

The symlink structure — The token files are symlinks pointing to ..data/, which itself is an atomic symlink. This is how Kubernetes rotates tokens without creating a window where an application reads a half-written file — the entire directory swap is atomic at the filesystem level.

ca.crt matters — The ca.crt file is the cluster's CA certificate. Without it, the Kubernetes client library can't verify the API server's TLS certificate and will either fail or (dangerously) skip verification. All well-written Kubernetes clients use this file automatically. Never configure a Kubernetes client with insecure-skip-tls-verify: true in production.

serviceAccountName: default — This Pod is using the default ServiceAccount. If you haven't granted the default ServiceAccount any permissions (which you shouldn't), calls to the Kubernetes API from this Pod will get 403 Forbidden. That's the correct secure baseline.

Creating and Using a Dedicated ServiceAccount

The scenario: You're deploying an order management service that needs to watch ConfigMaps in its own namespace to react to runtime configuration changes. It should not have access to anything else. You create a dedicated ServiceAccount, bind it to a minimal Role, and configure the Pod to use it.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: order-api-sa                 # Dedicated SA for the order API
  namespace: production
  labels:
    app: order-api
  annotations:
    # Optional: describe the purpose — helpful during RBAC audits
    description: "ServiceAccount for order-api to watch ConfigMaps"

---

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: order-api-role
  namespace: production
rules:
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list", "watch"]  # Read-only on ConfigMaps only
    # Nothing else — this SA cannot touch Pods, Secrets, Deployments, anything

---

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: order-api-binding
  namespace: production
subjects:
  - kind: ServiceAccount
    name: order-api-sa
    namespace: production
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: order-api-role

---

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-api
  namespace: production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-api
  template:
    metadata:
      labels:
        app: order-api
    spec:
      serviceAccountName: order-api-sa   # Use the dedicated SA instead of default
      automountServiceAccountToken: true  # true = mount the token (default)
                                          # false = don't mount — for Pods that never call the API
      containers:
        - name: order-api
          image: company/order-api:4.2.1
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "300m"
              memory: "256Mi"
$ kubectl apply -f order-api-sa-and-deployment.yaml
serviceaccount/order-api-sa created
role.rbac.authorization.k8s.io/order-api-role created
rolebinding.rbac.authorization.k8s.io/order-api-binding created
deployment.apps/order-api created

$ kubectl get pod order-api-7f9b4d-2xkpj -n production \
  -o jsonpath='{.spec.serviceAccountName}'
order-api-sa   ← now using dedicated SA ✓

$ kubectl auth can-i list configmaps -n production \
  --as=system:serviceaccount:production:order-api-sa
yes

$ kubectl auth can-i list pods -n production \
  --as=system:serviceaccount:production:order-api-sa
no   ← cannot list pods — minimal permissions confirmed ✓

$ kubectl auth can-i get secrets -n production \
  --as=system:serviceaccount:production:order-api-sa
no   ← cannot read secrets ✓

What just happened?

serviceAccountName in the Pod spec — Setting serviceAccountName: order-api-sa under spec (at the Pod template level, not the container level) tells Kubernetes to mount the token for order-api-sa instead of default. The token at /var/run/secrets/kubernetes.io/serviceaccount/token now represents order-api-sa's identity.

automountServiceAccountToken: false — Set this to false on any Pod that never calls the Kubernetes API (most application Pods). This removes the token mount entirely — an attacker who gets code execution in that Pod has no API credentials to escalate with. You can set this at the ServiceAccount level too — all Pods using that SA will not auto-mount unless they override it on the Pod spec.

The trio: SA + Role + RoleBinding in one file — Keeping the ServiceAccount, Role, and RoleBinding in the same manifest file (separated by ---) means they deploy together, they're reviewed together, and nobody accidentally creates the Role without the RoleBinding or forgets to delete the SA when the workload is decommissioned.

Projected Token Volumes — Modern Token Management

While Kubernetes automatically handles service account token projection, sometimes you need explicit control — a token for a specific audience, with a specific expiry, for a specific use case. Projected token volumes let you mount multiple tokens (with different audiences and TTLs) alongside other secrets and ConfigMaps in a single directory.

The scenario: Your payment processor calls two external services that use OIDC for authentication: the company's internal fraud detection API and an external payment gateway. Each expects a JWT with a specific audience claim. You need two separate tokens with different audience values mounted into the Pod.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-processor
  namespace: payments
spec:
  replicas: 2
  selector:
    matchLabels:
      app: payment-processor
  template:
    metadata:
      labels:
        app: payment-processor
    spec:
      serviceAccountName: payment-processor-sa
      automountServiceAccountToken: false    # Disable auto-mount — we use explicit projected volumes

      volumes:
        - name: token-fraud-detection
          projected:
            sources:
              - serviceAccountToken:
                  path: token                # File name inside the mounted directory
                  expirationSeconds: 3600    # Token valid for 1 hour
                  audience: fraud-detection-api   # audience claim in the JWT
                                             # The fraud API validates this claim to prevent token misuse
        - name: token-payment-gateway
          projected:
            sources:
              - serviceAccountToken:
                  path: token
                  expirationSeconds: 1800    # 30 minutes — shorter for the sensitive payment gateway
                  audience: payment-gateway-prod
              - configMap:                   # Also include the CA cert for TLS in the same mount
                  name: payment-gateway-ca
                  items:
                    - key: ca.crt
                      path: ca.crt

      containers:
        - name: payment-processor
          image: company/payment-processor:3.1.0
          volumeMounts:
            - name: token-fraud-detection
              mountPath: /var/credentials/fraud    # App reads /var/credentials/fraud/token
              readOnly: true
            - name: token-payment-gateway
              mountPath: /var/credentials/gateway  # App reads /var/credentials/gateway/token
              readOnly: true                        # and /var/credentials/gateway/ca.crt
$ kubectl apply -f payment-processor-deployment.yaml
deployment.apps/payment-processor created

$ kubectl exec -it payment-processor-8c4f7d-j9pkx -n payments -- \
  ls /var/credentials/fraud/
token

$ kubectl exec -it payment-processor-8c4f7d-j9pkx -n payments -- \
  cat /var/credentials/fraud/token | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
{
    "iss": "https://kubernetes.default.svc.cluster.local",
    "sub": "system:serviceaccount:payments:payment-processor-sa",
    "aud": ["fraud-detection-api"],
    "exp": 1741604822,
    "iat": 1741601222,
    "kubernetes.io/pod/name": "payment-processor-8c4f7d-j9pkx",
    "kubernetes.io/serviceaccount/name": "payment-processor-sa"
}

What just happened?

Audience binding prevents token reuse — The JWT has "aud": ["fraud-detection-api"]. If an attacker extracts this token from the container, they cannot use it to authenticate to the Kubernetes API server (wrong audience) or to the payment gateway (wrong audience). Each token is cryptographically bound to its intended recipient. This is a significant security improvement over the old static tokens.

Token metadata in the JWT — The token contains the Pod name, ServiceAccount name, and namespace. Services that receive this token can extract this metadata to understand exactly which workload is calling them — without any additional context passing. This is the basis for workload identity in modern Kubernetes security.

Auto-rotation by the kubelet — The kubelet continuously monitors projected token volumes and proactively refreshes tokens before they expire (typically at 80% of the expiry time). The application just re-reads the file on its next request — or uses a file watcher. The token at /var/credentials/fraud/token is always fresh without any application-side refresh logic.

Disabling Auto-Mount on the ServiceAccount

For most application Pods — your order API, your frontend, your data processors — they have no business calling the Kubernetes API. Mounting a service account token into them is unnecessary and creates an attack surface. You can disable it globally at the ServiceAccount level.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: order-api-sa
  namespace: production
automountServiceAccountToken: false     # Global: no Pods using this SA will get a token
                                        # Can be overridden per-Pod with
                                        # spec.automountServiceAccountToken: true
                                        # if specific Pods with this SA do need API access
kubectl get serviceaccounts -n production
# List all ServiceAccounts in the namespace
# The default SA is always present

kubectl describe serviceaccount order-api-sa -n production
# Shows: Name, Namespace, Mountable secrets (legacy), Token (none for modern clusters)
# Tokens: shows if the auto-mount is enabled

kubectl get pods -n production -o custom-columns=\
'NAME:.metadata.name,SA:.spec.serviceAccountName,AUTOMOUNT:.spec.automountServiceAccountToken'
# Custom column output: see which SA and auto-mount setting each Pod uses
# Good for auditing — any Pod with AUTOMOUNT=true that doesn't need API access is a risk
$ kubectl get pods -n production -o custom-columns=\
'NAME:.metadata.name,SA:.spec.serviceAccountName,AUTOMOUNT:.spec.automountServiceAccountToken'
NAME                             SA              AUTOMOUNT
order-api-7f9b4d-2xkpj           order-api-sa    false
order-api-7f9b4d-7rvqn           order-api-sa    false
payment-processor-8c4f7d-j9pkx   payment-processor-sa   false
checkout-api-9c4b2f-m8nzx        default         <none>   ← using default SA, auto-mount not explicitly set

$ kubectl get serviceaccount default -n production -o jsonpath='{.automountServiceAccountToken}'
           ← empty = not set, defaults to true — checkout-api gets a token it doesn't need

What just happened?

The checkout-api exposure — The checkout-api is using the default ServiceAccount with no explicit automountServiceAccountToken setting. Since the default SA doesn't have automountServiceAccountToken: false set, the Pod gets a token mounted. That token has no RBAC permissions — but it still exists, it still identifies as the default SA, and an attacker with code execution can try to use it against the API. The fix is simple: set automountServiceAccountToken: false on the default ServiceAccount in every application namespace.

Custom column output — The -o custom-columns flag is a powerful kubectl feature that lets you select specific fields from object JSON paths. This SA audit query gives you a clean table showing every Pod's SA and auto-mount status — runnable as a regular security check.

ServiceAccount Hardening Checklist

Apply this checklist to every namespace in production:

1
Disable auto-mount on the default ServiceAccount
kubectl patch serviceaccount default -n [ns] -p '{"automountServiceAccountToken": false}' — run this for every application namespace.
2
Create a dedicated SA for every workload that calls the API
One SA per application, scoped to exactly the resources it needs. Never share SAs between unrelated workloads.
3
Set automountServiceAccountToken: false on Pods that don't need API access
Even if the SA has no permissions, removing the token from the Pod eliminates the attack surface entirely.
4
Use audience-bound projected tokens for external service authentication
When a Pod needs to authenticate to an external OIDC-aware service, use projected token volumes with explicit audience values rather than long-lived static credentials.
5
Audit SA usage quarterly
kubectl get rolebindings,clusterrolebindings -A -o wide | grep serviceaccount — review all SA bindings and remove unused ones.

Teacher's Note: Workload identity — where ServiceAccounts are going

Projected service account tokens are the foundation of workload identity — the idea that a Pod's identity (its SA token) is sufficient to authenticate to external cloud services without any credentials stored in Secrets. AWS IRSA (IAM Roles for Service Accounts), GCP Workload Identity, and Azure Workload Identity all work on this principle: the cluster's OIDC provider signs tokens for specific ServiceAccounts, and cloud IAM validates those tokens to grant AWS/GCP/Azure resource access.

In practice this means: a Pod with the right ServiceAccount and IAM annotation can call AWS S3, Secrets Manager, or SQS without ever having an AWS access key in the cluster. No rotation, no secret leakage risk, no long-lived credentials. The SA token is the credential. This is the modern approach to cloud resource access from Kubernetes — and it's significantly more secure than mounting AWS credentials as Secrets.

If you're running on EKS, GKE, or AKS and still storing cloud credentials as Kubernetes Secrets, migrating to workload identity is the single highest-impact security improvement available to you. It eliminates an entire category of credential theft.

Practice Questions

1. Most of your application Pods never call the Kubernetes API. What field do you set on either the ServiceAccount or the Pod spec to prevent Kubernetes from automatically mounting a service account token into those Pods?



2. At what path is the service account token automatically mounted inside a running container?



3. What field in a Pod spec (under spec, not under containers) specifies which ServiceAccount the Pod should run as?



Quiz

1. What security advantage does a projected service account token with an explicit audience field have over a legacy static token with no audience?


2. A developer accidentally grants the edit ClusterRole to the default ServiceAccount in the production namespace. What is the security impact?


3. A projected service account token has expirationSeconds: 3600. What happens when the token nears expiry — does the application need to request a new token?


Up Next · Lesson 40

Security Contexts

Controlling what a container can do at the Linux level — user IDs, capabilities, read-only filesystems, privilege escalation prevention, and seccomp profiles.