Table of contents
- Why the Defaults Are Dangerous
- Authentication vs Authorization
- ServiceAccount — A Pod’s Identity Card
- Role vs ClusterRole
- RoleBinding — Linking Subjects to Permissions
- ClusterRoleBinding — Cluster-Wide Permissions
- Practical Pattern — Creating an SA for CI/CD
- NetworkPolicy — Controlling Inter-Pod Communication
- Pod Security Standards — Container Runtime Policies
- Image Security
- Checklist Summary
Why the Defaults Are Dangerous
When you first set up Kubernetes, everything is configured to “just work.” Pods within a namespace can freely communicate with each other, processes inside Pods run as root, and the default ServiceAccount is automatically attached. This is convenient for learning, but leaving it as-is in production is dangerous.
If one service is compromised, “lateral movement” attacks across the entire cluster become easy. If there’s a container escape vulnerability, the host node itself can be taken over. If someone gains kubectl access, the defaults let them do just about anything.
The foundation of Kubernetes security is the Principle of Least Privilege. Narrowing things down to “this subject can perform only this action on this resource.” In this part, we’ll look at the tools for that narrowing.
flowchart LR
A[Subject<br/>User / SA] -->|What permissions?| B[RBAC]
C[Pod<br/>Network traffic] -->|What's allowed?| D[NetworkPolicy]
E[Container<br/>Runtime environment] -->|How to restrict?| F[Pod Security]
Three axes. RBAC controls who can do what, NetworkPolicy controls who can communicate with whom, and Pod Security Standards control how containers are allowed to run.
Authentication vs Authorization
Let’s clarify the terminology first.
- Authentication: “Who are you?” — Verifying the identity of the subject
- Authorization: “What can you do?” — Verifying whether the subject has permission
Kubernetes has two types of subjects: Users and ServiceAccounts (SA). Humans authenticate via kubeconfig or OIDC; processes inside Pods authenticate via ServiceAccounts.
The API server follows this sequence for every incoming request:
sequenceDiagram
participant C as Client
participant A as API Server
participant Au as Authenticator
participant Az as Authorizer
participant Ad as Admission
participant E as etcd
C->>A: Request (token/certificate)
A->>Au: Who is this?
Au-->>A: user=alice, groups=[dev]
A->>Az: Can alice perform this action?
Az-->>A: Allow/Deny
A->>Ad: Validate/mutate resource spec
Ad-->>A: OK
A->>E: Store
E-->>A: OK
A-->>C: Response
While there are several authorization mechanisms, in practice it’s almost always RBAC (Role-Based Access Control). Users/ServiceAccounts are bound to Roles, and Roles are granted permissions on resources. You declaratively define “who (subject) can perform which actions (verbs) on which resources.”
ServiceAccount — A Pod’s Identity Card
If you don’t specify a serviceAccountName for a Pod, the namespace’s default SA is automatically attached. This SA’s token is mounted at /var/run/secrets/kubernetes.io/serviceaccount/token and used when communicating with the API server from inside the Pod.
apiVersion: v1
kind: ServiceAccount
metadata:
name: app-sa
namespace: backend
automountServiceAccountToken: true
If your application doesn’t need to call the Kubernetes API directly, it’s best to disable automatic token mounting.
apiVersion: v1
kind: Pod
metadata:
name: app
spec:
serviceAccountName: app-sa
automountServiceAccountToken: false # Disable if not needed
containers:
- name: app
image: myapp:1.0
Why disable it? Because if a container is compromised, the attacker gets a ready-made ticket to call the API server. If the app is just a regular web server that doesn’t use the Kubernetes API, there’s no reason to mount the token.
Role vs ClusterRole
The resources that define permissions in RBAC are Role and ClusterRole. The difference is scope:
- Role: Permissions on resources within a specific namespace
- ClusterRole: Permissions on cluster-wide resources (Nodes, PVs, CRD definitions, etc.)
A Role that grants read-only access to Pods within a namespace looks like this:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pod-reader
namespace: backend
rules:
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list", "watch"]
Understand these three keywords:
- apiGroups: The API group the resource belongs to. Core resources use
"", Deployments use"apps", Ingress uses"networking.k8s.io" - resources: The resource type.
pods,pods/log(log access),pods/exec(command execution) - verbs: The allowed actions.
get,list,watch,create,update,patch,delete
To see all available actions, check the Kubernetes documentation or run kubectl api-resources -o wide.
RoleBinding — Linking Subjects to Permissions
A Role only defines “these permissions exist.” A RoleBinding connects them to actual subjects.
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: app-sa-pod-reader
namespace: backend
subjects:
- kind: ServiceAccount
name: app-sa
namespace: backend
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io
Now app-sa can get/list/watch Pods in the backend namespace. It cannot create or delete them. Only those two verbs are permitted.
A RoleBinding can also reference a ClusterRole. This combination means “grant the permissions defined in this ClusterRole, but only within this namespace’s scope.” It’s used when granting built-in ClusterRoles like view, edit, or admin in a specific namespace.
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: alice-edit-on-backend
namespace: backend
subjects:
- kind: User
name: alice@example.com
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole # References a ClusterRole, but
name: edit # applied only within the backend namespace
apiGroup: rbac.authorization.k8s.io
ClusterRoleBinding — Cluster-Wide Permissions
ClusterRoleBinding is a binding that operates across the entire cluster. Use it with caution.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: platform-admins
subjects:
- kind: Group
name: platform-team
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
cluster-admin is a superuser with all actions on all resources. Never hand this out carelessly to individuals. You can check what the current user can do with kubectl auth can-i --list.
# Check my permissions
kubectl auth can-i --list
# Check if a specific action is allowed
kubectl auth can-i delete pods -n production
kubectl auth can-i '*' '*' --as=system:serviceaccount:backend:app-sa
Practical Pattern — Creating an SA for CI/CD
The most common combination in practice is a ServiceAccount for deployment pipelines. You narrow it down to “this SA can only update Deployments in this namespace.”
apiVersion: v1
kind: ServiceAccount
metadata:
name: deployer
namespace: backend
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: deployer-role
namespace: backend
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "patch", "update"]
- apiGroups: [""]
resources: ["configmaps", "services"]
verbs: ["get", "list", "watch", "update"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"] # For log access
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: deployer-binding
namespace: backend
subjects:
- kind: ServiceAccount
name: deployer
namespace: backend
roleRef:
kind: Role
name: deployer-role
apiGroup: rbac.authorization.k8s.io
Register this SA’s token in your CI system, and the pipeline can update only Deployments in this namespace — nothing more. It can’t read Secrets or access other namespaces. Even if the CI credentials are leaked, the blast radius is contained to this namespace.
NetworkPolicy — Controlling Inter-Pod Communication
By default, Kubernetes allows all Pods to communicate with each other. They can reach Pods in other namespaces and even outside the cluster. This is convenient but means that if a breach occurs, an attacker can easily move laterally to neighboring Pods.
NetworkPolicy narrows this down using a whitelist approach. “Pods with this label only accept traffic from Pods with that label.”
Start with a default deny policy:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: backend
spec:
podSelector: {} # Applies to all Pods in the namespace
policyTypes:
- Ingress
- Egress
With this policy applied, the namespace is completely isolated — nothing can come in from outside, and nothing can go out. From here, you open only what’s needed.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-api
namespace: backend
spec:
podSelector:
matchLabels:
app: api
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: frontend
podSelector:
matchLabels:
app: web
ports:
- protocol: TCP
port: 8080
The app=api Pods in the backend namespace only accept traffic on port 8080 from app=web Pods in the frontend namespace. All other inbound traffic is blocked.
flowchart LR
A[frontend ns<br/>app=web] -->|8080 OK| B[backend ns<br/>app=api]
C[frontend ns<br/>app=admin] -.->|Blocked| B
D[other ns] -.->|Blocked| B
Egress Control
Controlling outbound traffic is equally important. You can block attacks that exfiltrate data to external DNS servers, or enforce that only internal services are called.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-egress
namespace: backend
spec:
podSelector:
matchLabels:
app: api
policyTypes:
- Egress
egress:
# Allow DNS
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
# Allow only PostgreSQL in the database namespace
- to:
- namespaceSelector:
matchLabels:
name: database
podSelector:
matchLabels:
app: postgres
ports:
- protocol: TCP
port: 5432
The api Pod can only perform DNS lookups and access PostgreSQL. External internet is blocked, and other internal services are also blocked.
Important: NetworkPolicy requires CNI plugin support. Calico, Cilium, and Weave Net support it, but the default Flannel configuration does not. Check your cluster’s CNI first.
Pod Security Standards — Container Runtime Policies
If a container runs as root with the host filesystem mounted, an attacker who compromises that container can take over the entire node. Pod Security Standards (PSS) prevents such dangerous configurations.
The old PodSecurityPolicy (PSP) was removed in v1.25 and replaced by PSS and Pod Security Admission.
There are three standard profiles:
| Profile | Description |
|---|---|
privileged | No restrictions. For cluster infrastructure Pods |
baseline | Blocks known privilege escalation vectors. The minimum bar for general applications |
restricted | Best-practice level. Recommended for end-user workloads |
Apply them by labeling a namespace:
apiVersion: v1
kind: Namespace
metadata:
name: backend
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/audit: restricted
enforce rejects Pod creation on violation. warn only shows a warning, and audit records in the audit log. Suddenly turning on restricted as enforce may cause existing Pods to be rejected, so it’s safer to start with warn to absorb the shock and gradually ramp up.
What the restricted Profile Requires
To meet the restricted level, your Pod spec needs settings like these:
apiVersion: v1
kind: Pod
metadata:
name: secure-app
spec:
securityContext:
runAsNonRoot: true # Run as a non-root UID
runAsUser: 1000
runAsGroup: 3000
fsGroup: 2000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: myapp:1.0
securityContext:
allowPrivilegeEscalation: false # Prohibit setuid
capabilities:
drop:
- ALL # Drop all Linux capabilities
readOnlyRootFilesystem: true # Make root fs read-only
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
Key points to note:
- Non-root execution: If the container image is configured with a root user, this Pod will fail to start. You need to add a
USER 1000directive in your Dockerfile - Drop all capabilities: Strips all Linux privileges such as NET_RAW and SYS_ADMIN
- Read-only root filesystem: Prevents the application from writing to the root filesystem. Mount emptyDir volumes for directories that need writes
- seccomp: Restricts system calls.
RuntimeDefaultuses the runtime’s default profile
Image Security
Finally, you should also attend to the security of images themselves. No matter how well the code is written, if the base image has CVEs, those vulnerabilities ship with it.
Here are some recommended practices:
- Minimize base images: Use images with minimal contents like
alpineordistroless - Image scanning: Check for CVEs at the CI stage with tools like Trivy or Grype
- Use signed images: Sign with Cosign or Notation, and block unsigned images via Admission Controllers (e.g., Kyverno, Sigstore Policy Controller)
- Restrict registries: Set policies to prevent pulling images from sources other than official registries (docker.io, gcr.io, quay.io, etc.)
# Block unsigned images with Kyverno (example)
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-images
spec:
validationFailureAction: Enforce
rules:
- name: check-signature
match:
any:
- resources:
kinds: ["Pod"]
verifyImages:
- imageReferences:
- "registry.company.com/*"
attestors:
- entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
Checklist Summary
When creating a new namespace, run through this list to cover the essentials:
- Create a dedicated ServiceAccount and assign it to the Pod. Don’t use the
defaultSA - Disable automatic token mounting. Only enable it for Pods that genuinely need API access
- Grant only the minimum required permissions with Role and RoleBinding
- Apply a NetworkPolicy
default-denyand whitelist only necessary communications - Apply Pod Security Standards with
restricted enforce - Add image scanning and signature verification to the CI/CD pipeline
- Integrate Secrets with an external secret manager (see Part 7)
When first adopting these practices, it may feel like overkill. But after experiencing even one security incident, you’ll realize how dangerous the defaults really were. The upfront effort of locking things down pays off many times over in the long run.
The next part covers the most important operational concern: observability. We’ll look at how to collect logs, gather metrics, set up distributed tracing, and review the essential kubectl commands for debugging.




Loading comments...