Apply Pod Security Standards with namespace labels, write least-privilege RBAC roles, enforce NetworkPolicies, scan images with Trivy in CI, and manage secrets with Sealed Secrets. This guide takes a default Kubernetes cluster and hardens it to meet CIS Benchmark Level 2 requirements.
A default Kubernetes installation is designed for ease of adoption, not security. Pods run as root, there are no network restrictions between namespaces, and the default service account token is mounted into every container. The 2024 Red Hat State of Kubernetes Security report found that 89% of organizations experienced at least one container security incident in the previous 12 months, with misconfigurations being the leading cause. This guide provides the exact manifests and commands to close those gaps.
---
1. Enforce Pod Security Standards (PSS)
Pod Security Standards replaced the deprecated PodSecurityPolicy in Kubernetes 1.25. PSS uses namespace labels to enforce security profiles at three levels: Privileged (unrestricted), Baseline (prevents known privilege escalations), and Restricted (hardened best practices). According to the NSA/CISA Kubernetes Hardening Guide (updated 2024), all production namespaces should use at least the Baseline profile.
Apply PSS labels to namespaces
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
Apply to all existing namespaces at once
<h1>Label all non-system namespaces with baseline enforcement</h1>
for ns in $(kubectl get ns -o jsonpath='{.items[*].metadata.name}'
tr ' ' '\n'
grep -v "^kube-"); do
kubectl label ns "$ns" \
pod-security.kubernetes.io/enforce=baseline \
pod-security.kubernetes.io/warn=restricted \
--overwrite
done
This labels all non-system namespaces with baseline enforcement and restricted warnings. The warn mode shows developers what would fail under restricted without blocking deployments, giving teams time to migrate.
PSS level comparison
| Security Control | Privileged | Baseline | Restricted |
| Run as root | Allowed | Allowed | Blocked |
| Privileged containers | Allowed | Blocked | Blocked |
| Host networking | Allowed | Blocked | Blocked |
| Host PID/IPC | Allowed | Blocked | Blocked |
| Writable root filesystem | Allowed | Allowed | Blocked |
| Privilege escalation | Allowed | Blocked | Blocked |
| Seccomp profile | None | None | Required |
| Volume types | All | All | Limited |
---
2. Write Secure Pod Specifications
Even with PSS enforcement, writing secure pod specs from the start prevents debugging cycles. This template satisfies the Restricted PSS profile and passes CIS Benchmark checks.
Production-hardened pod template
apiVersion: apps/v1
kind: Deployment
metadata:
name: secure-api
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: secure-api
template:
metadata:
labels:
app: secure-api
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: api
image: registry.example.com/api:v1.2.3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 256Mi
ports:
- containerPort: 8080
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /readyz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
Key security features in this template:
- automountServiceAccountToken: false prevents the default SA token from being mounted. Most pods do not need Kubernetes API access.
- runAsNonRoot: true with explicit UID/GID ensures the container never runs as root, even if the Dockerfile specifies USER root.
- readOnlyRootFilesystem: true prevents attackers from writing malicious binaries to the container filesystem. The /tmp emptyDir volume provides writable space where needed.
- capabilities drop ALL removes all Linux capabilities. If your app needs specific capabilities (like NET_BIND_SERVICE for port 80), add only those.
- seccompProfile RuntimeDefault applies the container runtime's default seccomp filter, blocking about 44 dangerous syscalls.
Teams using Arxentra's managed Kubernetes get these security defaults applied automatically to all tenant workloads.
---
3. Configure Least-Privilege RBAC
The default RBAC configuration in many clusters is overly permissive. The 2024 Wiz Kubernetes Security report found that 40% of clusters have at least one service account with cluster-admin privileges that is never used. RBAC should follow the principle of least privilege: grant only the permissions each service actually needs.
Application-specific Role and RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: api-server-role
namespace: production
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "watch"]
resourceNames: ["api-config"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get"]
resourceNames: ["api-credentials"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: api-server-binding
namespace: production
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: api-server-role
subjects:
- kind: ServiceAccount
name: api-server
namespace: production
RBAC audit commands
<h1>Find all ClusterRoleBindings granting cluster-admin</h1>
kubectl get clusterrolebindings -o json | jq -r '
.items[]
select(.roleRef.name == "cluster-admin")
.metadata.name + " -> " + (.subjects[]? | .kind + "/" + .name)
'
<h1>Find service accounts with broad permissions</h1>
kubectl auth can-i --list --as=system:serviceaccount:default:default
<h1>Check if any SA can create pods (potential privilege escalation)</h1>
kubectl auth can-i create pods --as=system:serviceaccount:default:default
Run these audit commands monthly and remove any bindings that are no longer needed. The Arxentra security documentation covers RBAC best practices for multi-tenant environments.
---
4. Enforce NetworkPolicies
By default, every pod in a Kubernetes cluster can communicate with every other pod across all namespaces. This means a compromised pod in your staging namespace can reach your production database. NetworkPolicies are namespace-scoped firewall rules that restrict this traffic. According to Cilium's 2024 survey, only 35% of production clusters have any NetworkPolicies applied, making lateral movement the most common post-compromise attack vector.
Default-deny ingress and egress
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
Allow specific traffic patterns
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-to-db
namespace: production
spec:
podSelector:
matchLabels:
app: postgres
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: api-server
ports:
- port: 5432
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-egress
namespace: production
spec:
podSelector:
matchLabels:
app: api-server
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- port: 5432
- to:
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
Always remember to allow DNS egress (port 53 to kube-dns) or your pods will fail to resolve any hostnames. This is the most common mistake when implementing default-deny policies.
---
5. Scan Images with Trivy in CI
Container images are a primary attack vector. The 2024 Sysdig Container Security report found that 87% of container images contain at least one high or critical vulnerability. Scanning in CI prevents vulnerable images from reaching production.
Trivy scanning in GitHub Actions
name: Container Security Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t app:$GITHUB_SHA .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: app:$GITHUB_SHA
format: table
exit-code: "1"
ignore-unfixed: true
vuln-type: os,library
severity: CRITICAL,HIGH
- name: Run Trivy config scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: config
exit-code: "1"
severity: CRITICAL,HIGH
Local scanning during development
<h1>Scan a local image</h1>
trivy image app:latest
<h1>Scan a running cluster for vulnerabilities</h1>
trivy k8s --report summary cluster
<h1>Scan Kubernetes manifests for misconfigurations</h1>
trivy config ./k8s/manifests/
<h1>Generate SBOM (Software Bill of Materials)</h1>
trivy image --format spdx-json --output sbom.json app:latest
---
6. Manage Secrets with Sealed Secrets
Kubernetes Secrets are base64-encoded, not encrypted. Anyone with read access to the namespace can decode them. Sealed Secrets solves this by encrypting secrets with a cluster-specific key, allowing you to safely store encrypted secrets in Git.
Install Sealed Secrets controller
<h1>Install the controller</h1>
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm install sealed-secrets sealed-secrets/sealed-secrets \
--namespace kube-system \
--set-string fullnameOverride=sealed-secrets-controller
<h1>Install kubeseal CLI</h1>
<h1>macOS</h1>
brew install kubeseal
<h1>Linux</h1>
KUBESEAL_VERSION=$(curl -s https://api.github.com/repos/bitnami-labs/sealed-secrets/releases/latest
jq -r .tag_name
cut -c 2-)
curl -OL "https://github.com/bitnami-labs/sealed-secrets/releases/download/v$KUBESEAL_VERSION/kubeseal-$KUBESEAL_VERSION-linux-amd64.tar.gz"
tar -xvzf kubeseal-*.tar.gz kubeseal
sudo install -m 755 kubeseal /usr/local/bin/kubeseal
Create and seal a secret
<h1>Create a regular secret (do not apply this to the cluster)</h1>
kubectl create secret generic db-credentials \
--from-literal=username=app_user \
--from-literal=password=supersecret \
--dry-run=client -o yaml > secret.yaml
<h1>Seal it (encrypted with cluster public key)</h1>
kubeseal --format yaml < secret.yaml > sealed-secret.yaml
<h1>Now safe to commit sealed-secret.yaml to Git</h1>
rm secret.yaml # Delete the unencrypted version
git add sealed-secret.yaml
git commit -m "Add encrypted database credentials"
The sealed secret can only be decrypted by the Sealed Secrets controller running in your specific cluster. Even if someone gains access to your Git repository, they cannot decrypt the values without the controller's private key.
---
Security Baseline Checklist Summary
| Control | Tool/Feature | Status Check Command |
| Pod Security Standards | Namespace labels | `kubectl get ns -L pod-security.kubernetes.io/enforce` |
| Non-root containers | SecurityContext | `kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.name}{" runAsNonRoot="}{.spec.securityContext.runAsNonRoot}{"\n"}{end}'` |
| RBAC audit | kubectl auth | `kubectl auth can-i --list --as=system:serviceaccount:default:default` |
| NetworkPolicies | CNI plugin | `kubectl get networkpolicies -A` |
| Image scanning | Trivy | `trivy k8s --report summary cluster` |
| Secret encryption | Sealed Secrets | `kubectl get sealedsecrets -A` |
---
Frequently Asked Questions
How long does it take to implement this baseline?
A dedicated engineer can implement all six controls in 2-3 days for a cluster with up to 10 namespaces. The biggest time investment is writing NetworkPolicies, because you need to map all legitimate traffic flows before applying default-deny. Start with PSS labels (30 minutes) and RBAC audit (1 hour) for immediate wins.
Do NetworkPolicies work with all CNI plugins?
No. The default kubenet CNI does not support NetworkPolicies. You need a CNI that implements the NetworkPolicy API: Calico, Cilium, Weave Net, or Antrea. Most managed Kubernetes services (EKS, GKE, AKS) support Calico or Cilium out of the box.
Should I use OPA/Gatekeeper instead of Pod Security Standards?
PSS and OPA/Gatekeeper serve different purposes. PSS handles pod-level security with zero additional software. OPA/Gatekeeper handles custom organizational policies (naming conventions, required labels, approved registries). Use PSS as your security baseline and add OPA/Gatekeeper for policy enforcement. The Arxentra security best practices guide covers both approaches.
How do I handle legacy applications that need to run as root?
Use a dedicated namespace with the Baseline PSS profile (which allows root) and apply compensating controls: mandatory NetworkPolicies, restricted RBAC, and image scanning. Create a migration plan to refactor the application to run as non-root. Most applications that "need" root actually just need write access to specific directories, which can be solved with init containers or volume mounts.
What about runtime security (detecting attacks in progress)?
This baseline covers preventive controls. For runtime detection, add Falco (open-source runtime security) to detect anomalous behavior like unexpected process execution, file access, or network connections. Falco integrates with the same Kubernetes audit infrastructure and can alert to Slack, PagerDuty, or your SIEM.
How do I rotate Sealed Secrets encryption keys?
The Sealed Secrets controller automatically rotates its encryption key every 30 days by default. Old keys are kept so existing SealedSecrets can still be decrypted. To manually rotate, delete the current key and restart the controller. All existing SealedSecrets will need to be re-encrypted with the new key using `kubeseal --re-encrypt`.