Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Pod Security Standards (PSS): securing Kubernetes workloads

By updated

Improve this page

Kubernetes logo

The Pod Security Standards (PSS) define three restriction levels for Kubernetes pods: Privileged (no restriction), Baseline (blocks known privilege escalations) and Restricted (full hardening). Kubernetes applies them through a simple namespace label, thanks to the built-in Pod Security Admission controller. This guide is for a DevOps engineer who wants to harden their namespaces without breaking their applications. Everything is measured on Kubernetes 1.37: a default nginx pod violates four restricted rules, baseline also rejects hostPath volumes, and a pod accepted at admission can still fail at start-up if its image runs as root.

This topic belongs to the Minimize Microservice Vulnerabilities domain of the CKS certification, which weighs 20% of the exam and asks you to "Use appropriate pod security standards".

What you will learn

  • Distinguish what Baseline and Restricted block, field by field
  • Apply a level to a namespace with the three modes enforce, warn and audit
  • Interpret a Pod Security Admission rejection and fix it in a single pass
  • Separate an admission rejection from a start-up failure on the kubelet side
  • Migrate a namespace to Restricted without cutting production

What are the Pod Security Standards?

The Pod Security Standards (PSS) are the official Kubernetes standard that describes three predefined security profiles for pods. The standard says what to forbid; the Pod Security Admission (PSA) admission controller, stable since Kubernetes 1.25, applies it to every namespace that carries the right label. In the API server configuration, this plugin is named PodSecurity. It replaces PodSecurityPolicy (PSP), removed from Kubernetes in the same 1.25 release.

Why does it matter?

By default, Kubernetes allows dangerous configurations. Without an explicit restriction, anyone who can create a pod can give it full access to the node: that is exactly what an attacker is looking for. Without an admission configuration, the default level is privileged: a namespace without a label accepts a privileged pod.

Here is what is allowed if you configure nothing:

Default configurationRisk
Pod running as root (UID 0)Privilege escalation if the container is compromised
privileged: true allowedFull access to the host
hostPath mount of / possibleRead and write access to the host system
hostNetwork: true allowedListening on the host's ports

Attack scenario: an attacker compromises a vulnerable application in a pod. If the pod runs as root with privileged: true, the attacker can escape the container, reach the node, then pivot to other nodes in the cluster. PSS prevent this scenario by rejecting these configurations at deployment time.

The 3 PSS levels

The three levels form a scale, not a menu: each one contains the restrictions of the previous one and adds more. Privileged forbids nothing, Baseline blocks the best-known privilege escalations, Restricted imposes full hardening. The tabs give, for each one, what it allows and a manifest that passes it and starts. The jump in difficulty sits between the last two.

Privileged level

No restriction: this level allows everything, including the most dangerous configurations.

When to use it:

  • System namespaces (kube-system)
  • Infrastructure tools (monitoring agents, CNI, CSI drivers)
  • Never for business applications
# This pod is allowed at Privileged
apiVersion: v1
kind: Pod
metadata:
name: privileged-pod
spec:
containers:
- name: debug
image: ubuntu:24.04@sha256:008173c23f95b170204355c12626cb5a965d779a7e1283b09e9cffbb1bf33ca3
command: ["sleep", "3600"]
securityContext:
privileged: true # Full root access to the host
runAsUser: 0 # Runs as root

A rule not to relax: the Privileged level is reserved for system components, those that genuinely need access to the node. Never apply it to an application namespace, not even temporarily to unblock a deployment: that is how a cluster ends up with no constraint at all, several months after the incident that justified it.

How to apply PSS?

Kubernetes applies the Pod Security Standards through Pod Security Admission (PSA), an admission controller built into the API server and stable since version 1.25. It requires no installation: putting labels on a namespace is enough.

The 3 enforcement modes

PSA offers three ways to apply a PSS level to a namespace. These modes can be combined for a safe, gradual migration, and each one can target a different level.

ModeBehaviourUse
enforceBlocks non-compliant podsProduction
auditRecords the violation in the audit logs, the pod is createdMigration
warnShows a warning to the user, the pod is createdDevelopment

The three are independent, and the difference shows at a glance. On a namespace in warn only, the non-compliant pod is created, preceded by a warning:

Output
Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation
!= false (container "non-conforme" must set
securityContext.allowPrivilegeEscalation=false), unrestricted capabilities ...
pod/non-conforme created

On a namespace in enforce, the same command fails and no pod exists:

Output
Error from server (Forbidden): pods "non-conforme" is forbidden:
violates PodSecurity "restricted:latest": allowPrivilegeEscalation != false ...

Note the pod/non-conforme created in the first output: a warning protects against nothing, it informs.

Recommended migration strategy:

  1. First warn + audit: developers see the warnings, violations are logged, but nothing is blocked. This leaves time to fix the manifests.
  2. Then enforce: once all pods are compliant, turn on blocking. Any non-compliant creation will be rejected.

This approach avoids breaking production overnight.

Applying PSS to a namespace

PSS do not apply to a pod but to an entire namespace, through labels. There are two ways to set them, equivalent in effect: write them in the namespace manifest, which makes them versionable, or add them from the command line on an existing namespace. The -version label pins the version of the standard; latest follows the cluster's version.

Method 1: Labels on the namespace

apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
# Applies Restricted in enforce mode
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
# Audits Restricted violations
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
# Warns for Restricted
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest

Method 2: kubectl label

Terminal window
# Apply Restricted to an existing namespace
kubectl label namespace production \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restricted
# Check the labels
kubectl get namespace production -o yaml | grep pod-security

A typo behaves differently depending on where it sits. An unknown value (restrictd) is rejected, with the list of allowed values: must be one of privileged, baseline, restricted. A misspelled key (enforse) is accepted like any other label, and the namespace remains without constraint. Always check the effect by creating a non-compliant pod.

Labels are not the only setting. According to the Kubernetes documentation, the API server also reads an admission configuration (--admission-control-config-file) that sets default levels for the whole cluster and exemptions by username, by RuntimeClass or by namespace. These exemptions live on the control plane, out of reach of a cluster user: this is deliberate, an exempted party must not be able to exempt itself.

Migrating to Restricted without breaking your applications

Migration happens in four steps, and the order matters: observe first, fix next, block last. Two measured behaviours make the operation less risky than it looks: setting enforce evicts no existing pod, and a server-side dry run shows the effect before applying it.

  1. Audit first: identify the violations

    Apply audit + warn first to see what would break:

    Terminal window
    kubectl label namespace monapp \
    pod-security.kubernetes.io/audit=restricted \
    pod-security.kubernetes.io/warn=restricted

    Create a test pod and watch the warnings:

    Terminal window
    kubectl run test --image=nginx:1.30@sha256:9bf97bd7714f5e24c1ccd545ecb9eb5435cb6d109c97cebb15e7e455e0239edb -n monapp
    # Warning: would violate PodSecurity "restricted:latest"...
  2. Fix your Deployments

    For each violation found, update the securityContext, and replace the images that start as root with their non-root variant:

    spec:
    securityContext:
    runAsNonRoot: true
    seccompProfile:
    type: RuntimeDefault
    containers:
    - name: app
    securityContext:
    allowPrivilegeEscalation: false
    readOnlyRootFilesystem: true
    capabilities:
    drop: ["ALL"]
  3. Test in staging, then simulate the blocking

    Deploy with the new securityContext and check that the application starts. Before blocking, a server-side dry run lists the existing pods that would violate the level, without changing anything:

    Terminal window
    kubectl label --dry-run=server --overwrite namespace production \
    pod-security.kubernetes.io/enforce=restricted
  4. Apply Restricted in enforce

    Once all pods are compliant:

    Terminal window
    kubectl label namespace production \
    pod-security.kubernetes.io/enforce=restricted \
    --overwrite

Setting enforce on a namespace that still contains non-compliant pods does not stop them: the API server shows a warning and leaves them running. Blocking only applies to the following creations, including the pods recreated by a Deployment after a restart. Measured on Kubernetes 1.37:

Output
Warning: existing pods in namespace "pss-warn" violate the new PodSecurity enforce level "restricted:latest"
Warning: non-conforme: allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile
namespace/pss-warn labeled

The most frequent blocker comes from the image, not the manifest. Many official images run as root by default, nginx, redis or postgres for example, and no securityContext will make them compliant if they were not built for it. Look for their non-root variant: nginx becomes nginxinc/nginx-unprivileged, which runs as UID 101. It is often the only change needed to move to Restricted.

Common errors and solutions

A rejection does not always happen in the same place, and this is what confuses people most. A pod created directly is rejected by the API server as soon as you apply it. A Deployment, on the other hand, is accepted with a warning, then its ReplicaSet fails to create the pods: the rejection shows in the ReplicaSet's events, not in the container logs, since the container does not exist. Finally, a pod accepted at admission can still be blocked by the kubelet at start-up.

The diagram separates these two barriers. PSA only reads the fields declared in the manifest; the kubelet discovers the image's actual user at start-up.

Two successive barriers: Pod Security Admission checks the declared fields and rejects in enforce mode; an accepted pod is created, then the kubelet refuses to start the container with CreateContainerConfigError if the image runs as root despite runAsNonRoot.noyesyesnoPod submittedto the API serverPSA: declared fieldsmatch the level?Forbiddenin enforce modePod createdkubelet: does the imagestart as root?CreateContainerConfigErrorContainer started
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

For a rejected Deployment, the command that gives the reason is the events one:

Terminal window
kubectl get events -n production --field-selector reason=FailedCreate

PSA does not report violations one by one

The method point that saves the most time: the message lists every violation at once. Fixing the first one and rerunning to discover the next is a waste of time, everything is already written in the rejection.

Here is the full message, captured for an nginx pod without any securityContext in a restricted namespace:

