Overview
The default Kubernetes security posture can be summed up in one word: open. The default ServiceAccount has broad API access (depending on version and PSP configuration), Pods run as root, containers can mount the host filesystem, and the network is fully open. This “default open” design lowers the barrier to entry but creates significant security risks in production.
This article covers RBAC permission models, ServiceAccount management, Pod Security Standards, SecurityContext, NetworkPolicies, and audit logging—systematically explaining K8s security hardening practices.
Based on Kubernetes v1.30. Reference: Kubernetes Security Documentation
RBAC Permission Model
Four Core RBAC Objects
| Object | Scope | Function |
|---|---|---|
Role | Namespace | Defines permissions within a namespace |
ClusterRole | Cluster | Defines cluster-wide or namespace-level permissions |
RoleBinding | Namespace | Binds Role/ClusterRole to users/groups/SAs |
ClusterRoleBinding | Cluster | Binds ClusterRole to users/groups/SAs |
Core relationship:
User/Group/ServiceAccount ──Binding──→ Role/ClusterRole ──contains──→ Permission rules
Role and ClusterRole
# Role: namespace-level permissions, only affects specified namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: production
name: pod-reader
rules:
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get"]
resourceNames: ["app-config"] # Restrict access to specific ConfigMap
# ClusterRole: cluster-level permissions
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: node-reader
rules:
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "list", "watch"]
Rule of thumb: If permissions only involve namespace-scoped resources (Pod, Service, ConfigMap, etc.), use
Role; if they involve cluster-scoped resources (Node, Namespace, PV, etc.) or need reuse across namespaces, useClusterRole. Even for namespace-level permissions, theClusterRole+RoleBindingcombination is recommended for reusability.
RoleBinding and ClusterRoleBinding
# RoleBinding: binds ClusterRole to a ServiceAccount in a specific namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
namespace: production
name: pod-reader-binding
subjects:
- kind: ServiceAccount
name: myapp-sa
namespace: production
roleRef:
kind: ClusterRole # References ClusterRole, but scope is limited to production namespace by RoleBinding
name: pod-reader # ClusterRole name
apiGroup: rbac.authorization.k8s.io
# ClusterRoleBinding: cluster-wide binding
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: cluster-admin-binding
subjects:
- kind: User
name: admin@example.com
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
Verbs Explained
| Verb | Meaning | Risk Level |
|---|---|---|
get | Get a single resource | Low |
list | List resources | Low |
watch | Watch resource changes | Low |
create | Create resources | Medium |
update | Update entire resource | Medium |
patch | Partial update | Medium |
delete | Delete resources | High |
deletecollection | Bulk delete | High |
* | All operations | Critical |
Security red line: Never grant
*permissions to regular SAs in production. Even when broad permissions are needed, explicitly list the required verbs.
Common RBAC Configuration Patterns
Pattern 1: Read-only audit role
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: cluster-viewer
rules:
- apiGroups: ["", "apps", "batch", "extensions"]
resources: ["*"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/exec", "pods/portforward"]
verbs: [] # Explicitly deny exec and port forwarding
Pattern 2: CI/CD deployment role
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: production
name: deployer
rules:
- apiGroups: ["apps"]
resources: ["deployments", "replicasets"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch", "delete"] # Can delete Pods to trigger rolling updates
- apiGroups: [""]
resources: ["services", "configmaps", "secrets"]
verbs: ["get", "list", "watch"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch", "update", "patch"]
Pattern 3: Namespace administrator
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: team-a
name: namespace-admin
rules:
- apiGroups: ["*"]
resources: ["*"]
verbs: ["*"]
# Note: Role's * only affects the current namespace, not cluster-scoped resources
Using Impersonate for Permission Validation
Before granting RBAC permissions, use --as to impersonate the target user and verify permissions:
# Impersonate SA to verify permissions
kubectl auth can-i --list \
--as=system:serviceaccount:production:myapp-sa \
-n production
# Impersonate SA to verify a specific operation
kubectl auth can-i delete pods \
--as=system:serviceaccount:production:myapp-sa \
-n production
# Batch check sensitive permissions
for verb in create delete deletecollection patch update; do
for resource in pods secrets configmaps deployments; do
result=$(kubectl auth can-i $verb $resource \
--as=system:serviceaccount:production:myapp-sa \
-n production 2>/dev/null)
if [ "$result" = "yes" ]; then
echo "[WARN] myapp-sa can $verb $resource"
fi
done
done
ServiceAccount Management
Why Not Use the Default ServiceAccount
Every namespace has a default ServiceAccount. Pods that don’t specify an SA automatically use it. The problems:
- Multiple Pods sharing the same
defaultSA makes fine-grained permission control impossible - The
defaultSA’s token is automatically mounted to/var/run/secrets/kubernetes.io/serviceaccount/, allowing application code to access the K8s API directly - If any Pod is compromised, the attacker can use the
defaultSA’s token to operate the cluster
Create Dedicated SAs for Each Workload
apiVersion: v1
kind: ServiceAccount
metadata:
name: myapp-sa
namespace: production
annotations:
description: "MyApp backend service account"
automountServiceAccountToken: false # Set to false if the app doesn't need K8s API access
# Reference SA in Pod
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
template:
spec:
serviceAccountName: myapp-sa
automountServiceAccountToken: true # Override SA-level setting
containers:
- name: app
image: myapp:v1
ServiceAccount Token Management Evolution
Since K8s v1.24, ServiceAccount tokens are no longer automatically generated as Secrets. Instead, they’re created on-demand via the TokenRequest API with expiration times:
# Generate temporary token for SA (default 1 hour expiry)
kubectl create token myapp-sa -n production
# Specify duration
kubectl create token myapp-sa -n production --duration=24h
For long-lived tokens (e.g., CI/CD scenarios), manually create a Secret:
apiVersion: v1
kind: Secret
metadata:
name: myapp-sa-token
namespace: production
annotations:
kubernetes.io/service-account.name: myapp-sa
type: kubernetes.io/service-account-token
SA Permission Audit
# View all bindings for an SA
kubectl get rolebindings,clusterrolebindings -A -o json | \
jq '.items[] | select(.subjects[]? | select(.kind=="ServiceAccount" and .name=="myapp-sa" and .namespace=="production")) | .metadata.name'
# Check what the SA can do
kubectl auth can-i --list --as=system:serviceaccount:production:myapp-sa -n production
# Check for sensitive permissions
kubectl auth can-i --list --as=system:serviceaccount:production:myapp-sa -n production | \
grep -E "delete|exec|create.*secrets"
Pod Security Standards
Three Security Levels
Since K8s v1.25, Pod Security Standards (PSS) replaced the deprecated Pod Security Policies (PSP). PSS defines three security levels:
| Level | Description | Use Case |
|---|---|---|
privileged | No restrictions, maximum privileges | System components, special cases |
baseline | Prevents known privilege escalations, allows some non-privileged config | General applications |
restricted | Strictest, follows best security practices | Production applications |
Namespace-Level Configuration
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
# Enforce restricted policy in this namespace
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
# Audit mode: log violations but don't reject
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
# Warn mode: warn on violations
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
| Mode | Behavior | Stage |
|---|---|---|
enforce | Violating Pods rejected | Hardening complete |
audit | Allowed but logged | During hardening migration |
warn | Allowed with warning | During hardening migration |
Recommended migration strategy: Start with warn + audit to collect violation list → fix one by one → switch to enforce.
Restricted Level Requirements
The restricted level enforces the following configuration:
spec:
securityContext:
runAsNonRoot: true # Must run as non-root
runAsUser: 1000 # Must specify non-zero UID
fsGroup: 2000 # Must specify fsGroup
seccompProfile:
type: RuntimeDefault # Must use default seccomp profile
containers:
- name: app
securityContext:
allowPrivilegeEscalation: false # No privilege escalation
readOnlyRootFilesystem: true # Read-only root filesystem
runAsNonRoot: true
runAsUser: 1000
capabilities:
drop:
- ALL # Must drop all Linux capabilities
add:
- NET_BIND_SERVICE # Add as needed
Complete Restricted Level Constraints
| Constraint | Requirement | Reason |
|---|---|---|
privileged | Must be false | Privileged container = host root |
hostNetwork | Must be false | Cannot share host network stack |
hostPID | Must be false | Cannot view host processes |
hostIPC | Must be false | Cannot share host IPC |
hostPath | Prohibited | Cannot mount host filesystem |
runAsNonRoot | Must be true | No root execution |
runAsUser | Must be > 0 | Non-root UID |
capabilities.drop | Must include ALL | Drop all capabilities |
seccompProfile | Must be RuntimeDefault or Localhost | Restrict system calls |
allowPrivilegeEscalation | Must be false | Prevent setuid escalation |
SecurityContext
Pod-Level and Container-Level
SecurityContext can be configured at both Pod and Container levels. Container-level overrides Pod-level:
apiVersion: v1
kind: Pod
metadata:
name: secure-pod
spec:
securityContext:
# ===== Pod level =====
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 3000
fsGroup: 2000
fsGroupChangePolicy: "OnRootMismatch"
seccompProfile:
type: RuntimeDefault
sysctls:
- name: net.core.somaxconn
value: "4096"
containers:
- name: app
image: myapp:v1
securityContext:
# ===== Container level (overrides Pod level) =====
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
privileged: false
runAsNonRoot: true
runAsUser: 1000
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE
seccompProfile:
type: RuntimeDefault
volumeMounts:
- name: tmp
mountPath: /tmp # Read-only root FS needs writable directories
- name: cache
mountPath: /app/cache
volumes:
- name: tmp
emptyDir: {}
- name: cache
emptyDir: {}
Key Security Configuration Details
| Config | Default | Production Recommendation | Notes |
|---|---|---|---|
privileged | false | Must be false | Privileged container = host root |
allowPrivilegeEscalation | true | Must be false | Prevent setuid escalation |
runAsNonRoot | unset | Must be true | No root execution |
runAsUser | unset | Specify non-zero UID | Explicit running user |
readOnlyRootFilesystem | false | Recommend true | Read-only root FS, reduces attack surface |
capabilities.drop | unset | drop ALL | Drop all capabilities |
seccompProfile | unset | RuntimeDefault | Restrict system calls |
readOnlyRootFilesystem Considerations
When set to true, the container root filesystem is read-only. Directories the application needs to write to must be mounted as volumes:
containers:
- name: app
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
- name: logs
mountPath: /app/logs
- name: cache
mountPath: /app/cache
volumes:
- name: tmp
emptyDir:
medium: Memory # tmpfs, memory-backed
- name: logs
emptyDir: {}
- name: cache
emptyDir: {}
Common scenarios requiring writable directories:
| Path | Solution |
|---|---|
/tmp | emptyDir + medium: Memory (tmpfs) |
/var/log | emptyDir or PVC |
/app/cache | emptyDir |
/app/uploads | PVC (needs persistence) |
Linux Capabilities — Add as Needed
After dropping all capabilities, some applications may need specific capabilities:
| Capability | Purpose | Common Services |
|---|---|---|
NET_BIND_SERVICE | Bind ports below 1024 | Nginx, HAProxy (binding 80/443) |
CHOWN | Change file ownership | Init containers |
SETUID / SETGID | Switch users | Services needing privilege drop |
NET_RAW | Raw network packets | Network diagnostic tools (use with caution) |
SYS_PTRACE | Trace processes | Debugging tools |
NetworkPolicies
The Default All-Open Problem
K8s default network model is fully open: any Pod can access any Pod, any Pod can access any Service. In a microservices architecture, one compromised Pod means the attacker can scan the entire cluster’s internal network.
NetworkPolicy Basic Syntax
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {} # Select all Pods in namespace
policyTypes:
- Ingress
- Egress
# No ingress/egress rules defined = deny all
This is the most important security baseline: First create a
default-deny-allpolicy to deny all traffic, then selectively allow required traffic.
Whitelist Mode
# Allow frontend to access backend
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-backend
namespace: production
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
---
# Allow backend to access database
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-backend-to-db
namespace: production
spec:
podSelector:
matchLabels:
app: postgres
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: backend
ports:
- protocol: TCP
port: 5432
---
# Allow all Pods to access DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: production
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
NetworkPolicy Limitations
| Limitation | Notes |
|---|---|
| Requires CNI support | Calico, Cilium support it; Flannel does not by default |
| L3/L4 only | Can’t filter by URL path; need Service Mesh for that |
| No default deny | Namespaces without NetworkPolicy are fully open |
| Namespace isolation requires labels | namespaceSelector depends on namespace labels |
# Label namespaces
kubectl label namespace kube-system kubernetes.io/metadata.name=kube-system
Allowing Egress to External Services
# Allow backend Pods to access external database
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-external-db
namespace: production
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 10.0.5.100/32 # External database IP
ports:
- protocol: TCP
port: 5432
- to:
- namespaceSelector: {} # Allow access to all namespaces (for kube-dns, etc.)
ports:
- protocol: UDP
port: 53
Audit Logging
Enabling Audit Logs
# /etc/kubernetes/audit/audit-policy.yaml
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages:
- RequestReceived
rules:
# Don't log get/list/watch from system components
- level: None
users: ["system:apiserver", "system:kube-controller-manager", "system:kube-scheduler"]
verbs: ["get", "list", "watch"]
# Log Secret operations (full request/response body)
- level: RequestResponse
resources:
- group: ""
resources: ["secrets"]
# Log all write operations
- level: Request
verbs: ["create", "update", "patch", "delete"]
# Log auth-related operations
- level: Metadata
resources:
- group: "rbac.authorization.k8s.io"
# Default: log metadata only
- level: Metadata
Audit Log Levels
| Level | Records | Log Volume |
|---|---|---|
None | Nothing | None |
Metadata | Request metadata (who/what/when) | Small |
Request | Metadata + request body | Medium |
RequestResponse | Metadata + request body + response body | Large |
Configuring API Server
# /etc/kubernetes/manifests/kube-apiserver.yaml
spec:
containers:
- name: kube-apiserver
command:
- kube-apiserver
- --audit-policy-file=/etc/kubernetes/audit/audit-policy.yaml
- --audit-log-path=/var/log/kubernetes/audit/audit.log
- --audit-log-maxage=30
- --audit-log-maxbackup=10
- --audit-log-maxsize=100
- --audit-log-format=json
volumeMounts:
- mountPath: /etc/kubernetes/audit
name: audit-policy
readOnly: true
- mountPath: /var/log/kubernetes/audit
name: audit-log
volumes:
- name: audit-policy
hostPath:
path: /etc/kubernetes/audit
type: Directory
- name: audit-log
hostPath:
path: /var/log/kubernetes/audit
type: DirectoryOrCreate
Audit Log Analysis
# Find who deleted a Pod
cat /var/log/kubernetes/audit/audit.log | \
jq 'select(.verb=="delete" and .objectRef.resource=="pods") | \
{user: .user.username, ns: .objectRef.namespace, pod: .objectRef.name, time: .requestReceivedTimestamp}'
# Find who accessed Secrets
cat /var/log/kubernetes/audit/audit.log | \
jq 'select(.objectRef.resource=="secrets") | \
{user: .user.username, ns: .objectRef.namespace, secret: .objectRef.name, verb: .verb}'
# Count API calls per user
cat /var/log/kubernetes/audit/audit.log | \
jq -r '.user.username' | sort | uniq -c | sort -rn
# Find failed requests
cat /var/log/kubernetes/audit/audit.log | \
jq 'select(.responseStatus.code >= 400) | \
{user: .user.username, verb: .verb, resource: .objectRef.resource, code: .responseStatus.code}'
Security Hardening Checklist
RBAC
- No workloads using
defaultSA - Each workload has a dedicated ServiceAccount
- SA token not auto-mounted by default (
automountServiceAccountToken: false) - No SA with
cluster-adminpermissions - Regularly audit RBAC bindings, remove unnecessary permissions
- CI/CD SA permissions scoped to deployment namespace
Pod Security
- Production namespaces configured with
restrictedPSS - All Pods run as non-root user
- All containers have
allowPrivilegeEscalation: false - All containers have
readOnlyRootFilesystem: true - All containers have
capabilities.drop: [ALL] - seccompProfile configured
NetworkPolicies
- Each namespace has
default-deny-allpolicy - Traffic allowed in whitelist mode
- DNS traffic allowed
- CNI supports NetworkPolicy
Audit and Monitoring
- API Server audit logging enabled
- Secret operations recorded in audit logs
- Audit logs regularly archived and analyzed
- Alerts for anomalous API calls
Summary
K8s security hardening is not a one-time task but an ongoing process. The core principle is Principle of Least Privilege—any identity (SA, user) should only be granted the minimum permissions needed to function.
Recommended implementation path:
- Step 1: Create dedicated SAs for all workloads, disable default token mounting. This is the lowest-cost, highest-impact security measure.
- Step 2: Configure PSS on namespaces. Start with
warn+auditmode, collect violations, and fix them one by one. - Step 3: Configure NetworkPolicies—
default-deny-allfirst, then selectively allow. Verify CNI support first. - Step 4: Enable audit logging, build monitoring and alerting for security events.
- Step 5: Regular security audits—use
kubectl auth can-i --listto check permission creep, clean up redundant permissions.
Security has no silver bullet, but each layer of defense raises the attacker’s cost. The core idea of Defense in Depth: don’t rely on a single security mechanism. RBAC, PSS, NetworkPolicy, and audit logs each serve their purpose, together forming a layered defense system.
References & Acknowledgments
This article referenced the following materials during writing. We thank the original authors for their contributions:
- Kubernetes Security Documentation — Kubernetes Official, referenced for Kubernetes Security Documentation