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

ObjectScopeFunction
RoleNamespaceDefines permissions within a namespace
ClusterRoleClusterDefines cluster-wide or namespace-level permissions
RoleBindingNamespaceBinds Role/ClusterRole to users/groups/SAs
ClusterRoleBindingClusterBinds 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, use ClusterRole. Even for namespace-level permissions, the ClusterRole + RoleBinding combination 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

VerbMeaningRisk Level
getGet a single resourceLow
listList resourcesLow
watchWatch resource changesLow
createCreate resourcesMedium
updateUpdate entire resourceMedium
patchPartial updateMedium
deleteDelete resourcesHigh
deletecollectionBulk deleteHigh
*All operationsCritical

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:

  1. Multiple Pods sharing the same default SA makes fine-grained permission control impossible
  2. The default SA’s token is automatically mounted to /var/run/secrets/kubernetes.io/serviceaccount/, allowing application code to access the K8s API directly
  3. If any Pod is compromised, the attacker can use the default SA’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:

LevelDescriptionUse Case
privilegedNo restrictions, maximum privilegesSystem components, special cases
baselinePrevents known privilege escalations, allows some non-privileged configGeneral applications
restrictedStrictest, follows best security practicesProduction 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
ModeBehaviorStage
enforceViolating Pods rejectedHardening complete
auditAllowed but loggedDuring hardening migration
warnAllowed with warningDuring 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

ConstraintRequirementReason
privilegedMust be falsePrivileged container = host root
hostNetworkMust be falseCannot share host network stack
hostPIDMust be falseCannot view host processes
hostIPCMust be falseCannot share host IPC
hostPathProhibitedCannot mount host filesystem
runAsNonRootMust be trueNo root execution
runAsUserMust be > 0Non-root UID
capabilities.dropMust include ALLDrop all capabilities
seccompProfileMust be RuntimeDefault or LocalhostRestrict system calls
allowPrivilegeEscalationMust be falsePrevent 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

ConfigDefaultProduction RecommendationNotes
privilegedfalseMust be falsePrivileged container = host root
allowPrivilegeEscalationtrueMust be falsePrevent setuid escalation
runAsNonRootunsetMust be trueNo root execution
runAsUserunsetSpecify non-zero UIDExplicit running user
readOnlyRootFilesystemfalseRecommend trueRead-only root FS, reduces attack surface
capabilities.dropunsetdrop ALLDrop all capabilities
seccompProfileunsetRuntimeDefaultRestrict 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:

PathSolution
/tmpemptyDir + medium: Memory (tmpfs)
/var/logemptyDir or PVC
/app/cacheemptyDir
/app/uploadsPVC (needs persistence)

Linux Capabilities — Add as Needed

After dropping all capabilities, some applications may need specific capabilities:

CapabilityPurposeCommon Services
NET_BIND_SERVICEBind ports below 1024Nginx, HAProxy (binding 80/443)
CHOWNChange file ownershipInit containers
SETUID / SETGIDSwitch usersServices needing privilege drop
NET_RAWRaw network packetsNetwork diagnostic tools (use with caution)
SYS_PTRACETrace processesDebugging 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-all policy 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

LimitationNotes
Requires CNI supportCalico, Cilium support it; Flannel does not by default
L3/L4 onlyCan’t filter by URL path; need Service Mesh for that
No default denyNamespaces without NetworkPolicy are fully open
Namespace isolation requires labelsnamespaceSelector 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

LevelRecordsLog Volume
NoneNothingNone
MetadataRequest metadata (who/what/when)Small
RequestMetadata + request bodyMedium
RequestResponseMetadata + request body + response bodyLarge

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 default SA
  • Each workload has a dedicated ServiceAccount
  • SA token not auto-mounted by default (automountServiceAccountToken: false)
  • No SA with cluster-admin permissions
  • Regularly audit RBAC bindings, remove unnecessary permissions
  • CI/CD SA permissions scoped to deployment namespace

Pod Security

  • Production namespaces configured with restricted PSS
  • 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-all policy
  • 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:

  1. Step 1: Create dedicated SAs for all workloads, disable default token mounting. This is the lowest-cost, highest-impact security measure.
  2. Step 2: Configure PSS on namespaces. Start with warn + audit mode, collect violations, and fix them one by one.
  3. Step 3: Configure NetworkPolicies—default-deny-all first, then selectively allow. Verify CNI support first.
  4. Step 4: Enable audit logging, build monitoring and alerting for security events.
  5. Step 5: Regular security audits—use kubectl auth can-i --list to 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:

  1. Kubernetes Security Documentation — Kubernetes Official, referenced for Kubernetes Security Documentation