ServiceAccounts & RBAC
Pod identity, API server authentication, and role-based access control in Helm charts.
Introduction
In Kubernetes, every pod that communicates with the API server needs an identity. That identity comes from a ServiceAccount. Without explicit configuration, pods use the default ServiceAccount in their namespace, which typically has no meaningful permissions when RBAC is enabled. For any application that needs to interact with Kubernetes resources — reading ConfigMaps, watching for changes, managing custom resources — you must create a dedicated ServiceAccount and bind it to appropriate RBAC rules.
Helm charts that follow best practices always include ServiceAccount and RBAC templates as first-class resources. This lesson covers how ServiceAccounts work, how the RBAC authorization model is structured, and how to template both in Helm with proper conditional logic and least-privilege defaults.
ServiceAccount Purpose and Mechanics
A ServiceAccount is a Kubernetes resource that provides an identity for processes running inside pods. When RBAC is enabled (the default on all modern clusters), the API server uses the ServiceAccount's token to determine what the pod is allowed to do.
How Authentication Works
When a pod starts, Kubernetes can automatically mount a projected service account token at /var/run/secrets/kubernetes.io/serviceaccount/token. This is a short-lived JWT (JSON Web Token) that the kubelet refreshes automatically. Client libraries like the official Kubernetes Go client or the Python kubernetes package detect and use this token transparently.
The token contains the ServiceAccount name, namespace, and a unique identifier. The API server validates the token, identifies the ServiceAccount, and then checks RBAC policies to determine whether the request should be allowed.
The automountServiceAccountToken Field
By default, Kubernetes mounts the service account token into every pod. This is convenient but violates the principle of least privilege: most application pods never need to call the Kubernetes API. If a pod is compromised, the mounted token gives the attacker a starting point for lateral movement.
automountServiceAccountToken: false on the ServiceAccount or the pod spec by default. Only enable it for pods that genuinely need API access. This single setting significantly reduces your attack surface.
You can control token mounting at two levels:
- ServiceAccount level: Setting
automountServiceAccountToken: falseon the ServiceAccount disables mounting for all pods using it, unless overridden at the pod level. - Pod spec level: Setting
automountServiceAccountToken: truein the pod spec overrides the ServiceAccount setting for that specific pod.
Creating ServiceAccount Templates in Helm
A standard Helm ServiceAccount template includes conditional creation, configurable annotations (useful for cloud provider integrations like AWS IAM Roles for Service Accounts), and image pull secrets.
# templates/serviceaccount.yaml
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "myapp.serviceAccountName" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
{{- if .Values.serviceAccount.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.serviceAccount.imagePullSecrets | nindent 2 }}
{{- end }}
{{- end }}
The corresponding helper template resolves the ServiceAccount name:
# templates/_helpers.tpl
{{- define "myapp.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "myapp.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
When serviceAccount.create is true and no explicit name is given, the chart generates a name from the release. When create is false, the chart references an existing ServiceAccount (falling back to default).
Image Pull Secrets on ServiceAccounts
If your container images are stored in a private registry, you need authentication credentials. Rather than adding imagePullSecrets to every pod spec, you can attach them to the ServiceAccount. Every pod using that ServiceAccount inherits the pull secrets automatically.
# values.yaml
serviceAccount:
create: true
imagePullSecrets:
- name: ghcr-credentials
- name: ecr-credentials
This approach centralizes credential management. When you rotate registry credentials, you update the Secret and the ServiceAccount — no pod redeployment needed.
The RBAC Model
RBAC in Kubernetes revolves around four resource types. Understanding the distinction between namespace-scoped and cluster-scoped resources is essential for writing secure Helm charts.
Role vs. ClusterRole
| Aspect | Role | ClusterRole |
|---|---|---|
| Scope | Single namespace | Cluster-wide |
| Can access namespace resources | Yes (own namespace only) | Yes (all namespaces when bound with ClusterRoleBinding) |
| Can access cluster resources | No | Yes (nodes, PVs, namespaces, etc.) |
| Use case | Application-specific permissions | Operators, controllers, admin roles |
RoleBinding vs. ClusterRoleBinding
A RoleBinding grants the referenced Role or ClusterRole within a specific namespace. A ClusterRoleBinding grants a ClusterRole across the entire cluster. An important pattern: you can use a RoleBinding to reference a ClusterRole, which limits the ClusterRole's permissions to the RoleBinding's namespace. This is useful for reusing standard permission sets without granting cluster-wide access.
RBAC Rules Structure
Each RBAC rule specifies three components:
- apiGroups: The API group containing the resources (
""for core,"apps"for Deployments,"batch"for Jobs, etc.) - resources: The resource types to control access to (
"pods","configmaps","secrets", etc.) - verbs: The actions allowed (
"get","list","watch","create","update","patch","delete")
An optional resourceNames field further restricts access to specific named objects:
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["db-credentials", "api-key"]
verbs: ["get"]
Common RBAC Patterns
Read-Only Access to Config
The most common pattern for applications that read configuration from ConfigMaps or Secrets at runtime:
rules:
- apiGroups: [""]
resources: ["configmaps", "secrets"]
verbs: ["get", "list", "watch"]
Namespace Admin
For operators or admin tools that manage all resources within a namespace:
rules:
- apiGroups: ["", "apps", "batch", "networking.k8s.io"]
resources: ["*"]
verbs: ["*"]
"*" for resources and verbs is convenient during development but dangerous in production. Always enumerate the specific resources and verbs your application needs.
Specific Resource Access
When an app needs access to a single, known Secret (e.g., database credentials):
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["myapp-db-creds"]
verbs: ["get"]
Templating RBAC Conditionally
Not every deployment needs RBAC resources. Some clusters have pre-existing roles, and some pods never call the API. A well-designed Helm chart makes RBAC creation optional via values.yaml:
# values.yaml
rbac:
create: true
rules:
- apiGroups: [""]
resources: ["configmaps", "secrets"]
verbs: ["get", "list", "watch"]
# templates/role.yaml
{{- if .Values.rbac.create -}}
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
rules:
{{- toYaml .Values.rbac.rules | nindent 2 }}
{{- end }}
This pattern lets users pass custom rules through values overrides while the chart handles the structural boilerplate. The toYaml function converts the values list directly into valid YAML rules.
Binding ServiceAccounts to Roles
The RoleBinding connects a ServiceAccount to a Role. The subjects field must include the namespace, even for namespace-scoped RoleBindings, to avoid ambiguity:
# templates/rolebinding.yaml
{{- if .Values.rbac.create -}}
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: {{ include "myapp.fullname" . }}
subjects:
- kind: ServiceAccount
name: {{ include "myapp.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
{{- end }}
.Release.Namespace for the subject namespace rather than hardcoding a value. This ensures the binding works correctly regardless of which namespace the chart is installed into.
Least Privilege in Practice
The principle of least privilege states that every component should have only the permissions it needs to perform its function, and nothing more. In Kubernetes RBAC, this translates to concrete practices:
- Default to no API access. Set
automountServiceAccountToken: falseand only enable it for pods that call the API. - Prefer Role over ClusterRole. Namespace-scoped permissions limit blast radius.
- Enumerate verbs explicitly. Never use
["*"]in production. - Use resourceNames when possible. Restricting access to specific named objects is more secure than granting access to all objects of a type.
- Audit regularly. Use
kubectl auth can-i --list --as=system:serviceaccount:NAMESPACE:NAMEto verify what a ServiceAccount can actually do.
Example: App That Reads ConfigMaps and Secrets
Consider an application that loads its configuration from a ConfigMap and reads database credentials from a Secret at startup. The complete set of resources needed in Helm:
- A ServiceAccount with
automountServiceAccountToken: true(since this app needs API access). - A Role granting
get,list, andwatchonconfigmapsandsecrets. - A RoleBinding connecting the ServiceAccount to the Role.
- The Deployment referencing the ServiceAccount by name.
The Deployment spec references the ServiceAccount:
spec:
template:
spec:
serviceAccountName: {{ include "myapp.serviceAccountName" . }}
containers:
- name: myapp
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
With this setup, the application can read ConfigMaps and Secrets in its own namespace but cannot modify them, cannot access resources in other namespaces, and cannot interact with any other resource types. If the pod is compromised, the attacker gains read access to config data in one namespace — a limited blast radius.