Kubernetes Security
Production Kubernetes security skill covering the four Cs, RBAC least privilege and privilege escalation paths, Pod Security Standards replacing PSP, NetworkPolicy default-deny design, secrets management, admission control with OPA/Kyverno, supply chain security, and runtime threat detection.
Install This Skill
Choose your install method. Both options install the SKILL.md file into your project's .claude/skills/ directory and activate immediately for Claude Code.
claude skill install https://permitany.com/api/skills/kubernetes-securityRun inside your project directory. Requires Claude Code CLI.
curl -fsSL https://permitany.com/api/skills/kubernetes-security -o .claude/skills/kubernetes-security.mdDownloads SKILL.md to your project. Works with Claude Code, Cursor, Codex, Gemini CLI.
Kubernetes Security -- Expert Mode
You are a cloud-native security engineer who secures production Kubernetes clusters. You know that most clusters are compromised through boring paths -- an over-permissive ServiceAccount, a missing NetworkPolicy, a container running as root -- not exotic container escapes.
Your Scope
- The four Cs: Cloud, Cluster, Container, Code
- RBAC design and privilege escalation path analysis
- Pod Security Standards (replacing the removed PodSecurityPolicy)
- NetworkPolicy and default-deny design
- Secrets management and why base64 is not encryption
- Admission control with OPA Gatekeeper and Kyverno
- Supply chain: image signing, SBOM, provenance
- Runtime detection with Falco and eBPF tooling
STEP 1: The Four Cs
Security in depth, outside in. A weakness at an outer layer undermines everything inside it.
┌─────────────────────────────────────────┐ │ CLOUD IAM, VPC, managed node access│ │ ┌─────────────────────────────────────┐ │ │ │ CLUSTER API server, RBAC, etcd, │ │ │ │ admission, NetworkPolicy │ │ │ │ ┌─────────────────────────────────┐ │ │ │ │ │ CONTAINER image, runtime user, │ │ │ │ │ │ capabilities │ │ │ │ │ │ ┌─────────────────────────────┐ │ │ │ │ │ │ │ CODE deps, secrets, SAST │ │ │ │ │ │ │ └─────────────────────────────┘ │ │ │ │ │ └─────────────────────────────────┘ │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────┘
A perfect Pod Security policy does not help if the node's IAM role can read every S3 bucket in the account and any pod can reach the instance metadata service.
STEP 2: RBAC -- Where Most Real Compromises Start
Audit what exists
bash# Who can do what, cluster-wide kubectl get clusterrolebindings -o wide kubectl get rolebindings --all-namespaces -o wide # Find bindings to the dangerous built-in role kubectl get clusterrolebindings -o json | \ jq -r '.items[] | select(.roleRef.name=="cluster-admin") | "\(.metadata.name): \(.subjects // [] | map(.kind + "/" + .name) | join(", "))"' # Can a specific ServiceAccount do something? kubectl auth can-i --list --as=system:serviceaccount:default:my-app kubectl auth can-i create pods --as=system:serviceaccount:default:my-app -n kube-system # Anything bound to system:anonymous or system:unauthenticated -- critical finding kubectl get clusterrolebindings -o json | \ jq '.items[] | select(.subjects[]?.name | test("anonymous|unauthenticated"))'
The privilege escalation paths that matter
These permissions are equivalent to cluster-admin even though they do not look like it:
| Permission | Why it is cluster-admin | Path |
|---|---|---|
create pods in any namespace with privileged SAs | Mount any ServiceAccount token | Create a pod using a privileged SA, read its token |
create pods with hostPath | Read the host filesystem | Mount / and read kubelet credentials or etcd |
get/list secrets cluster-wide | Read every ServiceAccount token | Use the most privileged token found |
create serviceaccounts/token | Mint tokens for any SA | Direct impersonation |
escalate on roles | Grant yourself anything | Bypasses the normal privilege-escalation guard |
bind on roles | Bind existing powerful roles to yourself | Same outcome |
impersonate | Act as any user or group | Direct |
update on nodes | Attach to node objects | Workload scheduling control |
When reviewing a Role, ask not "what does this allow" but "what is the most privileged thing reachable from this".
Least-privilege Role pattern
yamlapiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: namespace: payments name: payments-app rules: - apiGroups: [""] resources: ["configmaps"] resourceNames: ["payments-config"] # scope to the specific object verbs: ["get", "watch"]
Three rules for RBAC that hold up in practice:
- Namespace-scoped
RoleoverClusterRoleunless genuinely cluster-wide - Enumerate
resourceNameswhere the object set is known - Never grant
*on verbs or resources -- write them out
Disable automatic token mounting
Every pod gets a ServiceAccount token mounted by default. Most applications never call the Kubernetes API.
yamlapiVersion: v1 kind: ServiceAccount metadata: name: payments-app automountServiceAccountToken: false # default off --- apiVersion: apps/v1 kind: Deployment spec: template: spec: serviceAccountName: payments-app automountServiceAccountToken: false # explicit at pod level too
This single change removes the most common post-compromise escalation path.
STEP 3: Pod Security Standards
PodSecurityPolicy was removed in v1.25. Pod Security Admission is the built-in replacement, with three profiles.
privileged -- unrestricted. Only for genuine infrastructure workloads. baseline -- blocks known privilege escalations. Minimal app changes. restricted -- hardened. Enforces non-root, seccomp, dropped capabilities.
Apply per namespace
yamlapiVersion: v1 kind: Namespace metadata: name: payments labels: pod-security.kubernetes.io/enforce: restricted pod-security.kubernetes.io/enforce-version: v1.29 pod-security.kubernetes.io/audit: restricted pod-security.kubernetes.io/warn: restricted
Roll out with warn and audit first, read the violations, fix the workloads, then switch enforce on. Enforcing immediately on an existing namespace will break deployments.
bash# What would break? Dry-run the label kubectl label --dry-run=server --overwrite ns payments \ pod-security.kubernetes.io/enforce=restricted
A pod that satisfies restricted
yamlapiVersion: v1 kind: Pod metadata: name: app spec: automountServiceAccountToken: false securityContext: runAsNonRoot: true runAsUser: 10001 runAsGroup: 10001 fsGroup: 10001 seccompProfile: type: RuntimeDefault containers: - name: app image: registry.example.com/app@sha256:abc123... # digest, not tag securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: ["ALL"] resources: limits: { cpu: "500m", memory: "512Mi" } requests: { cpu: "100m", memory: "128Mi" } volumeMounts: - name: tmp mountPath: /tmp volumes: - name: tmp emptyDir: {}
Note readOnlyRootFilesystem: true with an emptyDir mounted at /tmp. Most applications need somewhere writable; give them exactly that and nothing else.
Settings that are never acceptable in production
yamlsecurityContext: privileged: true # full host access -- container escape by design hostNetwork: true # sees all host traffic, bypasses NetworkPolicy hostPID: true # can see and signal host processes hostIPC: true volumes: - hostPath: path: / # host filesystem access
If a workload genuinely requires these (some CNI, CSI, and monitoring agents do), isolate it in a dedicated namespace with privileged PSS, restrict who can deploy there, and document the exception.
STEP 4: NetworkPolicy
Kubernetes is flat by default. Every pod can reach every other pod in every namespace. Without NetworkPolicy, a compromised frontend can talk directly to the payments database.
Default deny -- the foundation
yamlapiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny-all namespace: payments spec: podSelector: {} # all pods in the namespace policyTypes: ["Ingress", "Egress"]
Apply this to every namespace, then add explicit allows. Starting from deny is the only approach that scales.
Allow only what is needed
yamlapiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: api-allows-frontend namespace: payments spec: podSelector: matchLabels: { app: payments-api } policyTypes: ["Ingress", "Egress"] ingress: - from: - namespaceSelector: matchLabels: { kubernetes.io/metadata.name: frontend } podSelector: matchLabels: { app: web } ports: - protocol: TCP port: 8080 egress: # DNS is required -- forgetting this breaks everything - to: - namespaceSelector: matchLabels: { kubernetes.io/metadata.name: kube-system } podSelector: matchLabels: { k8s-app: kube-dns } ports: - { protocol: UDP, port: 53 } - { protocol: TCP, port: 53 } - to: - podSelector: matchLabels: { app: payments-db } ports: - { protocol: TCP, port: 5432 }
The three NetworkPolicy traps
- Forgetting DNS egress. A default-deny egress policy blocks DNS, and every outbound connection fails with a name resolution error that looks nothing like a network policy problem.
- No CNI enforcement. NetworkPolicy objects are accepted by the API server even if the CNI plugin does not implement them. Flannel does not. Verify enforcement with a real connectivity test, never assume.
namespaceSelectorandpodSelectorin the same list item vs separate items. Combined in one item means AND (that pod in that namespace). As separate list items it means OR. Getting this wrong silently widens the policy.
bash# Verify a policy actually blocks kubectl run test --rm -it --image=nicolaka/netshoot --restart=Never -n payments -- \ curl -m 5 http://payments-db:5432
Block the cloud metadata endpoint
yamlegress: - to: - ipBlock: cidr: 0.0.0.0/0 except: - 169.254.169.254/32 # cloud instance metadata
Pods reaching the metadata service can often retrieve the node's IAM credentials. This is one of the most common cloud escalation paths from a compromised container.
STEP 5: Secrets
Kubernetes Secrets are base64, not encrypted
bashkubectl get secret db-creds -o jsonpath='{.data.password}' | base64 -d
Anyone with get secrets reads them in plaintext. By default they are also stored unencrypted in etcd.
Enable encryption at rest
yaml# EncryptionConfiguration on the API server apiVersion: apiserver.config.k8s.io/v1 kind: EncryptionConfiguration resources: - resources: ["secrets"] providers: - kms: # external KMS -- preferred name: cloud-kms endpoint: unix:///var/run/kmsplugin/socket.sock - identity: {} # fallback, must be last
After enabling, rewrite existing secrets so they are re-encrypted:
bashkubectl get secrets --all-namespaces -o json | kubectl replace -f -
Better: do not store secrets in the cluster at all
External Secrets Operator -- syncs from Vault / AWS Secrets Manager / Azure Key Vault Secrets Store CSI Driver -- mounts directly from the external store, never in etcd Workload Identity -- no stored credential at all; the pod assumes a cloud role
Workload Identity is the strongest option where the cloud supports it -- there is no secret to steal because authentication is based on the pod's verified identity.
Never do this
yamlenv: - name: DB_PASSWORD value: "hunter2" # in the manifest, in git, in every log
Scan for it:
bashgitleaks detect --source . --verbose trufflehog filesystem ./manifests
STEP 6: Admission Control
Pod Security Admission covers pod-level hardening. For everything else -- required labels, allowed registries, resource limits, ingress hostname rules -- use a policy engine.
Kyverno (YAML-native, easier adoption)
yamlapiVersion: kyverno.io/v1 kind: ClusterPolicy metadata: name: require-signed-images spec: validationFailureAction: Enforce rules: - name: check-registry match: any: - resources: kinds: ["Pod"] validate: message: "Images must come from the approved registry" pattern: spec: containers: - image: "registry.example.com/*"
OPA Gatekeeper (Rego, more expressive)
regopackage k8srequiredlabels violation[{"msg": msg}] { provided := {label | input.review.object.metadata.labels[label]} required := {label | label := input.parameters.labels[_]} missing := required - provided count(missing) > 0 msg := sprintf("Missing required labels: %v", [missing]) }
Choose Kyverno if the team writes YAML, Gatekeeper if you need complex logic or already use Rego elsewhere. Both support audit mode -- always start there.
Policies worth enforcing from day one
[ ] Images from approved registries only [ ] Images referenced by digest, not mutable tags [ ] Resource requests and limits required [ ] No :latest tag [ ] Required labels for ownership and cost allocation [ ] No privileged containers outside the exception namespace [ ] ReadOnlyRootFilesystem required [ ] Ingress hostnames must match an owned domain
STEP 7: Supply Chain
bash# Scan images before they reach the cluster trivy image --severity HIGH,CRITICAL registry.example.com/app:1.2.3 grype registry.example.com/app:1.2.3 # Generate and store an SBOM syft registry.example.com/app:1.2.3 -o spdx-json > sbom.json # Sign the image cosign sign --key cosign.key registry.example.com/app@sha256:abc123 # Verify at admission time cosign verify --key cosign.pub registry.example.com/app@sha256:abc123
Enforce signature verification in the cluster
yamlapiVersion: kyverno.io/v1 kind: ClusterPolicy metadata: name: verify-image-signature spec: validationFailureAction: Enforce rules: - name: verify-signature match: any: - resources: kinds: ["Pod"] verifyImages: - imageReferences: ["registry.example.com/*"] attestors: - entries: - keys: publicKeys: |- -----BEGIN PUBLIC KEY----- ... -----END PUBLIC KEY-----
Build hardening
dockerfileFROM golang:1.22 AS build WORKDIR /src COPY . . RUN CGO_ENABLED=0 go build -o /app ./cmd/app FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /app /app USER nonroot:nonroot ENTRYPOINT ["/app"]
Distroless or scratch base images remove the shell, package manager, and most of the attack surface. An attacker who achieves RCE in a distroless container has no sh, no curl, and no apt.
STEP 8: Runtime Detection
Prevention fails eventually. You need to see it when it does.
yaml# Falco rule -- shell spawned in a container - rule: Terminal shell in container desc: A shell was spawned in a container condition: > spawned_process and container and shell_procs and proc.tty != 0 output: > Shell spawned (user=%user.name container=%container.name image=%container.image.repository cmd=%proc.cmdline) priority: WARNING
Signals worth alerting on
[ ] Shell spawned inside a container [ ] Process writing to a known binary directory [ ] Outbound connection to an unexpected destination [ ] ServiceAccount token read by a process that is not the application [ ] Pod created with privileged or hostPath [ ] RBAC binding to cluster-admin created [ ] Secret accessed by an unusual identity [ ] Container escape indicators -- nsenter, docker.sock access [ ] Crypto mining process signatures [ ] Access to 169.254.169.254 from a pod
Enable and use the audit log
yaml# Audit policy -- log the things that matter apiVersion: audit.k8s.io/v1 kind: Policy rules: - level: RequestResponse resources: - group: "" resources: ["secrets", "serviceaccounts/token"] - level: RequestResponse resources: - group: "rbac.authorization.k8s.io" resources: ["clusterrolebindings", "rolebindings"] - level: Metadata omitStages: ["RequestReceived"]
Ship these to the SIEM. Without the audit log you cannot answer "who created that binding" after an incident.
STEP 9: Common Issues -- Symptom, Cause, Fix
Issue 1: Compromised pod reached the cloud account
- Cause: pod reached 169.254.169.254 and retrieved node IAM credentials
- Fix: block metadata in NetworkPolicy egress; move to Workload Identity / IRSA so the node role is minimal
Issue 2: NetworkPolicy applied, traffic still flows
- Cause: CNI does not enforce NetworkPolicy (Flannel) or the policy selector does not match
- Verify: deploy a test pod and curl; check CNI documentation
- Fix: migrate to Calico, Cilium, or the cloud provider's CNI with enforcement
Issue 3: Everything broke after default-deny egress
- Cause: DNS egress to kube-dns not permitted
- Fix: add the DNS egress rule to every default-deny policy
Issue 4: Deployment rejected after enabling restricted PSS
- Cause: workload runs as root, lacks seccomp, or does not drop capabilities
- Fix: fix the workload; use
warnmode first to find all violations before enforcing
Issue 5: ServiceAccount token found in a compromised container
- Cause:
automountServiceAccountTokenleft at the default - Fix: set it false on every ServiceAccount and pod that does not call the API
Issue 6: A developer escalated to cluster-admin
- Cause: they had
create podsin a namespace containing a privileged ServiceAccount - Fix: audit which SAs exist per namespace; separate privileged workloads into a locked-down namespace
Issue 7: Secrets visible in the audit log
- Cause: audit policy at
RequestResponsefor secrets without care, logs shipped broadly - Fix: keep the level but restrict access to the audit log itself; it is a crown-jewel data source
Issue 8: Image with a critical CVE in production
- Cause: no admission-time scanning gate, or
:latesttag pulled a new vulnerable build - Fix: pin by digest, scan in CI, enforce signature and scan status at admission
Issue 9: etcd backup stolen, all secrets exposed
- Cause: encryption at rest not enabled
- Fix: enable KMS encryption, rewrite all secrets, rotate everything that was in the backup
Issue 10: Cannot tell what happened during an incident
- Cause: audit logging disabled or not shipped off-cluster
- Fix: enable the audit policy above, ship to the SIEM, retain per the IR policy
Quick Checklists
Cluster hardening baseline
[ ] 1. API server not exposed to the internet, or restricted by IP allowlist [ ] 2. Anonymous auth disabled; no bindings to system:unauthenticated [ ] 3. RBAC audited -- no unnecessary cluster-admin bindings [ ] 4. automountServiceAccountToken false by default [ ] 5. Pod Security Admission enforcing baseline minimum, restricted where possible [ ] 6. Default-deny NetworkPolicy in every namespace [ ] 7. Metadata endpoint blocked in egress policy [ ] 8. Secrets encrypted at rest with KMS [ ] 9. Admission policy engine deployed and enforcing [ ] 10. Audit logging enabled and shipped off-cluster [ ] 11. Runtime detection deployed (Falco or equivalent) [ ] 12. Node OS patched; managed node groups auto-upgrading
Pre-deployment workload review
[ ] 1. runAsNonRoot true with an explicit UID [ ] 2. allowPrivilegeEscalation false [ ] 3. capabilities drop ALL [ ] 4. readOnlyRootFilesystem true [ ] 5. seccompProfile RuntimeDefault [ ] 6. Resource requests and limits set [ ] 7. Image by digest from an approved registry, signature verified [ ] 8. Image scanned, no unaccepted HIGH/CRITICAL findings [ ] 9. Dedicated ServiceAccount with a minimal Role [ ] 10. NetworkPolicy defining exactly the required ingress and egress [ ] 11. No secrets in env values; mounted from an external store [ ] 12. No hostPath, hostNetwork, hostPID, or privileged
Incident triage in a cluster
[ ] 1. Isolate: apply a deny-all NetworkPolicy to the affected pod [ ] 2. Preserve: snapshot the pod and node before deleting anything [ ] 3. Scope: which ServiceAccount did it have, what could that token do? [ ] 4. Audit log: what API calls came from that identity? [ ] 5. Lateral: did it reach the metadata endpoint or other namespaces? [ ] 6. Rotate: every credential reachable from the compromised identity [ ] 7. Root cause: image vulnerability, exposed service, or supply chain? [ ] 8. Fix the class of issue, not just the instance
PermitANY Resources
Automation Scripts (https://permitany.com/scripts)
- /scripts/container-security-scanner -- Image and container configuration scanning
- /scripts/cloud-security-auditor -- Cloud posture assessment around the cluster
- /scripts/secrets-scanner -- Detect secrets in manifests and code
- /scripts/host-vulnerability-scanner -- Node-level vulnerability assessment
AI Prompts (https://permitany.com/prompts)
- /prompts/vulnerability-prioritization -- Triage image and cluster CVEs by real risk
- /prompts/zero-trust-design -- Apply Zero Trust principles to the cluster
- /prompts/incident-response-guide -- NIST-aligned IR for a container compromise
Working Style
When a user asks about Kubernetes security:
- Establish the environment -- managed (EKS/GKE/AKS) or self-managed changes who owns the control plane
- Ask what is already in place before recommending; most clusters have some controls
- Prioritise by real-world attack path, not by checklist length. The order that matters: RBAC and token mounting, NetworkPolicy default-deny, metadata blocking, Pod Security, then supply chain
- Give working YAML, not descriptions -- and include the parts people forget, like DNS egress
- Always recommend audit or warn mode before enforcement
- For any finding, explain the escalation path it enables. "This SA can list secrets" means little; "this SA can list secrets, so a compromise of this pod yields every token in the cluster" lands
The question that finds the real risk fastest: "if an attacker gets RCE in your least important pod, what can they reach?" Walk that path concretely -- token, network, metadata, cloud -- and the priorities become obvious.
Further Learning
Recommended books to go deeper on this topic.
Container Security
Liz Rice · O'Reilly · The Linux constructs containers are built on, how a misconfiguration breaks isolation, and image and runtime hardening
Kubernetes Best Practices, Second Edition
Brendan Burns, Eddie Villalba, Dave Strebel, Lachlan Evenson · O'Reilly · Blueprints for requests and limits, probes, rollouts, storage and multi-tenancy, from the people who built much of it
Practical Cloud Security: A Guide for Secure Design and Deployment
Chris Dotson · O'Reilly Media · Vendor-neutral cloud security fundamentals - IAM, network segmentation, encryption, and logging across AWS, Azure, and GCP