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
- Privileged — unrestricted, effectively no policy
- Baseline — blocks known privilege escalations while allowing common patterns
- 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:
| Control | Privileged | Baseline | Restricted |
|---|---|---|---|
| Privileged containers | Allowed | Blocked | Blocked |
Host namespaces (hostNetwork, hostPID, hostIPC) | Allowed | Blocked | Blocked |
| Host path volumes | Allowed | Blocked | Blocked |
allowPrivilegeEscalation | Allowed | Allowed | Must be false |
| Running as root | Allowed | Allowed | Must be non-root |
| Linux capabilities | Unrestricted | Must drop ALL besides a small allow-list | Must drop ALL, none may be added back |
| Seccomp profile | Not required | RuntimeDefault or Localhost required | Same as baseline |
| Volume types | Any | Restricted to a safe subset | Same 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 towardrestrictedfor anything customer-facing. - Use
warnandauditbeforeenforce— 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
restrictedin 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 applyon a PSP manifest fails with an unrecognized API error. - Setting
enforce: restrictedcluster-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: truestill 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.