A hardened pod in a wide-open cluster is still exposed. Apply least-privilege RBAC, scope ServiceAccounts, enforce Pod Security Standards, and cut lateral movement with default-deny NetworkPolicy.
Kubernetes RBAC controls who (users and, importantly, pods via ServiceAccounts) can do what. The default of broad or unused permissions is how a single compromised pod becomes cluster-wide compromise. Grant the narrowest Role that works, bind it to a specific subject, and never hand out cluster-admin as a convenience.
# a Role that can only READ pods in one namespace — nothing else
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { namespace: app, name: pod-reader }
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"] # no create/delete/exec, no secrets
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { namespace: app, name: app-can-read-pods }
subjects: [{ kind: ServiceAccount, name: app-sa, namespace: app }]
roleRef: { kind: Role, name: pod-reader, apiGroup: rbac.authorization.k8s.io }
# audit for danger: kubectl get clusterrolebindings -o wide | grep cluster-adminEvery pod gets a ServiceAccount; by default its API token is mounted into the container, so a compromised app can call the Kubernetes API with that identity. Turn off automounting unless the pod actually talks to the API, and give pods that do a dedicated, minimally-scoped ServiceAccount rather than the shared default one.
# don't mount an API token into pods that don't need the API
apiVersion: v1
kind: ServiceAccount
metadata: { name: app-sa, namespace: app }
automountServiceAccountToken: false
---
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
serviceAccountName: app-sa # dedicated, not 'default'
automountServiceAccountToken: false # override per-pod if the SA mounts it
# now a compromised web app has no cluster API credential to abuse.Rather than hope every team sets a good securityContext, enforce it. The built-in Pod Security Admission applies the Pod Security Standards — baseline (blocks known-bad like privileged, hostNetwork) or restricted (requires non-root, drop-all-caps, seccomp) — at the namespace level. Label a namespace restricted and non-conforming pods are rejected on creation.
# enforce the 'restricted' profile for a whole namespace
apiVersion: v1
kind: Namespace
metadata:
name: app
labels:
pod-security.kubernetes.io/enforce: restricted # reject violating pods
pod-security.kubernetes.io/warn: restricted # warn on kubectl
pod-security.kubernetes.io/audit: restricted # log to audit
# 'restricted' requires: runAsNonRoot, drop ALL caps, seccomp RuntimeDefault,
# no privilege escalation, no host namespaces. Exactly lesson 4, enforced
# for everyone by default.By default every pod can talk to every other pod, so one compromised service can reach your database, your internal APIs, everything. A default-deny NetworkPolicy flips this: nothing talks to anything until you explicitly allow it. Then permit only the flows your architecture needs, containing a breach to a single service.
# 1) deny ALL ingress in the namespace by default
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny-ingress, namespace: app }
spec:
podSelector: {}
policyTypes: ["Ingress"]
---
# 2) then allow ONLY the api -> db flow that's actually needed
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-api-to-db, namespace: app }
spec:
podSelector: { matchLabels: { app: db } }
policyTypes: ["Ingress"]
ingress:
- from: [{ podSelector: { matchLabels: { app: api } } }]
ports: [{ port: 5432 }]
# a compromised frontend now CANNOT reach the db directly. Blast radius = 1.