Certified Kubernetes Security Specialist (CKS) #9: Pod Security Admission (PSA, Pod Security Standards)
Up through #8 kernel hardening, capabilities, /proc protection we dealt with confining nodes and containers at the Linux kernel level. Starting with this post we move into the Minimize Microservice Vulnerabilities domain and look at how to make the cluster refuse a Pod that carries dangerous settings in the first place. The first tool for that is Pod Security Admission (PSA), built into Kubernetes.
PSA is an admission controller baked into the API server — no separate install required. Attach a single label to a namespace, and every Pod that comes into that namespace is checked against a set security standard, with creation itself rejected on a violation. On the exam, the recurring patterns are “apply the restricted standard to this namespace in enforce mode” and “fix the Pod that’s being rejected so it meets the standard.”
PodSecurityPolicy is gone #
First, the background in one line. In the past a different admission controller, PodSecurityPolicy (PSP), did the same job, but it was deprecated in v1.21 — its authorization model was complex and hard to use — and fully removed in v1.25. Its replacement, stable since v1.25, is Pod Security Admission. In the current CKS exam version PSP no longer exists, so you can treat all Pod-level security standards as handled by PSA.
Unlike PSP, PSA requires no separate policy resource and no RBAC wiring. All you do is pick one of the three predefined Pod Security Standards levels and apply it with a namespace label.
The three Pod Security Standards levels #
The Pod Security Standards are standard profiles that split security strength into three levels. The higher the level, the fewer dangerous settings a Pod is allowed.
| Level | Meaning | Allowed scope |
|---|---|---|
| privileged | No restrictions | All settings allowed. Host namespaces, privileged containers, even arbitrary capabilities — everything is possible |
| baseline | The minimum line that blocks known privilege escalation | Forbids privileged containers, hostNetwork, hostPID, dangerous capabilities, and so on. Other default settings are mostly allowed |
| restricted | Hardened best practice | On top of baseline’s prohibitions, it actively requires runAsNonRoot, seccomp RuntimeDefault, capabilities drop ALL, and the like |
The difference between the three levels in one line: privileged blocks nothing, baseline blocks only the obvious escape routes, and restricted enforces least privilege. privileged is normally used only for workloads that genuinely need host access — system components or node agents — while the recommended direction is to apply baseline or restricted to ordinary application namespaces.
What baseline blocks #
baseline blocks the known routes by which a container takes over a node. The main prohibited items are these.
- Host namespace sharing such as
hostNetwork,hostPID,hostIPC privileged: truecontainershostPathvolumes (with a few exceptions)- Dangerous added capabilities other than
NET_RAW - Explicitly setting the seccomp profile to Unconfined
What restricted additionally requires #
restricted includes all of baseline’s prohibitions and, on top of that, requires the following to be set without fail. To write a Pod that passes restricted on the exam, this list is your checklist.
runAsNonRoot: true(no running as root)allowPrivilegeEscalation: falseseccompProfile.type: RuntimeDefault(or Localhost)capabilities.dropmust includeALL. The only one additionally allowed back isNET_BIND_SERVICE- Only safe volume types, excluding types like
hostPath
These settings have to go into both the Pod-level securityContext and the container-level securityContext in the right places. In particular, runAsNonRoot and seccompProfile typically go at the Pod level, while allowPrivilegeEscalation and capabilities go at the container level.
The three PSA modes #
For the same standard, you choose what happens on a violation via the mode. PSA offers three modes, and you can even apply different levels at once in a single namespace.
| Mode | Behavior | Use |
|---|---|---|
| enforce | Rejects creation of a Pod that violates the standard. The Pod is not created | Actual blocking |
| audit | Allows creation despite the violation, but records the violation in the audit log | Impact analysis |
| warn | Allows creation despite the violation, but shows a warning message in the kubectl response | User guidance |
In operations, before applying a new standard directly in enforce, you usually examine the impact first with warn and audit, then raise it to enforce once you’ve confirmed it’s safe. That said, the exam usually asks for enforce alone and clearly, so when a question says “apply restricted in enforce mode,” you just attach the enforce label correctly.
Apply it with a namespace label #
PSA’s unit of application is the namespace. Attach a label of the following form to a namespace and the standard applies to all Pods in it.
pod-security.kubernetes.io/<mode>: <level>
pod-security.kubernetes.io/<mode>-version: <version><mode>is one ofenforce,audit,warn.<level>is one ofprivileged,baseline,restricted.- The
-versionlabel pins the Kubernetes version of the standard to apply. Omit it and it acts aslatest; when specifying, write it likev1.30.
For example, the labels that apply restricted in enforce mode to the app namespace look like this.
apiVersion: v1
kind: Namespace
metadata:
name: app
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latestApplying enforce and warn together like this means a violating Pod is rejected while a human-readable warning is shown at the same time. To handle this fast on the exam, it’s worth learning how to apply it with kubectl label instead of a manifest.
# Apply enforce restricted to an already existing namespace
kubectl label namespace app \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=latest --overwriteYou need to add --overwrite so it can overwrite even when a different value already exists.
How a violating Pod is rejected #
When you try to create a Pod that violates the standard in a namespace with enforce on, the API server blocks it at the admission stage and returns an error message. For example, creating a plain nginx Pod with no settings in a namespace where restricted is enforced produces a rejection message similar to this.
Error from server (Forbidden): error when creating "pod.yaml": pods "nginx" is forbidden:
violates PodSecurity "restricted:latest": allowPrivilegeEscalation != false (...),
unrestricted capabilities (...), runAsNonRoot != true (...),
seccompProfile (...) must be set to "RuntimeDefault" or "Localhost"This message is itself the fix guide. Because the violated items are listed as-is, filling in each field named in the message one by one makes it pass. When the exam says “fix the Pod that’s being rejected,” the fast flow is to apply it as-is first to get the rejection message, then reflect the listed items into securityContext.
Pods of a Deployment that were already running are unaffected. Since PSA acts only at the admission stage, only newly created Pods get checked after you attach the label. So if you applied restricted to an existing workload, you have to redeploy the Deployment to create new Pods before any actual violation surfaces.
A Pod that passes restricted — an example #
A Pod that’s created normally even in a namespace where restricted is enforced is written as follows. The checklist above is all reflected.
apiVersion: v1
kind: Pod
metadata:
name: secure-nginx
namespace: app
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: nginx
image: nginxinc/nginx-unprivileged:stable
ports:
- containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALLLet me restate the key points.
- Put
runAsNonRoot: trueandseccompProfile.type: RuntimeDefaultin the Pod-levelsecurityContext. - Put
allowPrivilegeEscalation: falseandcapabilities.drop: [ALL]in the container-levelsecurityContext. - The image has to be one that runs as a non-root user. The ordinary
nginximage binds port 80 as root, which conflicts withrunAsNonRoot. Use an unprivileged image as in the example above, or change the port to 1024 or higher.
That last item is the easy place to slip. If you fill in the securityContext fields but keep using an image that runs as root, creation passes but the container fails at the startup stage. Both a manifest that meets the standard and an image that actually runs unprivileged have to line up.
Cluster-wide defaults (AdmissionConfiguration) #
Instead of attaching a label per namespace, you can also lay down a default standard cluster-wide. You hand an AdmissionConfiguration file to the API server to set the defaults and exemptions of the PodSecurity plugin. System namespaces (such as kube-system) are usually pulled out as exemptions.
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-systemYou wire this file in with kube-apiserver’s --admission-control-config-file flag. That said, the common form on the exam is a per-namespace label rather than a cluster-wide setting, so it’s safer to give priority to the label approach.
Exam points #
- PSP was removed in v1.25 and its replacement is PSA. You must not use PSP syntax.
- The three levels get stronger in the order privileged → baseline → restricted. restricted is the strictest.
- The three modes are enforce (reject), audit (record), warn (warning). Only enforce actually blocks.
- The unit of application is the namespace, and the label keys are
pod-security.kubernetes.io/<mode>andpod-security.kubernetes.io/<mode>-version. - The restricted pass checklist is
runAsNonRoot: true,allowPrivilegeEscalation: false,seccompProfile.type: RuntimeDefault,capabilities.drop: [ALL]. - Since the rejection message lists the violated items as-is, the fast flow is to apply first, get the message, and fix it exactly as listed.
- An image that runs as root fails to start even if you fill in only the securityContext. You have to take care of the unprivileged image as well.
PSA’s hardening direction uses the same fields as the Pod-level security settings covered in CKAD #15 securityContext and Pod security; the only difference is that PSA enforces those settings at the namespace level. If CKAD was the stage where you got the recommended settings into your hands, CKS is the stage where you make the cluster check those settings.
Wrap-up #
What this post locked in:
- After PodSecurityPolicy was retired, Pod Security Admission took its place. It’s built into the API server with no separate install.
- The three Pod Security Standards levels (privileged, baseline, restricted) narrow the allowed scope as the level rises.
- The three modes (enforce, audit, warn) let you choose the behavior on a violation, and the actual rejection is enforce.
- You apply it with the namespace labels
pod-security.kubernetes.io/enforce: restrictedand enforce-version. - A Pod that passes restricted has
runAsNonRoot,allowPrivilegeEscalation: false, seccomp RuntimeDefault, and capabilities drop ALL, and must run on an unprivileged image.
Next — Secrets management #
Pod security standards can now be enforced with PSA. But what about the sensitive data a Pod handles — the Secret itself? A default Secret is stored in etcd as near-plaintext base64, so anyone with access to etcd can read it as-is.
In #10 Secrets management: etcd encryption, External Secrets, we’ll work through how to encrypt Secrets stored in etcd with an EncryptionConfiguration, key rotation, and the External Secrets pattern that integrates an external secret store — all from an exam point of view.