How to Configure PodSecurityPolicies in Kubernetes

How to Configure PodSecurityPolicies in Kubernetes

Let me start with something important that a lot of tutorials gloss over: PodSecurityPolicy (PSP) was deprecated in Kubernetes 1.21 and completely removed in Kubernetes 1.25. If you’re running a modern EKS cluster, you cannot use PSPs anymore — they simply don’t exist in the API. But I’m still going to walk through them thoroughly here because you’ll encounter them in legacy clusters, in older documentation, in job interviews, and because understanding why they were replaced tells you a lot about how Kubernetes security has matured. Then I’ll show you exactly what to use instead.

What PodSecurityPolicy Was

PodSecurityPolicy was a cluster-level resource that controlled security-sensitive aspects of pod specification. It acted as an admission controller: before a pod was allowed to be created, the PSP admission plugin checked the pod spec against a set of PSP objects and either allowed it, mutated defaults into it, or rejected it outright.

A PSP could control things like:

  • Whether containers could run as root
  • Whether privileged containers were allowed
  • Which Linux capabilities could be added or must be dropped
  • Which volume types were permitted
  • Whether the host network, host PID, or host IPC namespaces could be used
  • What SELinux, AppArmor, or seccomp profiles were required
  • Read-only root filesystem enforcement

A Legacy PSP Example (For Reference Only)

If you’re maintaining a cluster still on Kubernetes 1.20 or earlier, here’s what a restrictive PSP looked like:

apiVersion: policy/v1beta1
kind: PodSecurityPolicy
metadata:
  name: restricted
spec:
  privileged: false
  allowPrivilegeEscalation: false
  requiredDropCapabilities:
    - ALL
  volumes:
    - configMap
    - emptyDir
    - projected
    - secret
    - downwardAPI
    - persistentVolumeClaim
  hostNetwork: false
  hostIPC: false
  hostPID: false
  runAsUser:
    rule: MustRunAsNonRoot
  seLinux:
    rule: RunAsAny
  supplementalGroups:
    rule: MustRunAs
    ranges:
      - min: 1
        max: 65535
  fsGroup:
    rule: MustRunAs
    ranges:
      - min: 1
        max: 65535
  readOnlyRootFilesystem: true

This alone didn’t do anything — PSPs required binding to users or service accounts via RBAC, which was one of the biggest sources of confusion and misconfiguration:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: psp-restricted
rules:
  - apiGroups: ['policy']
    resources: ['podsecuritypolicies']
    verbs: ['use']
    resourceNames:
      - restricted
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: psp-restricted-binding
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: psp-restricted
subjects:
  - kind: Group
    apiGroup: rbac.authorization.k8s.io
    name: system:authenticated

This double-layer (PSP object + RBAC binding) was precisely why PSPs were removed — the UX was genuinely confusing, policies were evaluated non-deterministically when multiple PSPs matched a user, and there was no dry-run or good auditing story.

What Replaced PodSecurityPolicy

Kubernetes now ships Pod Security Admission (PSA), a built-in admission controller that applies Pod Security Standards at the namespace level via labels — no separate policy object, no RBAC binding gymnastics. This is what you should actually be configuring on any current EKS cluster.

The Three Pod Security Standards

  1. Privileged — unrestricted, effectively no policy
  2. Baseline — blocks known privilege escalations while allowing common patterns
  3. Restricted — heavily locked down, follows current pod hardening best practices

Applying Pod Security Standards via Namespace Labels

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

Apply it:

kubectl apply -f production-namespace.yaml

The three modes — enforce, warn, and audit — let you test policies without breaking workloads immediately. warn returns a user-facing warning on kubectl apply but still allows the pod; audit annotates the audit log; enforce actually rejects non-compliant pods.

Testing Before Enforcing

A very practical rollout pattern: set warn and audit to restricted first, watch your audit logs and CI pipelines for warnings over a week or two, fix violations, and only then flip enforce to restricted.

kubectl label --overwrite ns production \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/audit=restricted

Then try creating a non-compliant pod to see the warning:

kubectl run test-pod --image=nginx --namespace=production --dry-run=server

If it violates restricted, you’ll see output like:

Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile

A Compliant Pod Spec Under “Restricted”

apiVersion: v1
kind: Pod
metadata:
  name: secure-app
  namespace: production
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: app
      image: myapp:1.0
      securityContext:
        allowPrivilegeEscalation: false
        capabilities:
          drop:
            - ALL
        readOnlyRootFilesystem: true
      resources:
        requests:
          cpu: 100m
          memory: 128Mi
        limits:
          cpu: 250m
          memory: 256Mi

Third-Party Policy Engines for Advanced Cases

Pod Security Admission covers the standard hardening baseline, but it’s intentionally not extensible — it enforces fixed standards, not arbitrary custom rules. For organization-specific policy (e.g., “all images must come from our internal ECR registry,” or “all Deployments must have resource limits set”), you need a policy engine. The two dominant choices in the CNCF ecosystem are:

OPA Gatekeeper

helm repo add gatekeeper https://open-policy-agent.github.io/gatekeeper/charts
helm install gatekeeper gatekeeper/gatekeeper --namespace gatekeeper-system --create-namespace

Example constraint template requiring specific labels:

apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata:
  name: require-team-label
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
  parameters:
    labels: ["team", "environment"]

Kyverno

