CKS — Security Contexts Deep Dive
Security Contexts Deep Dive
Security contexts are the core mechanism for controlling how containers run within a Pod. They determine which user processes execute as, what Linux capabilities are available, whether the filesystem is writable, and whether privilege escalation is possible.
For the CKS exam, security contexts are where policy meets implementation. Pod Security Standards define the rules — security contexts are how you comply with them.
1. Pod-Level vs Container-Level Security Contexts
Security contexts can be set at two levels. Understanding which overrides which is critical.
apiVersion: v1
kind: Pod
metadata:
name: example
spec:
securityContext: # ← Pod-level (applies to ALL containers)
runAsUser: 1000
runAsGroup: 3000
fsGroup: 2000
containers:
- name: app
image: myapp:1.0
securityContext: # ← Container-level (overrides Pod-level for THIS container)
runAsUser: 2000 # This container runs as UID 2000, not 1000
Override Rules
| Field | Pod-Level | Container-Level | Which Wins? |
|---|---|---|---|
runAsUser |
✓ | ✓ | Container-level overrides Pod-level |
runAsNonRoot |
✓ | ✓ | Container-level overrides Pod-level |
runAsGroup |
✓ | ✓ | Container-level overrides Pod-level |
fsGroup |
✓ only | — | Pod-level only (no container override) |
supplementalGroups |
✓ only | — | Pod-level only |
sysctls |
✓ only | — | Pod-level only |
seccompProfile |
✓ | ✓ | Container-level overrides Pod-level |
allowPrivilegeEscalation |
— | ✓ only | Container-level only |
privileged |
— | ✓ only | Container-level only |
readOnlyRootFilesystem |
— | ✓ only | Container-level only |
capabilities |
— | ✓ only | Container-level only |
Key insight: Fields that affect the entire Pod (filesystem group ownership, sysctls) are Pod-level only. Fields that affect individual processes (user, capabilities, privilege escalation) can be set per container.
Exam Tip: If the question says “all containers must run as non-root,” set
runAsNonRoot: trueat the Pod level. If only one container needs a specific UID, set it at the container level.
2. runAsUser, runAsNonRoot, runAsGroup
runAsUser
Specifies the UID that the container’s process runs as, regardless of what the Dockerfile’s USER directive specifies.
securityContext:
runAsUser: 1000 # Process runs as UID 1000
# Inside the container
$ id
uid=1000 gid=0(root) groups=0(root)
$ whoami
1000 # No username mapped, just the UID
runAsNonRoot
A validation check — Kubernetes rejects the Pod if the container image would run as root (UID 0).
securityContext:
runAsNonRoot: true # Rejects if the image's USER is root or unset
How it works:
- Kubernetes inspects the container image’s
USERdirective - If the image runs as UID 0 (or has no USER set), the Pod is rejected
- If
runAsUseris also set to a non-zero UID, the check passes
runAsNonRoot: true + runAsUser: 0 → REJECTED
runAsNonRoot: true + runAsUser: 1000 → ALLOWED
runAsNonRoot: true + image USER=root → REJECTED
runAsNonRoot: true + image USER=1000 → ALLOWED
runAsNonRoot: true + image USER unset → REJECTED (defaults to root)
Common mistake: Setting
runAsNonRoot: truewithoutrunAsUseron an image that has noUSERdirective. The Pod will fail withError: container has runAsNonRoot and image will run as root.
runAsGroup
Sets the primary GID for the container process.
securityContext:
runAsUser: 1000
runAsGroup: 3000
$ id
uid=1000 gid=3000 groups=3000
Without runAsGroup, the primary group defaults to 0 (root group), which can be a security concern:
# Without runAsGroup
securityContext:
runAsUser: 1000
# Result: uid=1000 gid=0(root) — primary group is root!
Best practice: Always set runAsGroup alongside runAsUser to avoid implicit root group membership.
3. fsGroup and Volume Permissions
fsGroup sets the group ownership of all files in mounted volumes. The kubelet changes the group of all files in each volume to match the fsGroup value and sets the setgid bit.
spec:
securityContext:
runAsUser: 1000
runAsGroup: 3000
fsGroup: 2000 # All volume files owned by GID 2000
containers:
- name: app
image: myapp:1.0
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: app-data
# Inside the container
$ ls -la /data/
drwxrwsrwx 2 root 2000 4096 ... . # Owned by GID 2000
-rw-r--r-- 1 root 2000 512 ... file.txt # Owned by GID 2000
$ id
uid=1000 gid=3000 groups=3000,2000 # User is member of fsGroup
How fsGroup Works
Pod starts → kubelet processes volumes:
1. Changes group ownership of all files to fsGroup (GID 2000)
2. Sets setgid bit on directories (new files inherit the group)
3. Adds fsGroup to the container's supplemental groups
→ Container process (UID 1000) can read/write files owned by GID 2000
fsGroupChangePolicy
Controls when the kubelet changes volume ownership. This matters for large volumes where a recursive chown is expensive.
securityContext:
fsGroup: 2000
fsGroupChangePolicy: "OnRootMismatch" # Only change if root dir doesn't match
| Policy | Behavior | Performance |
|---|---|---|
Always (default) |
Recursive chown on every Pod start | Slow for large volumes |
OnRootMismatch |
Only chown if root directory GID doesn’t match fsGroup | Fast — skips if already correct |
4. readOnlyRootFilesystem
Forces the container’s root filesystem to be read-only. Any write attempt to the root filesystem fails with “Read-only file system.”
containers:
- name: app
image: nginx:1.27
securityContext:
readOnlyRootFilesystem: true # Root filesystem is read-only
volumeMounts:
- name: tmp
mountPath: /tmp # Writable via emptyDir
- name: cache
mountPath: /var/cache/nginx # Writable via emptyDir
- name: run
mountPath: /var/run # Writable via emptyDir
volumes:
- name: tmp
emptyDir: {}
- name: cache
emptyDir: {}
- name: run
emptyDir: {}
Why It Matters
Without readOnlyRootFilesystem:
Attacker compromises container → Writes malware to /usr/local/bin
→ Modifies /etc/passwd
→ Installs backdoor tools
→ Persists (until container restarts)
With readOnlyRootFilesystem:
Attacker compromises container → Write to /usr/local/bin? DENIED
→ Modify /etc/passwd? DENIED
→ Can only write to mounted emptyDir volumes
→ Attack surface drastically reduced
Common Writable Directories to Mount
| Application | Writable Paths | Mount Type |
|---|---|---|
| nginx | /tmp, /var/cache/nginx, /var/run |
emptyDir: {} |
| Node.js | /tmp |
emptyDir: {} |
| Java | /tmp, /app/logs |
emptyDir: {} |
| Python | /tmp, __pycache__ directories |
emptyDir: {} |
Exam Tip: If a container crashes with “Read-only file system” errors after you set
readOnlyRootFilesystem: true, check what paths the application writes to and mountemptyDirvolumes at those paths.
5. allowPrivilegeEscalation
Controls whether a process can gain more privileges than its parent process. When set to false, the kernel sets the no_new_privs flag on the process.
securityContext:
allowPrivilegeEscalation: false # Cannot gain new privileges
What This Blocks
| Escalation Method | Without Flag | With Flag |
|---|---|---|
setuid binaries (sudo, su, passwd) |
✓ Work | ✗ Blocked |
| Capabilities inherited from parent | ✓ Work | ✗ Blocked |
| Execve with elevated privileges | ✓ Work | ✗ Blocked |
# With allowPrivilegeEscalation: false
$ sudo whoami
sudo: effective uid is not 0, is sudo installed setuid root?
$ ping -c1 8.8.8.8
ping: permission denied (missing capability)
Default Behavior
- Default:
true(privilege escalation allowed) - Restricted PSS requirement:
false - When
privileged: trueis set,allowPrivilegeEscalationis forced totrue
Best practice: Always set
allowPrivilegeEscalation: falseunless the application specifically requires setuid binaries or capability inheritance.
6. Linux Capabilities
Linux capabilities split root’s privileges into distinct units. Instead of giving a container full root access, you grant only the specific capabilities it needs.
The Capabilities Model
Traditional Linux:
Root (UID 0) → ALL privileges (mount, kill, chown, bind ports, ...)
Non-root → Almost no privileges
Capabilities:
Any UID → Specific privileges only
Example: CAP_NET_BIND_SERVICE → Bind to port <1024 (no other root privileges)
Drop ALL, Add Only What’s Needed
The security best practice (and Restricted PSS requirement) is to drop ALL capabilities and add back only what’s necessary:
securityContext:
capabilities:
drop: ["ALL"] # Remove all 40+ capabilities
add: ["NET_BIND_SERVICE"] # Only add what's needed
Common Capabilities
| Capability | What It Allows | Common Use Case |
|---|---|---|
NET_BIND_SERVICE |
Bind to ports below 1024 | Web servers on port 80/443 |
CHOWN |
Change file ownership | Build processes, init scripts |
DAC_OVERRIDE |
Bypass file permission checks | Legacy apps with bad file permissions |
SETUID / SETGID |
Change process UID/GID | Drop privileges during startup |
SYS_PTRACE |
Trace processes | Debugging tools (avoid in production) |
NET_ADMIN |
Network configuration (iptables, routes) | Service mesh sidecars, CNI |
SYS_ADMIN |
Broad admin operations (mount, etc.) | Dangerous — almost root equivalent |
NET_RAW |
Use RAW/PACKET sockets | ping, network diagnostics |
What Each PSS Level Allows
| Level | Capabilities Policy |
|---|---|
| Privileged | Any capability allowed |
| Baseline | Can add: AUDIT_WRITE, CHOWN, DAC_OVERRIDE, FOWNER, FSETID, KILL, MKNOD, NET_BIND_SERVICE, SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOT |
| Restricted | Must drop: ["ALL"]. Can only add: NET_BIND_SERVICE |
Checking Capabilities in a Running Container
# From inside the container
cat /proc/1/status | grep -i cap
# CapPrm: 00000000a80425fb (permitted)
# CapEff: 00000000a80425fb (effective)
# CapBnd: 00000000a80425fb (bounding set)
# Decode capabilities
capsh --decode=00000000a80425fb
# 0x00000000a80425fb=cap_chown,cap_dac_override,...
# Or from the host
kubectl exec my-pod -- cat /proc/1/status | grep Cap
7. Complete Hardened Pod Example
This Pod passes the Restricted PSS level and represents the CKS exam target state:
apiVersion: v1
kind: Pod
metadata:
name: hardened-web
namespace: production
labels:
app: web
spec:
# Pod-level security context
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
automountServiceAccountToken: false # Don't mount SA token if not needed
containers:
- name: web
image: nginx:1.27-alpine
ports:
- containerPort: 8080
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
# Container-level security context
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /var/cache/nginx
- name: run
mountPath: /var/run
volumes:
- name: tmp
emptyDir: {}
- name: cache
emptyDir: {}
- name: run
emptyDir: {}
Security Checklist
| Setting | Value | Purpose |
|---|---|---|
runAsNonRoot: true |
Pod-level | Validates container doesn’t run as root |
runAsUser: 1000 |
Pod-level | Explicit non-root UID |
runAsGroup: 1000 |
Pod-level | Avoids default root group |
fsGroup: 1000 |
Pod-level | Volume files readable by container |
seccompProfile: RuntimeDefault |
Pod-level | Blocks dangerous syscalls |
allowPrivilegeEscalation: false |
Container-level | Prevents setuid/privilege gain |
readOnlyRootFilesystem: true |
Container-level | Prevents filesystem tampering |
capabilities.drop: ["ALL"] |
Container-level | Removes all Linux capabilities |
automountServiceAccountToken: false |
Pod-level | Prevents SA token theft |
resources |
Container-level | Prevents resource abuse (DoS) |
8. Security Context Best Practices for CKS
Exam-Focused Checklist
For every Pod on the CKS exam, apply:
□ runAsNonRoot: true (Pod level)
□ runAsUser: <non-zero> (Pod level)
□ runAsGroup: <non-zero> (Pod level)
□ seccompProfile: RuntimeDefault (Pod level)
□ allowPrivilegeEscalation: false (each container)
□ capabilities.drop: ["ALL"] (each container)
□ readOnlyRootFilesystem: true (each container, with emptyDir mounts)
□ automountServiceAccountToken: false (if SA not needed)
□ Resource requests and limits (each container)
Common Mistakes
| Mistake | What Happens | Fix |
|---|---|---|
Forgetting runAsNonRoot |
Container runs as root despite other settings | Add runAsNonRoot: true at Pod level |
Setting runAsNonRoot: true without runAsUser on root-default image |
Pod fails to start | Add runAsUser: 1000 |
| Not dropping capabilities | Container retains default capabilities (14+) | Add capabilities.drop: ["ALL"] |
| Missing Seccomp profile | All syscalls available to attacker | Add seccompProfile.type: RuntimeDefault |
readOnlyRootFilesystem without writable mounts |
App crashes on write attempts | Mount emptyDir at writable paths |
| Setting security context only at Pod level for container-only fields | Fields ignored (e.g., capabilities) | Set capabilities, readOnlyRootFilesystem, allowPrivilegeEscalation at container level |
9. Multi-Container Security Contexts
When a Pod has multiple containers (sidecars, init containers), each container needs its own security context:
apiVersion: v1
kind: Pod
metadata:
name: multi-container
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
initContainers:
- name: init-permissions
image: busybox:1.36
command: ["sh", "-c", "cp /config-template/* /config/"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /config
- name: config-template
mountPath: /config-template
containers:
- name: app
image: myapp:1.0
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /config
readOnly: true
- name: tmp
mountPath: /tmp
- name: sidecar
image: logging-agent:2.0
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: logs
mountPath: /var/log/app
readOnly: true
volumes:
- name: config
emptyDir: {}
- name: config-template
configMap:
name: app-config
- name: tmp
emptyDir: {}
- name: logs
emptyDir: {}
Key points:
- Pod-level
securityContextapplies to all containers (runAsUser, seccompProfile) - Each container (including init containers) must set its own
allowPrivilegeEscalation,capabilities, andreadOnlyRootFilesystem - Volume mounts with
readOnly: trueadd an extra layer — the sidecar can read logs but not modify them
Summary
| Field | Level | Purpose |
|---|---|---|
runAsUser |
Pod or Container | Set process UID |
runAsNonRoot |
Pod or Container | Validate non-root |
runAsGroup |
Pod or Container | Set process GID |
fsGroup |
Pod only | Set volume file group ownership |
seccompProfile |
Pod or Container | Syscall filtering |
allowPrivilegeEscalation |
Container only | Block setuid/privilege gain |
readOnlyRootFilesystem |
Container only | Immutable root filesystem |
capabilities |
Container only | Fine-grained privilege control |
privileged |
Container only | Full host access (avoid) |
Practice Questions
Q1: A Pod has runAsUser: 1000 at the Pod level and runAsUser: 2000 on one of its containers. What UID does each container run as?
- The container with
runAsUser: 2000at the container level runs as UID 2000 (container-level overrides Pod-level) - All other containers without a container-level
runAsUserrun as UID 1000 (inherited from Pod-level)
spec:
securityContext:
runAsUser: 1000 # Default for all containers
containers:
- name: app
image: myapp:1.0 # Runs as UID 1000 (Pod-level)
- name: special
image: special:2.0
securityContext:
runAsUser: 2000 # Runs as UID 2000 (container override)
Verification:
kubectl exec multi-pod -c app -- id
# uid=1000 ...
kubectl exec multi-pod -c special -- id
# uid=2000 ...
Q2: You set readOnlyRootFilesystem: true on an nginx container but the Pod keeps crashing. The logs show "failed to open /var/cache/nginx/..." and "read-only file system." How do you fix this?
Nginx needs to write to several directories at runtime. Mount emptyDir volumes at those paths:
containers:
- name: nginx
image: nginx:1.27-alpine
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /var/cache/nginx
- name: run
mountPath: /var/run
- name: nginx-conf # If nginx needs to write to /etc/nginx/conf.d
mountPath: /etc/nginx/conf.d
volumes:
- name: tmp
emptyDir: {}
- name: cache
emptyDir: {}
- name: run
emptyDir: {}
- name: nginx-conf
emptyDir: {}
Approach:
- Read the crash logs:
kubectl logs <pod> - Identify every path nginx tries to write to
- Mount an
emptyDirat each writable path - The root filesystem stays read-only — writes only go to ephemeral
emptyDirvolumes
This pattern works for any application. Check the logs for “read-only file system” errors and mount writable volumes where needed.
Q3: What is the difference between drop: ["ALL"] and not setting capabilities at all? What capabilities does a container have by default?
Without any capabilities configuration, a container gets the Docker/containerd default capability set (~14 capabilities):
Default capabilities:
AUDIT_WRITE, CHOWN, DAC_OVERRIDE, FOWNER, FSETID, KILL,
MKNOD, NET_BIND_SERVICE, NET_RAW, SETFCAP, SETGID, SETPCAP,
SETUID, SYS_CHROOT
With drop: ["ALL"], all capabilities are removed — the container has zero capabilities:
securityContext:
capabilities:
drop: ["ALL"] # 0 capabilities
add: ["NET_BIND_SERVICE"] # Add back only what's needed → 1 capability
| Setting | Capabilities | Security Level |
|---|---|---|
| Not set | ~14 default capabilities | Low — process can chown files, set UIDs, use raw sockets |
drop: ["ALL"] |
0 capabilities | High — process has no special privileges |
drop: ["ALL"] + add: ["NET_BIND_SERVICE"] |
1 capability | High — only port binding for ports <1024 |
The Restricted PSS level requires drop: ["ALL"] and only allows adding back NET_BIND_SERVICE.
Q4: Explain what fsGroup: 2000 does. A container runs as UID 1000 and GID 3000 — can it read files in a PVC mounted volume?
What fsGroup: 2000 does:
- Kubelet recursively changes group ownership of all files in all mounted volumes to GID 2000
- Sets the setgid bit on directories (new files automatically inherit GID 2000)
- Adds GID 2000 to the container process’s supplemental groups
Can the container (UID 1000, GID 3000) read the files?
Yes! The process’s supplemental groups include the fsGroup (2000):
$ id
uid=1000 gid=3000 groups=3000,2000
^^^^
fsGroup added as supplemental group
Since the files in the volume are owned by GID 2000 and the process is a member of GID 2000 (supplemental), the process can read and write the files (assuming the file permissions allow group access, e.g., rw-rw----).
Without fsGroup, the volume files would retain their original ownership and the container process (UID 1000, GID 3000) might not have access.
Q5: Write a complete Pod spec for a Java application that passes the Restricted PSS level. The app writes logs to /app/logs and temp files to /tmp. It needs to bind to port 8443.
apiVersion: v1
kind: Pod
metadata:
name: java-app
namespace: production
labels:
app: java-backend
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
automountServiceAccountToken: false
containers:
- name: java
image: eclipse-temurin:21-jre-alpine
command: ["java", "-jar", "/app/app.jar"]
ports:
- containerPort: 8443
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
- name: logs
mountPath: /app/logs
volumes:
- name: tmp
emptyDir: {}
- name: logs
emptyDir: {}
Restricted compliance checklist:
runAsNonRoot: true✓runAsUser: 1000(non-zero) ✓seccompProfile.type: RuntimeDefault✓allowPrivilegeEscalation: false✓capabilities.drop: ["ALL"]✓- No
hostPath,hostNetwork,hostPID,privileged✓ readOnlyRootFilesystem: truewithemptyDirfor writable dirs ✓- Port 8443 is above 1024, so
NET_BIND_SERVICEcapability is not needed ✓
| Previous | Up | Next |
|---|---|---|
| Pod Security Admission | Pod Security | Runtime Sandboxing |