Pod Security Admission & Workload Hardening

PodSecurityPolicy is gone. What replaced it, what the three built-in standards actually enforce, and how to harden a pod spec so a container compromise stays inside the container.

advanced 19 min lesson hands-on task included

PodSecurityPolicy was removed in Kubernetes 1.25. If your reference material still teaches PSP, it is describing a resource that no longer exists. Its replacement is simpler, built in, and enforced by namespace labels.


Topic 1: Why PSP Died

PodSecurityPolicy was deprecated in 1.21 and removed in 1.25. Its problems were structural:

  • Authorisation by RBAC on the policy object — which PSP applied depended on which ones the creating user could use, and when several matched, an ordering algorithm nobody could predict picked one.
  • Mutation. PSPs could silently modify pods, so what you applied was not what ran.
  • Impossible to roll out safely. Enabling the admission plugin with no policies blocked every pod in the cluster.
  • It applied to the pod creator, but pods are usually created by controllers — so the effective identity was a ServiceAccount, not a human, and reasoning about it was near-impossible.

Pod Security Admission (PSA) replaced it in 1.25: built into the API server, configured with namespace labels, no mutation, and completely predictable.


Topic 2: The Three Standards

PSA implements the Pod Security Standards — three fixed profiles, not custom policies.

ProfileBlocksFor
privilegedNothingSystem components, CNI, CSI
baselineKnown privilege escalationsMost application workloads
restrictedBaseline + enforces hardeningAnything you can make work

baseline rejects: privileged: true, host namespaces (hostNetwork, hostPID, hostIPC), hostPath volumes, host ports, adding capabilities beyond a small allowed set, /proc mount tricks, unsafe sysctls, and non-default AppArmor/SELinux/seccomp overrides.

restricted additionally requires:

  • runAsNonRoot: true
  • allowPrivilegeEscalation: false
  • capabilities.drop: ["ALL"]
  • seccompProfile.type: RuntimeDefault (or Localhost)
  • Only projected/ephemeral volume types

Topic 3: Applying It

apiVersion: v1
kind: Namespace
metadata:
  name: payments
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: v1.36
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted

Three modes, and using all three is the point:

ModeEffect
enforceReject violating pods
auditAllow, but record a violation in the audit log
warnAllow, but return a warning to the user on apply

The safe adoption path uses them together:

    pod-security.kubernetes.io/enforce: baseline     # what you enforce TODAY
    pod-security.kubernetes.io/warn: restricted      # what you WILL enforce
    pod-security.kubernetes.io/audit: restricted

Teams see warnings for months while you collect audit data, and nothing breaks. When the warnings stop, raise enforce to restricted.

Pin enforce-version. Without it, the profile tracks the cluster version, so upgrading Kubernetes can silently tighten policy and start rejecting pods that were fine yesterday.

kubectl label namespace payments pod-security.kubernetes.io/enforce=restricted
kubectl label namespace payments pod-security.kubernetes.io/warn=restricted

# Dry-run the whole namespace against a profile BEFORE enforcing
kubectl label --dry-run=server --overwrite ns payments \
  pod-security.kubernetes.io/enforce=restricted
# Warning: existing pods in namespace "payments" violate the new PodSecurity level
#   "restricted:latest": api-7d9f (container "api" must set securityContext...)

That server-side dry run is the single most useful command here — it lists every existing pod that would break, without changing anything.

PSA applies at pod creation only. Running pods are never evicted by a label change, and a Deployment whose pods now violate policy keeps running until its next rollout — at which point it fails to create pods, and the rollout stalls. That delay between labelling and breaking is worth planning for.


Topic 4: A Compliant Pod Spec

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  template:
    spec:
      serviceAccountName: api-sa
      automountServiceAccountToken: false
      securityContext:                       # POD level
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: api
          image: registry.example.com/api:1.4.2@sha256:abc123...
          securityContext:                   # CONTAINER level — wins over pod level
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: cache
              mountPath: /var/cache
      volumes:
        - name: tmp
          emptyDir: {}
        - name: cache
          emptyDir: {}

Field by field, and why:

runAsNonRoot: true + runAsUser — a container process running as UID 0 that escapes the container is root on the node. Note the image must actually work as a non-root user; if its USER is root and you set runAsNonRoot, the container fails to start with CreateContainerConfigError.

