CKS — Security Contexts Deep Dive

13 minute read

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: true at 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:

  1. Kubernetes inspects the container image’s USER directive
  2. If the image runs as UID 0 (or has no USER set), the Pod is rejected
  3. If runAsUser is 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: true without runAsUser on an image that has no USER directive. The Pod will fail with Error: 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 mount emptyDir volumes 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: true is set, allowPrivilegeEscalation is forced to true

Best practice: Always set allowPrivilegeEscalation: false unless 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 securityContext applies to all containers (runAsUser, seccompProfile)
  • Each container (including init containers) must set its own allowPrivilegeEscalation, capabilities, and readOnlyRootFilesystem
  • Volume mounts with readOnly: true add 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: 2000 at the container level runs as UID 2000 (container-level overrides Pod-level)
  • All other containers without a container-level runAsUser run 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:

  1. Read the crash logs: kubectl logs <pod>
  2. Identify every path nginx tries to write to
  3. Mount an emptyDir at each writable path
  4. The root filesystem stays read-only — writes only go to ephemeral emptyDir volumes

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:

  1. Kubelet recursively changes group ownership of all files in all mounted volumes to GID 2000
  2. Sets the setgid bit on directories (new files automatically inherit GID 2000)
  3. 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: true with emptyDir for writable dirs ✓
  • Port 8443 is above 1024, so NET_BIND_SERVICE capability is not needed ✓

Previous Up Next
Pod Security Admission Pod Security Runtime Sandboxing

Updated: