Kubernetes security requires a defense-in-depth approach covering cluster hardening, RBAC, pod security, network policies, supply chain security, and runtime protection. This guide provides comprehensive security guidance for production Kubernetes clusters.
Disable anonymous authentication:
# kube-apiserver configuration
apiVersion: v1
kind: Pod
metadata:
name: kube-apiserver
namespace: kube-system
spec:
containers:
- name: kube-apiserver
command:
- kube-apiserver
- --anonymous-auth=false # Disable anonymous auth
- --authorization-mode=Node,RBAC # Enable RBAC
- --enable-admission-plugins=NodeRestriction,PodSecurity
Restrict API server access:
# Check API server access
kubectl get --raw /api/v1/namespaces/kube-system/pods
# Audit API server logs
kubectl logs -n kube-system kube-apiserver-control-plane | grep -i "forbidden\|unauthorized"
Enable audit logging:
# /etc/kubernetes/audit-policy.yaml
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
# Log no events for certain paths
- level: None
nonResourceURLs:
- "/healthz*"
- "/version"
- "/swagger*"
# Log metadata for pod exec
- level: Metadata
resources:
- group: ""
resources: ["pods/exec", "pods/attach"]
# Log request and response for secrets
- level: RequestResponse
resources:
- group: ""
resources: ["secrets"]
# Default level
- level: Metadata
omitStages:
- RequestReceived
Apply audit policy:
# Add to kube-apiserver configuration
- --audit-policy-file=/etc/kubernetes/audit-policy.yaml
- --audit-log-path=/var/log/kubernetes/audit.log
- --audit-log-maxage=30
- --audit-log-maxbackup=10
- --audit-log-maxsize=100
Principle of least privilege:
# Create limited role
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pod-reader
namespace: default
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
---
# Bind role to service account
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: read-pods
namespace: default
subjects:
- kind: ServiceAccount
name: my-app
namespace: default
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io
Avoid cluster-admin binding:
# Check cluster-admin bindings
kubectl get clusterrolebinding cluster-admin -o yaml
# List all cluster-admin users/groups
kubectl get clusterrolebinding cluster-admin -o jsonpath='{.subjects[*].name}'
# Remove unnecessary cluster-admin bindings
kubectl delete clusterrolebinding risky-binding
Use short-lived tokens:
# Service account with short-lived token
apiVersion: v1
kind: ServiceAccount
metadata:
name: my-app
automountServiceAccountToken: false
---
# Create token with expiration
apiVersion: authentication.k8s.io/v1
kind: TokenRequest
metadata:
name: my-app
spec:
audiences:
- https://kubernetes.default.svc
expirationSeconds: 3600 # 1 hour
Audit RBAC permissions:
# Check what a service account can do
kubectl auth can-i --list --as=system:serviceaccount:default:my-app
# Check who can access secrets
kubectl auth can-i get secrets --all-namespaces --list
# Find overprivileged service accounts
kubectl get serviceaccounts --all-namespaces -o json | \
jq '.items[] | select(.automountServiceAccountToken != false)'
Enable TLS for etcd:
# etcd configuration
apiVersion: v1
kind: Pod
metadata:
name: etcd
namespace: kube-system
spec:
containers:
- name: etcd
command:
- etcd
- --cert-file=/etc/kubernetes/pki/etcd/server.crt
- --key-file=/etc/kubernetes/pki/etcd/server.key
- --trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt
- --client-cert-auth=true
- --peer-cert-file=/etc/kubernetes/pki/etcd/peer.crt
- --peer-key-file=/etc/kubernetes/pki/etcd/peer.key
- --peer-trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt
- --peer-client-cert-auth=true
Restrict etcd access:
# Check etcd access
ETCDCTL_API=3 etcdctl --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 \
get / --prefix --keys-only
# Backup etcd with encryption
ETCDCTL_API=3 etcdctl snapshot save backup.db \
--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
# Encrypt backup
gpg --encrypt --recipient admin@example.com backup.db
Encrypt secrets at rest:
# /etc/kubernetes/encryption-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key1
secret: <base64-encoded-key>
- identity: {} # Fallback (no encryption)
Apply encryption configuration:
# Add to kube-apiserver
- --encryption-provider-config=/etc/kubernetes/encryption-config.yaml
Configure kubelet authentication:
# /var/lib/kubelet/config.yaml
authentication:
anonymous:
enabled: false
webhook:
enabled: true
authorization:
mode: Webhook
Restrict kubelet access:
# Check kubelet access
curl -k https://node-ip:10250/pods
# With authentication
curl -k -H "Authorization: Bearer $TOKEN" \
https://node-ip:10250/pods
# Secure kubelet metrics
curl -k --cert client.crt --key client.key \
https://node-ip:10250/metrics
Pod Security Admission (PSA):
# Namespace with restricted policy
apiVersion: v1
kind: Namespace
metadata:
name: secure-namespace
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
Pod Security Standards levels:
| Level | Description | Use Case |
|---|---|---|
| privileged | No restrictions | System namespaces, kube-system |
| baseline | Minimally restrictive | General workloads |
| restricted | Highly restrictive | Security-sensitive workloads |
Restricted policy requirements:
Pod-level security context:
apiVersion: v1
kind: Pod
metadata:
name: secure-pod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: myapp:1.0
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
Container-level security context:
containers:
- name: app
image: myapp:1.0
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE # Only if needed for port < 1024
seccompProfile:
type: RuntimeDefault
Never use privileged: true unless absolutely necessary:
# BAD - Don't do this
containers:
- name: app
image: myapp
securityContext:
privileged: true # ⚠️ Security risk!
# GOOD - Use specific capabilities
containers:
- name: app
image: myapp
securityContext:
capabilities:
add:
- NET_ADMIN
- SYS_TIME
Check for privileged pods:
# Find privileged pods
kubectl get pods --all-namespaces -o json | \
jq '.items[] | select(.spec.containers[].securityContext.privileged == true)'
# Find pods running as root
kubectl get pods --all-namespaces -o json | \
jq '.items[] | select(.spec.containers[].securityContext.runAsNonRoot != true)'
Avoid hostPath volumes:
# BAD - Don't mount host paths
volumes:
- name: host-root
hostPath:
path: / # ⚠️ Security risk!
# GOOD - Use persistent volumes
volumes:
- name: data
persistentVolumeClaim:
claimName: my-pvc
Avoid host namespaces:
# BAD - Don't use host namespaces
spec:
hostNetwork: true # ⚠️ Security risk!
hostPID: true # ⚠️ Security risk!
hostIPC: true # ⚠️ Security risk!
# GOOD - Use isolated namespaces
spec:
hostNetwork: false
hostPID: false
hostIPC: false
Default deny all traffic:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: default
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
Allow specific ingress:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend
namespace: default
spec:
podSelector:
matchLabels:
app: frontend
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: ingress
ports:
- protocol: TCP
port: 80
Allow specific egress:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: default
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector: {}
ports:
- protocol: UDP
port: 53
Check network policies:
# List network policies
kubectl get networkpolicies --all-namespaces
# Describe network policy
kubectl describe networkpolicy allow-frontend -n default
Pull from trusted registries:
# GOOD - Official/trusted registries
containers:
- name: app
image: gcr.io/google-containers/nginx:1.21
imagePullPolicy: IfNotPresent
# BAD - Unknown sources
containers:
- name: app
image: randomuser/potentially-malicious:latest
Configure image pull secrets:
# Create image pull secret
kubectl create secret docker-registry regcred \
--docker-server=https://registry.example.com \
--docker-username=user \
--docker-password=password \
--docker-email=user@example.com
# Use in pod spec
spec:
containers:
- name: app
image: registry.example.com/myapp:1.0
imagePullSecrets:
- name: regcred
Never use latest tag:
# BAD - Don't use latest
containers:
- name: app
image: myapp:latest
# GOOD - Pin to version
containers:
- name: app
image: myapp:1.2.3
# BEST - Pin to digest
containers:
- name: app
image: myapp@sha256:abc123...
Use Trivy in CI/CD:
# .github/workflows/k8s-security.yml
name: Kubernetes Security
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Run Trivy
run: trivy image --exit-code 1 --severity CRITICAL myapp:${{ github.sha }}
- name: Push to registry
run: docker push registry.example.com/myapp:${{ github.sha }}
Use Kyverno for admission control:
# ClusterPolicy to require image scanning
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-image-scan
spec:
validationFailureAction: Enforce
background: true
rules:
- name: check-image-scan
match:
resources:
kinds:
- Pod
validate:
message: "Images must be scanned before deployment"
deny:
conditions:
- key: "{{ request.object.metadata.annotations.\"image-scanned\" || '' }}"
operator: NotEquals
value: "true"
Enable Pod Security Admission:
# kube-apiserver configuration
- --enable-admission-plugins=NodeRestriction,PodSecurity
Use OPA Gatekeeper:
# ConstraintTemplate for requiring non-root
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8spsenonroot
spec:
crd:
spec:
names:
kind: K8sPSENonRoot
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8spsenonroot
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
not container.securityContext.runAsNonRoot
msg := sprintf("Container %v must set runAsNonRoot to true", [container.name])
}
Apply constraint:
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sPSENonRoot
metadata:
name: require-nonroot
spec:
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
Use External Secrets Operator:
# Install External Secrets Operator
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets
# Create ClusterSecretStore
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault-backend
spec:
provider:
vault:
server: https://vault.example.com
path: secret
auth:
kubernetes:
mountPath: kubernetes
role: kubernetes-role
serviceAccountRef:
name: external-secrets
Create ExternalSecret:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: database-credentials
namespace: default
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: database-credentials
creationPolicy: Owner
data:
- secretKey: username
remoteRef:
key: secret/data/database
property: username
- secretKey: password
remoteRef:
key: secret/data/database
property: password
Automate secret rotation:
# Use sealed-secrets for GitOps
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm install sealed-secrets sealed-secrets/sealed-secrets
# Create sealed secret
kubeseal --format yaml < secret.yaml > sealed-secret.yaml
Rotate service account tokens:
# Delete old token secret
kubectl delete secret my-app-token -n default
# Kubernetes automatically creates new token
# Update deployments to use new token
Configure audit backend:
# Send audit logs to syslog
- --audit-log-path=/var/log/kubernetes/audit.log
- --audit-log-maxage=30
- --audit-log-maxbackup=10
- --audit-log-maxsize=100
Monitor audit logs:
# Watch for denied requests
tail -f /var/log/kubernetes/audit.log | grep -i "forbidden"
# Watch for secret access
grep '"resource":"secrets"' /var/log/kubernetes/audit.log
# Watch for pod exec
grep '"resource":"pods/exec"' /var/log/kubernetes/audit.log
Install Falco for runtime security:
# Install Falco via Helm
helm repo add falcosecurity https://falcosecurity.github.io/charts
helm install falco falcosecurity/falco
# Configure Falco rules
# /etc/falco/falco_rules.yaml
- rule: Shell Spawned in Container
desc: Detect shell spawned in container
condition: spawned_process and container
output: "Shell spawned in container (user=%user.name container=%container.name)"
priority: WARNING
Install kube-bench for CIS benchmarks:
# Run kube-bench
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml
# Check results
kubectl logs job/kube-bench
Install kube-hunter for penetration testing:
# Run kube-hunter
docker run --rm aquasec/kube-hunter --remote --k8s-auto-discover
# Or from within cluster
docker run --rm aquasec/kube-hunter --pod
Check for crypto mining:
# Look for high CPU usage
kubectl top pods --all-namespaces | sort -k3 -nr | head
# Check for suspicious images
kubectl get pods --all-namespaces -o json | \
jq '.items[] | select(.spec.containers[].image | contains("miner") or contains("crypto"))'
Check for privilege escalation:
# Find pods with dangerous capabilities
kubectl get pods --all-namespaces -o json | \
jq '.items[] | select(.spec.containers[].securityContext.capabilities.add[] | select(. == "SYS_ADMIN" or . == "NET_ADMIN"))'
# Find pods with hostPath
kubectl get pods --all-namespaces -o json | \
jq '.items[] | select(.spec.volumes[]?.hostPath != null)'
| Control | Status | Notes |
|---|---|---|
| Anonymous auth disabled | ☐ | API server hardening |
| RBAC properly configured | ☐ | Least privilege |
| cluster-admin restricted | ☐ | Minimal bindings |
| etcd encrypted | ☐ | TLS + encryption at rest |
| Secrets encrypted at rest | ☐ | EncryptionConfiguration |
| Pod Security Standards | ☐ | Restricted policy |
| Security contexts applied | ☐ | Non-root, read-only, capabilities |
| Network policies enabled | ☐ | Default deny |
| Images from trusted sources | ☐ | Official registries only |
| Image versions pinned | ☐ | No latest tags |
| Image scanning enabled | ☐ | Trivy/Grype in CI/CD |
| Admission controllers | ☐ | OPA/Kyverno |
| External secrets | ☐ | Vault/AWS Secrets Manager |
| Audit logging enabled | ☐ | API server audit |
| Runtime security | ☐ | Falco installed |
latest tag - Pin versions/digestsCheck cluster security:
# Check API server access
kubectl auth can-i --list
# Check RBAC bindings
kubectl get clusterrolebindings --sort-by=.metadata.name
# Find privileged pods
kubectl get pods --all-namespaces -o json | \
jq '.items[] | select(.spec.containers[].securityContext.privileged == true) | .metadata.name'
# Check network policies
kubectl get networkpolicies --all-namespaces
# Check pod security contexts
kubectl get pods --all-namespaces -o json | \
jq '.items[] | {name: .metadata.name, namespace: .metadata.namespace, securityContext: .spec.securityContext}'
Run security scan:
# Run kube-bench
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml
kubectl logs job/kube-bench
# Run kube-hunter
kubectl run -it --rm kube-hunter --image=aquasec/kube-hunter --restart=Never -- --pod