allowPrivilegeEscalation: false — sets no_new_privs, so setuid binaries cannot gain privilege. This blocks a whole class of escalation inside the container.

readOnlyRootFilesystem: true — an attacker cannot write a payload to disk. Requires mounting emptyDir at every path the app genuinely writes to (/tmp almost always).

capabilities.drop: ["ALL"] — containers get a default capability set including CHOWN, SETUID, NET_RAW and others. NET_RAW alone permits ARP spoofing from a compromised pod. Drop everything and add back only what fails:

            capabilities:
              drop: ["ALL"]
              add: ["NET_BIND_SERVICE"]     # only to bind ports below 1024

Better still, do not bind a low port — listen on 8080 and let the Service map 80 to it.

seccompProfile: RuntimeDefault — restricts the syscalls available to the container to the runtime’s curated set, which blocks many kernel exploits. It is the default for restricted and has near-zero compatibility cost.

Pin by digest (@sha256:...) for anything security-sensitive: a tag can be repointed at different content, a digest cannot.


Topic 5: What PSA Cannot Do, and What Fills the Gap

PSA enforces three fixed profiles. It cannot express:

  • “Images must come from our registry"
  • "Every pod must have resource limits"
  • "Every workload must carry a team label"
  • "No :latest tags”

For those you need a policy engine as a validating admission webhook:

ToolLanguageNote
KyvernoYAMLKubernetes-native; can also mutate and generate. Easiest to adopt
OPA GatekeeperRegoVery expressive; steeper learning curve
Validating Admission PolicyCEL, built-in, GA in 1.30No webhook to run — see below
# Kyverno: require resource limits
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-limits
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-limits
      match:
        any:
          - resources: { kinds: [Pod] }
      validate:
        message: "Every container must set resource limits."
        pattern:
          spec:
            containers:
              - resources:
                  limits:
                    memory: "?*"
                    cpu: "?*"

ValidatingAdmissionPolicy (GA 1.30) is worth knowing because it removes the webhook entirely — the rule is evaluated in-process by the API server using CEL:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: require-limits
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: [""], apiVersions: ["v1"], operations: ["CREATE","UPDATE"], resources: ["pods"]
  validations:
    - expression: "object.spec.containers.all(c, has(c.resources.limits))"
      message: "all containers must set resource limits"

No webhook means no availability risk from the policy engine itself — which, as the next lesson covers, is a real failure mode.


Topic 6: Hardening Beyond the Pod Spec

Images:

  • Distroless or scratch bases — no shell means no shell for an attacker either.
  • Multi-stage builds so build tools never ship.
  • Scan in CI (Trivy, Grype) and fail the build on critical CVEs.
  • Sign and verify (cosign, Sigstore) with an admission policy that rejects unsigned images.

Runtime:

  • NET_RAW dropped stops ARP spoofing and most packet crafting.
  • Block the metadata endpoint (169.254.169.254) in NetworkPolicy — see the NetworkPolicy lesson.
  • Falco or Tetragon for runtime detection of unexpected syscalls, exec into containers, or writes to sensitive paths.

Supply chain: generate an SBOM per image, and track which clusters run which digests. When the next widely-exploited CVE lands, the question “are we running it?” should take minutes, not days.

A useful audit:

# Privileged containers anywhere
kubectl get pods -A -o json | jq -r '.items[] |
  select(.spec.containers[]?.securityContext.privileged == true) |
  "\(.metadata.namespace)/\(.metadata.name)"'

# Pods running as root (or not explicitly non-root)
kubectl get pods -A -o json | jq -r '.items[] |
  select((.spec.securityContext.runAsNonRoot // false) != true) |
  "\(.metadata.namespace)/\(.metadata.name)"'

# Which namespaces have no PSA labels at all
kubectl get ns -o json | jq -r '.items[] |
  select((.metadata.labels // {}) | has("pod-security.kubernetes.io/enforce") | not) |
  .metadata.name'

Try it yourself: Label a namespace enforce=restricted and apply a stock nginx Deployment. It will be rejected — nginx runs as root and binds port 80. Work through the rejection message until it is admitted (hint: nginxinc/nginx-unprivileged exists precisely for this).

Common mistake: Applying enforce=restricted to a namespace with running workloads and assuming nothing happened because nothing broke. Existing pods are untouched; the breakage arrives at the next rollout, possibly weeks later, in an unrelated change. Use the server-side dry run first, and set warn before enforce.