Kubernetes Security Best Practices: CIS Benchmark (2026)
Kubernetes security is not a single feature — it's a defense-in-depth strategy that spans the container image, pod runtime, RBAC, network, and secrets management layers. The CIS Kubernetes Benchmark is the industry standard checklist, and modern tools like Kyverno, Trivy, and NetworkPolicy make it practical to enforce these controls at scale. This guide walks through the most impactful controls with real YAML examples.
Pod Security Standards
Pod Security Standards (PSS) replaced the deprecated PodSecurityPolicy in Kubernetes 1.25. They define three security profiles enforced at the namespace level via labels.
| Profile | Restrictions | Use Case |
|---|---|---|
| Privileged | No restrictions — allows all | System namespaces (kube-system, monitoring) |
| Baseline | Blocks most privilege escalation vectors; allows some host namespace access | General workloads that don't need root |
| Restricted | Enforces all security best practices; requires non-root, dropped capabilities, read-only root filesystem | Sensitive workloads, multi-tenant clusters |
Each profile can be applied in three modes via namespace labels:
enforce— reject pods that violate the policyaudit— log violations but allow the podwarn— return a warning to the client but allow the pod
# Apply Restricted profile to a namespace (enforce + warn)
kubectl label namespace production \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=v1.29 \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/warn-version=v1.29
# Audit mode for a namespace you're migrating
kubectl label namespace legacy \
pod-security.kubernetes.io/audit=baseline \
pod-security.kubernetes.io/audit-version=v1.29
# Dry-run: check what would be rejected in a namespace
kubectl label namespace production \
pod-security.kubernetes.io/enforce=restricted \
--dry-run=server
# Check existing namespace labels
kubectl get namespace production -o yaml | grep pod-security
kube-system, kube-public, kube-node-lease) must remain Privileged or the cluster will not function. Apply Restricted to your application namespaces, Baseline to shared tooling namespaces, and leave system namespaces alone.securityContext Fields
The securityContext controls Linux security settings for a pod or individual container. Under the Restricted PSS profile, several of these become mandatory.
apiVersion: apps/v1
kind: Deployment
metadata:
name: secure-app
namespace: production
spec:
template:
spec:
# Pod-level security context
securityContext:
runAsNonRoot: true # Pod fails to start if image runs as root
runAsUser: 1000 # UID to run as
runAsGroup: 3000 # GID to run as
fsGroup: 2000 # Files created in volumes owned by this GID
seccompProfile:
type: RuntimeDefault # Use container runtime's default seccomp profile
containers:
- name: app
image: my-app:1.4.2
# Container-level security context (overrides pod-level)
securityContext:
allowPrivilegeEscalation: false # Prevent sudo/setuid escalation
readOnlyRootFilesystem: true # Container FS is read-only
runAsNonRoot: true
capabilities:
drop:
- ALL # Drop all Linux capabilities
add:
- NET_BIND_SERVICE # Add back only what's needed (e.g., bind port 80)
volumeMounts:
- name: tmp-dir
mountPath: /tmp # Writable /tmp since rootFS is read-only
- name: cache-dir
mountPath: /app/cache
volumes:
- name: tmp-dir
emptyDir: {}
- name: cache-dir
emptyDir: {}
readOnlyRootFilesystem: true breaks applications that write to the filesystem at runtime (logs to local files, temp files, pid files). Always pair it with emptyDir volume mounts for /tmp and any other directories the app needs to write to. Check your application's writes with strace -e trace=file -p <pid> before enabling.Verifying securityContext Compliance
# Check if any containers run as root
kubectl get pods -n production -o json | \
jq '.items[].spec.containers[] | select(.securityContext.runAsUser == 0 or .securityContext.runAsUser == null) | .name'
# Check for privileged containers
kubectl get pods --all-namespaces -o json | \
jq '.items[] | select(.spec.containers[].securityContext.privileged == true) | .metadata.name'
RBAC: Principle of Least Privilege
RBAC (Role-Based Access Control) controls who can do what to which resources. The principle of least privilege means granting only the permissions actually needed — nothing more.
Role vs ClusterRole
- Role — grants permissions within a single namespace
- ClusterRole — grants permissions cluster-wide or can be bound per-namespace
- RoleBinding — binds a Role or ClusterRole to a subject within a namespace
- ClusterRoleBinding — binds a ClusterRole cluster-wide
# Minimal Role: read-only access to pods and logs in one namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pod-reader
namespace: production
rules:
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list", "watch"]
---
# Bind the role to a service account
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: pod-reader-binding
namespace: production
subjects:
- kind: ServiceAccount
name: monitoring-sa
namespace: production
roleRef:
kind: Role
apiGroupi: rbac.authorization.k8s.io
name: pod-reader
# Application service account — no permissions (deny by default)
apiVersion: v1
kind: ServiceAccount
metadata:
name: myapp-sa
namespace: production
automountServiceAccountToken: false # Don't auto-mount API credentials
---
# If the app needs to read its own ConfigMaps:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: myapp-configmap-reader
namespace: production
rules:
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["myapp-config"] # Restrict to a specific ConfigMap by name
verbs: ["get", "watch"]
# Audit: find overly permissive bindings with wildcard verbs
kubectl get clusterrolebindings -o json | \
jq '.items[] | select(.roleRef.name == "cluster-admin") | .subjects[].name'
# Check what permissions a service account has
kubectl auth can-i --list \
--as=system:serviceaccount:production:myapp-sa \
-n production
# Simulate a specific action
kubectl auth can-i delete pods \
--as=system:serviceaccount:production:myapp-sa \
-n production
Secrets Encryption at Rest
By default, Kubernetes Secrets are stored unencrypted in etcd. Anyone with direct etcd access can read all Secrets in plaintext. Enable encryption at rest with an EncryptionConfiguration resource.
# /etc/kubernetes/encryption-config.yaml (on API server nodes)
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
- configmaps # Optional: also encrypt ConfigMaps
providers:
# AES-GCM with a 256-bit key (preferred)
- aescbc:
keys:
- name: key1
secret: c2VjcmV0LWtleS0zMi1ieXRlcy1sb25nLXN0cmluZw== # base64(32-byte key)
# KMS provider for key rotation (cloud-managed)
# - kms:
# name: myKMSPlugin
# endpoint: unix:///var/run/kmsplugin/socket.sock
# cachesize: 1000
- identity: {} # Fallback — do not list first
# Enable on kube-apiserver (add to /etc/kubernetes/manifests/kube-apiserver.yaml)
# --encryption-provider-config=/etc/kubernetes/encryption-config.yaml
# After enabling, re-encrypt all existing secrets
kubectl get secrets --all-namespaces -o json | \
kubectl replace -f -
# Verify a secret is encrypted in etcd
ETCDCTL_API=3 etcdctl get \
--endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
/registry/secrets/default/my-secret | hexdump -C | head
# Encrypted output starts with "k8s:enc:aescbc:v1:key1:"
Admission Controllers: OPA Gatekeeper vs Kyverno
Admission controllers intercept API requests before they are persisted, allowing you to enforce custom policies (e.g., "all images must come from our registry", "all pods must have resource limits").
Kyverno (Kubernetes-native, recommended)
# Install Kyverno
helm repo add kyverno https://kyverno.github.io/kyverno
helm install kyverno kyverno/kyverno \
--namespace kyverno \
--create-namespace
# Policy: require resource limits on all containers
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-resource-limits
spec:
validationFailureAction: Enforce # Reject non-compliant resources
background: true # Also audit existing resources
rules:
- name: check-container-resources
match:
any:
- resources:
kinds:
- Pod
exclude:
any:
- resources:
namespaces:
- kube-system
- kyverno
validate:
message: "CPU and memory limits are required for all containers."
pattern:
spec:
containers:
- (name): "*"
resources:
limits:
memory: "?*"
cpu: "?*"
# Policy: require images from approved registries only
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: restrict-image-registries
spec:
validationFailureAction: Enforce
rules:
- name: validate-registries
match:
any:
- resources:
kinds: [Pod]
validate:
message: "Images must come from registry.company.com or gcr.io/distroless."
pattern:
spec:
containers:
- image: "registry.company.com/* | gcr.io/distroless/*"
# Policy: mutate — auto-add securityContext to pods missing it
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: add-default-security-context
spec:
rules:
- name: add-security-context
match:
any:
- resources:
kinds: [Pod]
mutate:
patchStrategicMerge:
spec:
securityContext:
+(runAsNonRoot): true
+(seccompProfile):
type: RuntimeDefault
containers:
- (name): "*"
securityContext:
+(allowPrivilegeEscalation): false
+(capabilities):
drop: [ALL]
# Check Kyverno policy reports
kubectl get policyreport --all-namespaces
kubectl describe clusterpolicyreport
# Check violations for a specific policy
kubectl get polr -n production -o json | \
jq '.items[].results[] | select(.result == "fail")'
Container Image Scanning with Trivy
Trivy (by Aqua Security) scans container images for OS package vulnerabilities, application dependency CVEs, misconfigured Dockerfiles, and secret leaks. Integrating it into CI prevents vulnerable images from reaching production.
# Install Trivy
brew install aquasecurity/trivy/trivy # macOS
# Or: apt install trivy
# Scan an image
trivy image nginx:1.25.3
# Scan with severity filter (fail CI on CRITICAL/HIGH)
trivy image --severity CRITICAL,HIGH \
--exit-code 1 \
my-app:1.4.2
# Scan a local Dockerfile
trivy config ./Dockerfile
# Scan a Kubernetes manifest directory for misconfigurations
trivy config ./k8s/
# Generate SARIF output for GitHub Advanced Security
trivy image --format sarif \
--output trivy-results.sarif \
my-app:1.4.2
# GitHub Actions CI integration
name: Security Scan
on: [push, pull_request]
jobs:
trivy-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t my-app:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: my-app:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: 1
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
Network Segmentation with NetworkPolicies
By default, all pods in a Kubernetes cluster can communicate with all other pods. NetworkPolicies implement firewall rules at the pod level using label selectors.
# Default deny all ingress and egress in a namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {} # Applies to all pods in the namespace
policyTypes:
- Ingress
- Egress
# No ingress/egress rules = deny everything
# Allow frontend to talk to backend, and backend to talk to database
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-ingress
namespace: production
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
- Egress
ingress:
# Only allow traffic from frontend pods
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
egress:
# Allow to database
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- protocol: TCP
port: 5432
# Allow DNS resolution
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
# Allow monitoring to scrape metrics from all pods
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-prometheus-scrape
namespace: production
spec:
podSelector: {} # All pods
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
podSelector:
matchLabels:
app: prometheus
ports:
- protocol: TCP
port: 9090
- protocol: TCP
port: 8080 # App metrics port
kubectl get networkpolicy -n kube-system.Audit Logging
Kubernetes audit logs record every API request — who did what to which resource. Essential for incident investigation and compliance (SOC2, PCI-DSS, HIPAA).
# /etc/kubernetes/audit-policy.yaml
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
# Log secret access at RequestResponse level (includes response body)
- level: RequestResponse
resources:
- group: ""
resources: ["secrets"]
# Log all authentication failures
- level: Request
verbs: ["create"]
resources:
- group: "authentication.k8s.io"
resources: ["tokenreviews"]
# Log pod exec (security-sensitive)
- level: RequestResponse
verbs: ["create"]
resources:
- group: ""
resources: ["pods/exec", "pods/attach", "pods/portforward"]
# Log RBAC changes
- level: RequestResponse
resources:
- group: "rbac.authorization.k8s.io"
resources: ["roles", "clusterroles", "rolebindings", "clusterrolebindings"]
# Minimal logging for read-only operations on non-sensitive resources
- level: Metadata
verbs: ["get", "list", "watch"]
# Default: log metadata for everything else
- level: Metadata
# Enable on kube-apiserver
# --audit-policy-file=/etc/kubernetes/audit-policy.yaml
# --audit-log-path=/var/log/kubernetes/audit.log
# --audit-log-maxage=30
# --audit-log-maxbackup=3
# --audit-log-maxsize=100 # MB
# Query audit logs for a specific user
grep '"user":{"username":"admin"' /var/log/kubernetes/audit.log | \
jq '{time: .requestReceivedTimestamp, verb: .verb, resource: .objectRef.resource, name: .objectRef.name}'
CIS Kubernetes Benchmark Key Checks
The CIS Kubernetes Benchmark has over 100 checks. These are the highest-impact ones to implement first.
| CIS Check | Control | How to Implement |
|---|---|---|
| 1.2.1 | API server anonymous auth disabled | --anonymous-auth=false on kube-apiserver |
| 1.2.6 | AlwaysPullImages admission controller | Add to --enable-admission-plugins |
| 1.2.10 | Audit logging enabled | Configure --audit-policy-file as above |
| 1.2.24 | Secrets encryption at rest | EncryptionConfiguration with AES-GCM or KMS |
| 3.2.1 | Minimal RBAC permissions | Audit ClusterRoleBindings to cluster-admin |
| 4.1.5 | No containers run as root | runAsNonRoot: true + Pod Security Standards |
| 4.2.1 | No privileged containers | Kyverno/PSS: block privileged: true |
| 4.2.6 | No host network/PID/IPC sharing | PSS Baseline/Restricted profile enforcement |
| 5.1.1 | RBAC is enabled | Enabled by default since K8s 1.6 |
| 5.2.1 | Minimize privileged containers | PSS + Kyverno policies |
| 5.4.1 | Prefer Secrets as files over env vars | Use volume mounts for secrets |
| 5.7.1 | NetworkPolicies in all namespaces | Default-deny + explicit allow rules |
# Run kube-bench to automatically check CIS compliance
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml
kubectl logs job/kube-bench
# Or run as a DaemonSet to check all nodes
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job-node.yaml
# Check specific CIS section
kubectl run kube-bench \
--image=aquasec/kube-bench:latest \
--restart=Never \
-- --check 1.2.1,4.1.5,5.7.1
Frequently Asked Questions
What's the difference between OPA Gatekeeper and Kyverno?
Both are admission controllers that enforce custom policies. OPA Gatekeeper uses Rego, a purpose-built policy language that is powerful but has a steep learning curve. Kyverno uses YAML-based policies (no new language to learn) and is Kubernetes-native, meaning policies are Kubernetes resources themselves. Kyverno also supports mutation (auto-patching resources) and image verification natively. For most Kubernetes teams in 2026, Kyverno is the faster path to production policy enforcement. Use OPA Gatekeeper if you already have Rego expertise or need complex multi-step logic.
Should I use Pod Security Standards or Kyverno policies — or both?
Use both, with complementary scopes. PSS (enforced via namespace labels) covers a well-defined set of security controls built into Kubernetes itself — no extra components required, zero performance overhead. Kyverno handles everything PSS cannot: registry restrictions, required labels/annotations, resource limits enforcement, image signing verification, and custom organizational policies. Set PSS to Restricted on application namespaces and use Kyverno for the additional policy layer on top.
How do I find all service accounts with cluster-admin access?
Run: kubectl get clusterrolebindings -o json | jq '.items[] | select(.roleRef.name == "cluster-admin") | {name: .metadata.name, subjects: .subjects}'. Also check RoleBindings within individual namespaces for cluster-admin references. Any service account with cluster-admin is a full-cluster compromise vector — replace it with narrowly scoped roles. Legitimate uses for cluster-admin should be restricted to cluster operators and CI/CD systems with strict controls.
What does Trivy scan that traditional vulnerability scanners miss?
Trivy scans four distinct layers: (1) OS packages (apt/yum/apk installed packages vs CVE databases), (2) application dependencies (package-lock.json, requirements.txt, pom.xml, go.sum parsed from the image layer), (3) IaC misconfigurations (Dockerfile, Kubernetes manifests, Terraform), and (4) embedded secrets (API keys, passwords, tokens hardcoded in files). Traditional scanners often only cover OS packages. The IaC scanning is particularly valuable in CI — catch a missing readOnlyRootFilesystem: true before it reaches production.
Do NetworkPolicies affect performance?
With Cilium (eBPF-based CNI), NetworkPolicy enforcement happens in the kernel with minimal overhead — typically under 1% latency impact. With iptables-based CNIs (Calico in legacy mode, Weave), performance degrades at scale due to the linear scan of iptables rules. For clusters with thousands of pods and hundreds of policies, use Cilium or Calico with eBPF mode. Start with default-deny plus explicit allow rules in staging and measure latency before and after to quantify impact in your environment.