
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,warnandaudit - 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 configuration | Risk |
|---|---|
Pod running as root (UID 0) | Privilege escalation if the container is compromised |
privileged: true allowed | Full access to the host |
hostPath mount of / possible | Read and write access to the host system |
hostNetwork: true allowed | Listening 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 PrivilegedapiVersion: v1kind: Podmetadata: name: privileged-podspec: 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 rootA 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.
Baseline level
Minimal restrictions: blocks the most dangerous privilege escalations while remaining compatible with most workloads. Baseline still allows root, but rejects anything that opens access to the node.
What is blocked:
| Configuration | Why it is blocked |
|---|---|
privileged: true | Full access to the host |
hostPID, hostIPC, hostNetwork: true | Seeing the host's processes, shared memory or ports |
hostPath volumes | Mounting a node directory into the pod |
hostPort | Listening directly on a node port |
| Capabilities outside the list | SYS_ADMIN, NET_RAW, etc.: only 13 common capabilities remain allowed |
Unconfined seccomp profile | Removing system call filtering |
Measured on Kubernetes 1.37, each row produces a named rejection:
privileged, host namespaces (hostNetwork=true), hostPath volumes (volume "h"), non-default capabilities (... "NET_RAW" ...).
When to use it:
- Legacy applications that do not support Restricted
- First migration step away from Privileged
- Development environments
# This pod is Baseline-compatible: it runs as root, which Baseline allowsapiVersion: v1kind: Podmetadata: name: baseline-podspec: containers: - name: app image: nginx:1.30@sha256:9bf97bd7714f5e24c1ccd545ecb9eb5435cb6d109c97cebb15e7e455e0239edb securityContext: allowPrivilegeEscalation: falseRestricted level
Maximum hardening: applies current security best practices. It is the level recommended for production by the Kubernetes documentation.
What is required:
| Requirement | Required value |
|---|---|
runAsNonRoot | true (and runAsUser never 0) |
allowPrivilegeEscalation | false |
seccompProfile | RuntimeDefault or Localhost |
capabilities.drop | ALL (only NET_BIND_SERVICE can be added back) |
| Allowed volumes | configMap, csi, downwardAPI, emptyDir, ephemeral, persistentVolumeClaim, projected, secret |
When to use it:
- Production applications
- Workloads handling sensitive data
- CKS certification preparation
The image matters as much as the manifest: the official nginx image starts
as root and cannot run here. This pod uses its non-root variant,
nginxinc/nginx-unprivileged, which listens on port 8080. The read-only
file system requires an emptyDir volume on /tmp, where nginx writes its
cache.
# This pod is Restricted-compliant, and it startsapiVersion: v1kind: Podmetadata: name: restricted-podspec: securityContext: runAsNonRoot: true seccompProfile: type: RuntimeDefault containers: - name: app image: nginxinc/nginx-unprivileged:1.30-alpine@sha256:ed04ec1ff34502c339ee5c3ae3f855442398edc1d05591e2b98981dcbbd20b1e securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: - ALL volumeMounts: - name: tmp mountPath: /tmp volumes: - name: tmp emptyDir: {}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.
| Mode | Behaviour | Use |
|---|---|---|
enforce | Blocks non-compliant pods | Production |
audit | Records the violation in the audit logs, the pod is created | Migration |
warn | Shows a warning to the user, the pod is created | Development |
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:
Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation!= false (container "non-conforme" must setsecurityContext.allowPrivilegeEscalation=false), unrestricted capabilities ...pod/non-conforme createdOn a namespace in enforce, the same command fails and no pod exists:
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:
- First
warn+audit: developers see the warnings, violations are logged, but nothing is blocked. This leaves time to fix the manifests. - 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: v1kind: Namespacemetadata: 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: latestMethod 2: kubectl label
# Apply Restricted to an existing namespacekubectl label namespace production \ pod-security.kubernetes.io/enforce=restricted \ pod-security.kubernetes.io/warn=restricted \ pod-security.kubernetes.io/audit=restricted
# Check the labelskubectl get namespace production -o yaml | grep pod-securityA 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.
-
Audit first: identify the violations
Apply
audit+warnfirst to see what would break:Terminal window kubectl label namespace monapp \pod-security.kubernetes.io/audit=restricted \pod-security.kubernetes.io/warn=restrictedCreate 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"... -
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: trueseccompProfile:type: RuntimeDefaultcontainers:- name: appsecurityContext:allowPrivilegeEscalation: falsereadOnlyRootFilesystem: truecapabilities:drop: ["ALL"] -
Test in staging, then simulate the blocking
Deploy with the new
securityContextand 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 -
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:
Warning: existing pods in namespace "pss-warn" violate the new PodSecurity enforce level "restricted:latest"Warning: non-conforme: allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfilenamespace/pss-warn labeledThe 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.
For a rejected Deployment, the command that gives the reason is the events one:
kubectl get events -n production --field-selector reason=FailedCreatePSA 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:
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.
apiVersion: v1kind: Podmetadata: name: conformespec: 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: truecontainers:- name: app image: nginxinc/nginx-unprivileged:1.30-alpine@sha256:ed04ec1ff34502c339ee5c3ae3f855442398edc1d05591e2b98981dcbbd20b1eError 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: RuntimeDefaultError 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.
| Configuration | Privileged | Baseline | Restricted |
|---|---|---|---|
privileged: true | allowed | blocked | blocked |
hostNetwork, hostPID, hostIPC | allowed | blocked | blocked |
hostPath volumes | allowed | blocked | blocked |
| Root (UID 0) | allowed | allowed | blocked |
allowPrivilegeEscalation: true | allowed | allowed | blocked |
Capabilities outside the list (SYS_ADMIN, NET_RAW) | allowed | blocked | blocked |
| Explicit seccomp profile required | no | no | yes |
How to read this table?
- Baseline closes access to the node:
privileged, host namespaces andhostPathare 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,
hostPathincluded), Restricted (full hardening) - Production = Restricted: it is the level recommended by the Kubernetes documentation
- Gradual migration:
auditandwarnbeforeenforce, and--dry-run=serverto 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
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
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Kubernetes NetworkPolicies: isolating traffic between Pods
A new full quiz with random questions
Work again only on the questions you missed
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).