Kyverno is often preferred because policies are written as plain Kubernetes YAML rather than Rego:

helm repo add kyverno https://kyverno.github.io/kyverno/
helm install kyverno kyverno/kyverno --namespace kyverno --create-namespace
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-image-registry
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-registry
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        message: "Images must come from our approved ECR registry"
        pattern:
          spec:
            containers:
              - image: "123456789012.dkr.ecr.us-east-1.amazonaws.com/*"

EKS-Specific Considerations

On EKS, Pod Security Admission is enabled by default from Kubernetes 1.23 onward — there’s nothing extra to install for the built-in standards. What you need to add is a policy engine (Gatekeeper or Kyverno) for custom guardrails, and you’ll likely combine this with:

  • IAM Roles for Service Accounts (IRSA) or EKS Pod Identity to control AWS permission scope per workload
  • Security groups for pods if you need network-level isolation beyond Kubernetes NetworkPolicies
  • AWS Config rules or Security Hub for cluster-level compliance auditing outside the cluster itself

Comparing the Three Pod Security Standards in Practice

It helps to see what each standard actually blocks, side by side, since the documentation can be abstract about it:

ControlPrivilegedBaselineRestricted
Privileged containersAllowedBlockedBlocked
Host namespaces (hostNetwork, hostPID, hostIPC)AllowedBlockedBlocked
Host path volumesAllowedBlockedBlocked
allowPrivilegeEscalationAllowedAllowedMust be false
Running as rootAllowedAllowedMust be non-root
Linux capabilitiesUnrestrictedMust drop ALL besides a small allow-listMust drop ALL, none may be added back
Seccomp profileNot requiredRuntimeDefault or Localhost requiredSame as baseline
Volume typesAnyRestricted to a safe subsetSame as baseline

baseline is a good default for most internal workloads — it blocks the genuinely dangerous stuff (privileged containers, host namespace access) without demanding every image be rebuilt to run as non-root. restricted is what you want for anything customer-facing, anything handling sensitive data, or any namespace where you can’t fully vouch for every image running in it.

Handling Legacy Images That Need Root

A very common real-world snag: enforcing restricted against a namespace running an older off-the-shelf image (a legacy database sidecar, a vendor-provided scanner) that was built assuming it starts as root and drops privileges internally. You have three real options: patch the image to add a non-root USER directive, run it in its own baseline-only namespace isolated from more sensitive workloads, or, as a last resort, grant it an explicit Kyverno exception scoped narrowly to that one Deployment rather than loosening the whole namespace’s enforcement level.

apiVersion: kyverno.io/v1
kind: PolicyException
metadata:
  name: legacy-scanner-exception
  namespace: production
spec:
  exceptions:
    - policyName: require-non-root
      ruleNames:
        - check-runasnonroot
  match:
    any:
      - resources:
          kinds:
            - Pod
          names:
            - legacy-scanner-*

Scoped exceptions like this are far preferable to weakening the namespace-wide enforce label, since they keep the blast radius of the exception limited to a named, auditable resource.

Best Practices

  • Default new namespaces to at least baseline, and push toward restricted for anything customer-facing.
  • Use warn and audit before enforce — never flip straight to enforcement in production without a dry-run period.
  • Combine PSA (mandatory baseline hardening) with Kyverno or Gatekeeper (custom business rules) — they’re complementary, not competing.
  • Don’t try to reintroduce PSP-style behavior through hacks; the ecosystem has moved on and doing so creates maintenance debt.
  • Audit existing workloads before enforcing anything — running restricted in enforce mode against an unaudited legacy namespace will break deployments.

Common Mistakes

  • Assuming PSP still exists on a current EKS cluster and wondering why kubectl apply on a PSP manifest fails with an unrecognized API error.
  • Setting enforce: restricted cluster-wide on day one without testing, causing a wave of failed deployments.
  • Confusing Pod Security Admission (built-in labels) with Pod Security Policy (the deprecated object) — they solve overlapping problems but are architecturally unrelated.
  • Forgetting runAsNonRoot: true still requires the container image to actually support running as a non-root UID — enforcing security context alone doesn’t fix an image built to require root.

Disaster Recovery and Migration Notes

If you’re migrating an old cluster off PSPs, AWS and the Kubernetes community both provide the pod-security-migration tooling patterns: audit current PSP bindings, translate intent into equivalent PSA namespace labels plus Kyverno/Gatekeeper policies, roll out in warn/audit mode, then enforce. Never do a hard cutover during a cluster upgrade that also removes the PSP API — test the migration in a staging cluster first, since a botched migration can leave namespaces with no enforced policy at all, silently reducing your security posture.

Summary

PodSecurityPolicy is gone — removed in Kubernetes 1.25 — replaced by Pod Security Admission for baseline hardening and policy engines like Kyverno or OPA Gatekeeper for custom rules. On EKS, PSA is available out of the box via namespace labels (enforce, warn, audit), and it’s genuinely simpler and safer than the old PSP model. If you’re still running PSPs, plan your migration now — the gap between “still works” and “actively vulnerable because nothing enforces policy” is exactly one Kubernetes upgrade away.

References

Total
0
Shares

Leave a Reply

Previous Post
How to Manage Namespaces in Kubernetes

How to Manage Namespaces in Kubernetes

Next Post
How to Use DaemonSets in Kubernetes

How to Use DaemonSets in Kubernetes

Related Posts