Security

Pod Security Standards & Admission

● Advanced ⏱ 15 min read

PodSecurityPolicy was deprecated in Kubernetes 1.21 and removed in 1.25. Its replacement — Pod Security Admission (PSA) with Pod Security Standards (PSS) — ships built-in to every cluster as an admission controller. No webhook, no CRD, no third-party install required. Three levels, three enforcement modes, and a single kubectl label to activate them.

Why PSS Replaced PSP

PodSecurityPolicy had a fundamental usability problem: granting a PSP to a namespace required an RBAC binding — and the binding had to be to the ServiceAccount that created the pod (often a controller like the Deployment controller), not the pod's own ServiceAccount. This confused everyone, required extra RBAC grants, and made auditing hard.

PSA fixes this by operating at the namespace level with no RBAC entanglement. You label a namespace with the desired security level; the API server evaluates every pod creation against that level automatically.

Security Levels

PSS defines three levels, from most permissive to most restrictive:

LevelIntentNotable restrictions
privileged No restrictions. Equivalent to no policy.
baseline Prevents known privilege escalations. Minimal impact on typical workloads. No hostPID/hostIPC/hostNetwork, no privileged containers, limited capabilities, restricted volume types, no host path mounts.
restricted Strongly hardened. Current best practice for security-sensitive workloads. Everything in baseline, plus: must run as non-root, must drop ALL capabilities, seccomp profile required, no privilege escalation allowed.
Pod Security Standards — level comparison
PRIVILEGED
  • ✅ hostPID
  • ✅ hostNetwork
  • ✅ privileged containers
  • ✅ any capabilities
  • ✅ run as root
  • ✅ any volume type
Use: system namespaces only
BASELINE
  • ❌ hostPID/hostNetwork
  • ❌ privileged containers
  • ❌ hostPath volumes
  • ✅ can run as root
  • ✅ default capabilities
  • ✅ emptyDir, PVC, CM, secret
Use: most app namespaces
RESTRICTED
  • ❌ everything in baseline
  • ❌ run as root
  • ❌ privilege escalation
  • ❌ capabilities (must drop ALL)
  • ⚠️ seccomp required
  • ⚠️ runAsNonRoot required
Use: security-sensitive workloads
Three PSS levels. Privileged for system infrastructure, baseline for typical apps, restricted for security-sensitive workloads.

Enforcement Modes

Each level can be applied in three independent modes:

ModeEffectWhen to use
enforceViolating pods are rejected at creation time.Production enforcement after testing.
auditViolations are recorded in the audit log but pods are allowed.Monitoring impact without blocking.
warnkubectl prints a warning for violating pods but allows them.Developer feedback during migration.

Run all three simultaneously during migration. Set warn and audit to restricted to surface issues, while keeping enforce at baseline so nothing breaks yet.

Namespace Labels

Activation is purely via namespace labels. The label format is pod-security.kubernetes.io/<mode>: <level> with an optional version pin.

namespace labels — production hardening
apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    # Block pods that violate baseline
    pod-security.kubernetes.io/enforce: baseline
    pod-security.kubernetes.io/enforce-version: v1.31

    # Warn devs about anything that would fail restricted
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: v1.31

    # Audit violations of restricted in the API server audit log
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: v1.31
apply labels imperatively
# Dry-run to preview violations before enforcing
kubectl label --dry-run=server --overwrite ns production \
  pod-security.kubernetes.io/enforce=restricted

# Enforce baseline, warn at restricted
kubectl label --overwrite ns production \
  pod-security.kubernetes.io/enforce=baseline \
  pod-security.kubernetes.io/warn=restricted
💡
Pin the version

The -version label pins the policy to a specific Kubernetes version's definition of that level. Without it, the policy upgrades automatically when you upgrade the cluster. Pinning prevents silent policy tightening mid-release.

securityContext Fields

To pass the restricted level, pods and containers need explicit securityContext fields. The check is at the pod/container spec level — the admission controller does not auto-inject defaults.

deployment passing restricted level
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: production
spec:
  template:
    spec:
      securityContext:
        runAsNonRoot: true          # required by restricted
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
        seccompProfile:
          type: RuntimeDefault      # required by restricted (or Localhost)
      containers:
      - name: api
        image: myapp:1.0.0
        securityContext:
          allowPrivilegeEscalation: false   # required by restricted
          readOnlyRootFilesystem: true       # best practice
          capabilities:
            drop: ["ALL"]                   # required by restricted
            add: []                         # add back only what's needed
        resources:
          requests: { cpu: "100m", memory: "128Mi" }
          limits: { cpu: "500m", memory: "256Mi" }
⚠️
initContainers and ephemeral containers are also checked

PSA evaluates the security context of every container in the pod spec — including initContainers and ephemeralContainers. A debug sidecar running as root will cause the whole pod to be rejected under restricted enforcement.

Exemptions

Some system workloads legitimately need elevated privileges (e.g. CNI DaemonSets, log collectors). Exemptions are configured globally in the API server's AdmissionConfiguration, not per-namespace:

AdmissionConfiguration — cluster-level exemptions
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
- name: PodSecurity
  configuration:
    apiVersion: pod-security.admission.config.k8s.io/v1
    kind: PodSecurityConfiguration
    defaults:
      enforce: "baseline"            # cluster-wide default
      enforce-version: "latest"
      audit: "restricted"
      audit-version: "latest"
      warn: "restricted"
      warn-version: "latest"
    exemptions:
      usernames: []
      runtimeClasses: []
      namespaces:
      - kube-system                  # system namespace exempted entirely
      - calico-system
      - monitoring

Migrating from PSP

PodSecurityPolicy was removed in Kubernetes 1.25. If you are upgrading a cluster that used PSP, the migration path is:

  1. Inventory existing PSPskubectl get psp and map each policy to the closest PSS level.
  2. Add warn labels — label each namespace with pod-security.kubernetes.io/warn: restricted and observe warnings without blocking.
  3. Fix violations — update Deployments to set runAsNonRoot, drop capabilities, and add seccompProfile.
  4. Add audit labels — direct evidence to API server audit logs.
  5. Switch to enforce — start with baseline, then tighten to restricted namespace by namespace.
  6. Delete PSPs and their RBAC — after enforcement is live and stable.
quick PSP inventory before migration
# List all PSPs and their key settings
kubectl get psp -o custom-columns=\
'NAME:.metadata.name,PRIV:.spec.privileged,RUN_AS:.spec.runAsUser.rule,CAPS:.spec.allowedCapabilities'

# Find all pods using a specific PSP
kubectl get pods -A \
  -o json | jq '.items[] |
    select(.metadata.annotations["kubernetes.io/psp"] == "restricted") |
    "\(.metadata.namespace)/\(.metadata.name)"'

kubectl Commands

# Check current PSA labels on all namespaces
kubectl get ns -o json | jq -r '
  .items[] |
  select(.metadata.labels | has("pod-security.kubernetes.io/enforce")) |
  "\(.metadata.name): \(.metadata.labels["pod-security.kubernetes.io/enforce"])"'

# Dry-run enforce restricted — lists violations without blocking
kubectl label --dry-run=server --overwrite ns mynamespace \
  pod-security.kubernetes.io/enforce=restricted

# Check what level a namespace is at
kubectl describe ns production | grep pod-security

# Simulate PSA for an existing workload by extracting and reapplying
kubectl get deploy myapp -n production -o yaml | \
  kubectl apply --dry-run=server -f -