Output
Error from server (Forbidden): pods "non-conforme" is forbidden:
violates PodSecurity "restricted:latest":
allowPrivilegeEscalation != false (container "non-conforme" must set
securityContext.allowPrivilegeEscalation=false),
unrestricted capabilities (container "non-conforme" must set
securityContext.capabilities.drop=["ALL"]),
runAsNonRoot != true (pod or container "non-conforme" must set
securityContext.runAsNonRoot=true),
seccompProfile (pod or container "non-conforme" must set
securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")

Four violations, four fixes, and the manifest below covers them all. Measured in a restricted namespace: it is admitted and its container starts, because the image already runs as non-root. With the official nginx image, the same manifest would be admitted and then blocked by the kubelet.

pod-conforme.yaml
apiVersion: v1
kind: Pod
metadata:
name: conforme
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: nginxinc/nginx-unprivileged:1.30-alpine@sha256:ed04ec1ff34502c339ee5c3ae3f855442398edc1d05591e2b98981dcbbd20b1e
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]

Two fields go at the pod level, two at the container level: this is the most frequent placement error. The following sections take each error separately, in case you only run into one.

Error 1: "container has runAsNonRoot and image will run as root"

This message does not come from admission but from the kubelet: the pod is created, then stays in CreateContainerConfigError. The cause is an image that starts as root while the pod requires runAsNonRoot: true. The message is read with kubectl describe pod.

Solution: use an image built to run as non-root, such as nginxinc/nginx-unprivileged. Forcing a UID with runAsUser: 1000 gets past the kubelet check, but an image not designed for that user then fails: measured with nginx:1.30, the container stops on mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied).

securityContext:
runAsNonRoot: true
containers:
- name: app
image: nginxinc/nginx-unprivileged:1.30-alpine@sha256:ed04ec1ff34502c339ee5c3ae3f855442398edc1d05591e2b98981dcbbd20b1e

Error 2: "seccompProfile ... must set securityContext.seccompProfile.type"

Cause: the Restricted level requires an explicit seccomp profile, which most manifests do not declare. The rejection names it seccompProfile, with the two allowed values.

Solution:

spec:
securityContext:
seccompProfile:
type: RuntimeDefault

Error 3: "unrestricted capabilities"

Cause: the container does not drop all its capabilities. The Restricted level requires drop: ["ALL"] and tolerates only one addition, NET_BIND_SERVICE, to listen on a port below 1024.

Solution:

containers:
- name: app
securityContext:
capabilities:
drop: ["ALL"]
# If really needed, only this capability can be added back:
# add: ["NET_BIND_SERVICE"]

Comparison of the 3 levels

This table sums up what is allowed or blocked at each level, as measured on Kubernetes 1.37 and described by the official documentation. Use it as a quick reference during your security audits.

ConfigurationPrivilegedBaselineRestricted
privileged: trueallowedblockedblocked
hostNetwork, hostPID, hostIPCallowedblockedblocked
hostPath volumesallowedblockedblocked
Root (UID 0)allowedallowedblocked
allowPrivilegeEscalation: trueallowedallowedblocked
Capabilities outside the list (SYS_ADMIN, NET_RAW)allowedblockedblocked
Explicit seccomp profile requirednonoyes

How to read this table?

  • Baseline closes access to the node: privileged, host namespaces and hostPath are rejected. It does, however, let the container run as root.
  • Restricted goes further: it requires running as non-root, dropping capabilities, a seccomp profile, and limits volume types. It is the level to aim for in production.
  • Watch the image: a Restricted-compliant pod only starts if its image accepts running as non-root.

Key points

  • 3 levels: Privileged (everything allowed), Baseline (closes access to the node, hostPath included), Restricted (full hardening)
  • Production = Restricted: it is the level recommended by the Kubernetes documentation
  • Gradual migration: audit and warn before enforce, and --dry-run=server to foresee the effect
  • Pod Security Admission: the built-in controller that applies PSS through namespace labels
  • Two barriers: admission reads the manifest, then the kubelet rejects a root image with runAsNonRoot
  • A misspelled label key is ignored without an error: check the effect, not the label
  • CKS: PSS belong to the "Minimize Microservice Vulnerabilities" domain, which weighs 20% of the exam

Knowledge check

Seven questions on what costs the most in practice: the real difference between the three levels, the three enforcement modes, and reading the rejections that follow a move to Restricted.

Knowledge check

Check what you have learned with this interactive quiz

7 questions
8 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Put it into practice

This lab is about admission, which is what this page teaches. The restricted standard is enabled on a permissive namespace that still accepts a Pod sharing the host's PID namespace, and the rejection is observed without creating anything, through a server-side dry run that goes through admission and writes nothing.

Hardening a Pod that is already running belongs to the securityContext field and not to admission: the two matching exercises live on Security Context (in French). Opening a complete enclave to an external team is the capstone of the CKS exercises (in French).

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn