How to Set Up Pod Security Policies in Kubernetes

How to Set Up Pod Security Policies in Kubernetes

If there’s one topic in Kubernetes security that trips people up through pure naming confusion, it’s this one. PodSecurityPolicy (PSP) was removed from Kubernetes entirely as of v1.25. Anyone setting up cluster security today needs to know both the history and the replacement, because a lot of tutorials and Stack Overflow answers still reference the old API and will simply fail on a modern cluster. This article covers what PSPs were, why they’re gone, and exactly what to use instead.

A Short History: What PodSecurityPolicy Was

PodSecurityPolicy was a cluster-level resource that constrained what a Pod spec was allowed to request — things like running as root, using host networking, mounting host paths, or gaining privileged access. It was admission-controlled: the PSP admission controller checked incoming Pod specs against the policies bound to the requesting user/ServiceAccount via RBAC, and rejected non-compliant Pods.

A PSP looked like this (for historical reference only — this API no longer exists on current clusters):

apiVersion: policy/v1beta1
kind: PodSecurityPolicy
metadata:
  name: restricted
spec:
  privileged: false
  allowPrivilegeEscalation: false
  runAsUser:
    rule: MustRunAsNonRoot
  seLinux:
    rule: RunAsAny
  volumes:
    - configMap
    - secret
    - emptyDir
    - persistentVolumeClaim

It was deprecated in Kubernetes 1.21 and fully removed in 1.25. The reasons were well documented by the project: the binding model (via RBAC, indirectly, based on which policy a user could “use”) was confusing, hard to reason about, and made it easy to accidentally grant broader access than intended.

What Replaced It: Pod Security Admission (PSA)

Kubernetes 1.25+ ships Pod Security Admission, a built-in admission controller that enforces the Pod Security Standards — three predefined levels: privileged, baseline, and restricted. Unlike PSP, PSA is configured per-namespace via labels, not via a separate custom resource bound through RBAC.

kubectl label namespace production \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/enforce-version=latest \
  pod-security.kubernetes.io/audit=restricted \
  pod-security.kubernetes.io/warn=restricted

This single command does what previously required a PSP object, an RBAC Role, and a RoleBinding.

The Three Pod Security Standards

For most application namespaces, restricted is the correct target.

Applying Restricted Standards in Practice

A Pod that will actually pass restricted enforcement needs a securityContext designed for it:

apiVersion: v1
kind: Pod
metadata:
  name: secure-app
  namespace: production
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: app
      image: registry.example.com/myapp:1.0.0
      securityContext:
        allowPrivilegeEscalation: false
        capabilities:
          drop: ["ALL"]
        readOnlyRootFilesystem: true
      ports:
        - containerPort: 8080

Test it against the enforced standard:

kubectl apply -f secure-app.yaml -n production

If a Pod violates the standard, the API server rejects it outright with a message like:

Error from server (Forbidden): error when creating "secure-app.yaml":
pods "secure-app" is forbidden: violates PodSecurity "restricted:latest":
allowPrivilegeEscalation != false (container "app" must set
securityContext.allowPrivilegeEscalation=false)

That immediate, specific feedback is a real improvement over PSP’s often-cryptic RBAC-denial errors.

Namespace Rollout Strategy

Because warn and audit modes don’t block anything, they’re the safe way to test a policy before enforcing it:

kubectl label namespace staging \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/audit=restricted

Deploy your normal workloads, check kubectl get events and API server audit logs for violations, fix the Pod specs, and only then flip enforce=restricted.

Deployment Example Meeting Restricted Standards

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        fsGroup: 2000
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: myapp
          image: registry.example.com/myapp:1.0.0
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 250m
              memory: 256Mi

Third-Party Alternatives: OPA Gatekeeper and Kyverno

For teams needing more granular or custom policy logic than the three built-in PSA levels offer, two CNCF-ecosystem projects fill the gap:

Kyverno (policy-as-YAML, easier learning curve):

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-non-root
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-runAsNonRoot
      match:
        resources:
          kinds: ["Pod"]
      validate:
        message: "runAsNonRoot must be true"
        pattern:
          spec:
            securityContext:
              runAsNonRoot: true

OPA Gatekeeper (Rego-based, more powerful, steeper curve) is the other common choice, typically picked when policy logic needs to reach beyond what PSA’s fixed levels express — for example, requiring specific labels across all namespaces, or enforcing organization-specific naming conventions.

RBAC Still Matters

PSA replaces PSP’s enforcement model, but RBAC still governs who can create, edit, or delete resources at all. The two are complementary, not substitutes for each other:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: production
  name: deployer
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "create", "update", "patch"]

Monitoring and Auditing

kubectl get events -n production --field-selector reason=FailedCreate
kubectl get pods -n production -o jsonpath='{.items[*].spec.securityContext}'

Cluster audit logs (configured at the API server level) will also record PSA deny and warn decisions if audit policy is set to capture them — worth wiring into your existing log aggregation.

Migrating an Existing Cluster from PSP to PSA

Teams running clusters built before 1.25 often still have PSP objects and RBAC bindings lingering from the old model, and migrating cleanly takes a deliberate sequence rather than a single flag flip:

  1. Inventory existing PSPs and note which workloads each one was actually permitting.
  2. For each namespace, determine the closest matching Pod Security Standard level (privileged, baseline, or restricted).
  3. Apply that level in warn/audit mode first, across every namespace.
  4. Review audit logs and kubectl get events for violations over at least one full deployment cycle.
  5. Fix any workloads that would be rejected, then flip to enforce.
  6. Only after enforce is live everywhere and stable, remove the old PSP objects and their RBAC bindings.
# Step 3, applied cluster-wide via a script over all namespaces
for ns in $(kubectl get ns -o jsonpath='{.items[*].metadata.name}'); do
  kubectl label namespace "$ns" \
    pod-security.kubernetes.io/warn=baseline \
    pod-security.kubernetes.io/audit=baseline --overwrite
done

Skipping straight to enforce across an entire cluster in one step is the single most common cause of an unplanned outage during this specific migration — a warn/audit trial period is genuinely worth the extra day or two it takes.

Namespace Exemptions for System Workloads

Not every namespace should target restricted — CNI DaemonSets, storage drivers, and other system components often need privileged access to do their job, and PSA supports exempting specific namespaces, users, or RuntimeClasses entirely at the cluster level rather than fighting the standard on a per-Pod basis:

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
        enforce-version: latest
      exemptions:
        namespaces:
          - kube-system
          - calico-system

This is applied via the API server’s admission configuration file, not a namespace label — a genuinely different mechanism worth distinguishing from the per-namespace labeling shown earlier, since it operates cluster-wide rather than per-namespace.

Verifying Enforcement Actually Works

Before trusting a restricted label in production, it’s worth deliberately testing that it rejects what it should:

kubectl run privileged-test --image=nginx --privileged=true -n production
Error from server (Forbidden): pods "privileged-test" is forbidden:
violates PodSecurity "restricted:latest": privileged (container
"privileged-test" must not set securityContext.privileged=true)

Seeing this exact rejection is the confirmation that the namespace label is actually being enforced by the API server — an easy, low-risk smoke test worth running immediately after labeling any namespace.

What Pod Security Admission Deliberately Doesn’t Cover

It’s worth being clear about PSA’s actual scope, since it’s narrower than what PSP theoretically allowed teams to enforce. PSA only evaluates the three fixed standards against a Pod’s own security-relevant fields — it has no concept of organization-specific rules like “every image must come from our approved registry,” “every Deployment must have a cost-center label,” or “no Service may be of type LoadBalancer outside these three namespaces.” Those genuinely custom policies are exactly the gap Kyverno and OPA Gatekeeper fill, and most production clusters running anything beyond a small team end up using PSA for the baseline security posture and one of these two tools for organization-specific governance layered on top, rather than treating PSA as a complete policy solution on its own.

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: restrict-image-registries
spec:
  validationFailureAction: Enforce
  rules:
    - name: validate-registries
      match:
        resources:
          kinds: ["Pod"]
      validate:
        message: "Images must come from the approved registry"
        pattern:
          spec:
            containers:
              - image: "registry.example.com/*"

This kind of registry allowlisting is a genuinely common production requirement — preventing a compromised or careless deploy from pulling an unverified public image — and it sits entirely outside what PSA is designed to check, regardless of which of the three built-in levels a namespace enforces.

Choosing Between Kyverno and OPA Gatekeeper

For teams evaluating which of the two extension tools to adopt: Kyverno’s policies are plain YAML with a pattern-matching syntax that’s approachable for anyone already comfortable writing Kubernetes manifests, while OPA Gatekeeper’s policies are written in Rego, a genuinely separate query language with a real learning curve but considerably more expressive power for complex, cross-resource logic. Teams whose policy needs are mostly “require this field,” “restrict this value,” or “block this resource type” tend to find Kyverno faster to adopt and maintain; teams with more complex governance requirements spanning multiple resource types and external data sources often find Rego’s expressiveness worth the steeper initial investment.

Common Mistakes

Summary

PodSecurityPolicy is gone, and Pod Security Admission is the current standard: three built-in levels, applied via namespace labels, with immediate rejection feedback instead of indirect RBAC denials. For anything beyond the three fixed levels, Kyverno or OPA Gatekeeper extend policy enforcement with custom rules. Either way, the goal hasn’t changed since PSP’s introduction — Pods should run with the least privilege that lets them actually do their job.

References

Exit mobile version