K8s Security Pro
#05 RBAC free

Secure Service Account

A comprehensive service account security configuration with disabled auto-mount, projected token volumes, imagePullSecrets, and workload identity annotations.

CIS Benchmark
5.1.55.1.6
MITRE ATT&CK
T1528T1552.007

Overview

This template implements defense-in-depth for Kubernetes service accounts across four layers: disabled automatic token mounting (most pods do not need API access), projected/bound tokens for pods that do need API access (time-limited and audience-bound), imagePullSecrets for private registries, and workload identity annotations for cloud API access without static credentials.

Security threat addressed: By default, every pod receives a long-lived service account token mounted at /var/run/secrets/kubernetes.io/serviceaccount. If an attacker compromises a pod, they can use this token to interact with the Kubernetes API, list secrets, or escalate privileges.

When to use: Apply this to every application in production. Disable token mounting by default, and use projected tokens only for the pods that genuinely need Kubernetes API access.

Threat Model

  • Credential theft prevention: Disabling auto-mount removes the most common credential theft vector — stolen SA tokens from compromised containers.
  • Time-limited access: Projected tokens expire (default: 1 hour) and are automatically rotated by the kubelet, limiting the window of exploitation.
  • Audience binding: Tokens are restricted to a specific API audience, preventing them from being used against other services.
  • Zero static credentials: Workload identity (IRSA/GKE Workload Identity) provides cloud API access without storing AWS/GCP access keys in the cluster.

MITRE ATT&CK:

  • T1528 — Steal Application Access Token: Mounted SA tokens at /var/run/secrets/ enable API access if a container is compromised.
  • T1552.007 — Unsecured Credentials: Container API: Default mounted tokens are a prime target for credential theft.

Real-world scenario: An attacker exploits an RCE vulnerability and finds a service account token at the default mount path. With token auto-mount disabled, there is no token to steal. If the pod uses projected tokens, the attacker has at most 1 hour before the token expires.

YAML Source

# STEP 1: ServiceAccount -- Disable automatic token mounting
apiVersion: v1
kind: ServiceAccount
metadata:
  name: app-workload-sa
  namespace: production
  labels:
    app.kubernetes.io/name: k8s-security
    app.kubernetes.io/part-of: k8s-security-pro
    app.kubernetes.io/managed-by: k8s-security-pro
  annotations:
    # AWS EKS: IAM Roles for Service Accounts (IRSA)
    eks.amazonaws.com/role-arn: "arn:aws:iam::123456789012:role/app-workload-role"
    # GCP GKE: Workload Identity
    iam.gke.io/gcp-service-account: "app-workload@my-project.iam.gserviceaccount.com"
automountServiceAccountToken: false
imagePullSecrets:
  - name: registry-credentials
---
# STEP 2: Pod with projected (bound) service account token
apiVersion: v1
kind: Pod
metadata:
  name: app-with-api-access
  namespace: production
  labels:
    app.kubernetes.io/name: k8s-security
    app.kubernetes.io/part-of: k8s-security-pro
    app.kubernetes.io/managed-by: k8s-security-pro
    app: api-consumer
spec:
  serviceAccountName: app-workload-sa
  automountServiceAccountToken: false
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    runAsGroup: 1000
    fsGroup: 1000
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: app
      image: registry.example.com/app:v1.2.3
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
      resources:
        requests:
          memory: "64Mi"
          cpu: "100m"
        limits:
          memory: "256Mi"
          cpu: "500m"
      env:
        - name: KUBERNETES_TOKEN_PATH
          value: /var/run/secrets/tokens/api-token
      volumeMounts:
        - name: api-token
          mountPath: /var/run/secrets/tokens
          readOnly: true
        - name: tmp
          mountPath: /tmp
  volumes:
    - name: api-token
      projected:
        defaultMode: 0440
        sources:
          - serviceAccountToken:
              expirationSeconds: 3600
              audience: "https://kubernetes.default.svc"
              path: api-token
          - configMap:
              name: kube-root-ca.crt
              items:
                - key: ca.crt
                  path: ca.crt
    - name: tmp
      emptyDir:
        sizeLimit: "64Mi"
---
# STEP 3: Minimal RBAC for the ServiceAccount
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: app-workload-role
  namespace: production
  labels:
    app.kubernetes.io/name: k8s-security
    app.kubernetes.io/part-of: k8s-security-pro
    app.kubernetes.io/managed-by: k8s-security-pro
rules:
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: app-workload-binding
  namespace: production
  labels:
    app.kubernetes.io/name: k8s-security
    app.kubernetes.io/part-of: k8s-security-pro
    app.kubernetes.io/managed-by: k8s-security-pro
subjects:
  - kind: ServiceAccount
    name: app-workload-sa
    namespace: production
roleRef:
  kind: Role
  name: app-workload-role
  apiGroup: rbac.authorization.k8s.io

Installation

kubectl:

kubectl apply -f 05_secure_service_account.yaml

Helm:

helm install k8s-security ./charts/k8s-security -f values-prod.yaml

Kustomize:

kubectl apply -k kustomize/overlays/prod

Verification

# Verify ServiceAccount has automount disabled
kubectl get sa app-workload-sa -n production -o jsonpath='{.automountServiceAccountToken}'
# Expected: false

# Verify pod does not have default token mounted
kubectl exec -n production app-with-api-access -- ls /var/run/secrets/kubernetes.io/serviceaccount 2>&1
# Expected: No such file or directory

# Verify projected token is mounted
kubectl exec -n production app-with-api-access -- ls /var/run/secrets/tokens/
# Expected: api-token  ca.crt

# Check pods using default service account (should be zero in production)
kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{"/"}{.metadata.name}{"\t"}{.spec.serviceAccountName}{"\n"}{end}' | grep "default"

CIS Benchmark References

  • 5.1.5 — Ensure that default service accounts are not actively used. This template creates dedicated service accounts per application.
  • 5.1.6 — Ensure that Service Account Tokens are only mounted where necessary. This template disables auto-mount and uses projected tokens only when needed.

MITRE ATT&CK References

  • T1528 — Steal Application Access Token: Mounted tokens at default paths are the primary target for credential theft. Disabling auto-mount removes this vector entirely.
  • T1552.007 — Unsecured Credentials: Container API: Long-lived SA tokens are accessible at a well-known path inside every container. Projected tokens with 1-hour expiry limit the exploitation window.

Further Reading