Introduction
Kubernetes has become the de facto standard for container orchestration, but with its complexity comes significant security challenges. A misconfigured Kubernetes cluster can expose applications to attacks, data breaches, and compliance violations. In 2026, securing Kubernetes requires a defense-in-depth approach spanning multiple layers—from container images to runtime behavior.
This comprehensive guide explores Kubernetes security in depth, covering authentication, authorization, network policies, pod security, secrets management, and runtime protection.
Kubernetes Security Architecture
Defense in Depth
Kubernetes security cannot be won with a single control. Every layer of the stack—the container image, the cluster API, the underlying node, the pod itself, and the runtime behavior of processes—represents a separate attack surface, and a failure at any one of them can compromise the whole system. The defense-in-depth model therefore assumes that at least one layer will be breached and designs the others to catch what slips through. If a malicious image is scanned and rejected at admission time, runtime monitoring still watches for odd behavior in case a legitimate image is later exploited. If an attacker obtains pod access, network policies and RBAC limit how far they can pivot.
The diagram below arranges these controls into five layers, roughly in the order an attacker would encounter them. Image security is the outermost gate: scanning, minimal base images, signing, and private registries reduce the chance of running known-vulnerable or tampered software. Cluster security covers the control plane—RBAC, etcd encryption, API-server authentication, and network policies. Node security hardens the operating system and kernel that containers share. Pod security constrains what a workload may request from its own perspective. Finally, runtime security catches the behavior that static configuration cannot predict, using tools like Falco and seccomp.
Two principles follow from this stack. First, start at the bottom of the stack and work up: there is little point in adding runtime monitoring if anyone with a valid token can impersonate the admin. Second, treat the layers as complementary rather than optional extras. A cluster that enforces restricted pod policies but allows unrestricted egress is only half protected; the attacker who reaches a pod simply uses it as a relay to reach everything else.
┌─────────────────────────────────────────────────────────────────────┐
│ Kubernetes Security Layers │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 1. Image Security │ │
│ │ • Scan for vulnerabilities │ │
│ │ • Use minimal base images │ │
│ │ • Sign and verify images │ │
│ │ • Store images in secure registries │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 2. Cluster Security │ │
│ │ • RBAC for access control │ │
│ │ • etcd encryption │ │
│ │ • API server authentication │ │
│ │ • Network policies │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 3. Node Security │ │
│ │ • OS hardening │ │
│ │ • Kernel parameters │ │
│ │ • Container runtime security │ │
│ │ • Node access control │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 4. Pod Security │ │
│ │ • Pod Security Standards │ │
│ │ • Security contexts │ │
│ │ • Resource limits │ │
│ │ • Service account management │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 5. Runtime Security │ │
│ │ • Falco for behavioral monitoring │ │
│ │ • Seccomp profiles │ │
│ │ • AppArmor/SELinux │ │
│ │ • Admission controllers │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Security Context
A security context is the pod’s own declaration of how it is allowed to run, and Kubernetes
merges it with cluster-level admission policies. The example separates settings at two levels.
The pod-level securityContext applies to every container in the pod and to the pod’s volumes. Here it
forces a non-root user (UID 10000), a non-root group, and an fsGroup so mounted volumes are owned
by that unprivileged identity rather than root. The seccompProfile with type RuntimeDefault opts every container into
the container runtime’s default system-call filter, which blocks a large class of kernel
exploits without requiring you to author a profile.
The container-level securityContext tightens the individual workload. allowPrivilegeEscalation: false prevents processes from gaining
more privileges than their parent, which defeats a common escape technique. readOnlyRootFilesystem: true makes the
container’s root filesystem immutable, so an attacker who drops a binary or edits configuration
files finds nowhere to write; the explicit /tmp volume mount gives the app the only writable
space it needs. Dropping ALL capabilities removes tools such as NET_BIND_SERVICE and SYS_PTRACE that legitimate
processes rarely use but attackers love. Together with privileged: false, these settings implement the
essential container hardening baseline.
The trade-offs are practical. Non-root images must be built correctly—the base image’s user
must be UID 10000 or the container will fail to start. Read-only filesystems break applications
that assume they can write to /var or cache directories, so they typically require an audit of
where the app writes. Capability drops can break tools that need raw sockets or packet capture.
The resources block at the bottom is also a security control in disguise: limits prevent a
compromised container from exhausting node memory or CPU and starving its neighbors. In short,
a security context is where you encode “this workload should be boring” and force it to stay
boring.
# pod-security-context.yaml
apiVersion: v1
kind: Pod
metadata:
name: secure-pod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10000
runAsGroup: 10000
fsGroup: 10000
seccompProfile:
type: RuntimeDefault
sysctls:
- name: net.ipv4.ping_group_range
value: "0 0"
containers:
- name: app
image: secure-app:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
privileged: false
resources:
limits:
cpu: "500m"
memory: "256Mi"
requests:
cpu: "100m"
memory: "64Mi"
volumeMounts:
- name: tmp
mountPath: /tmp
Role-Based Access Control (RBAC)
ClusterRoles and Roles
RBAC is Kubernetes’ authorization model: after a user or workload is authenticated, RBAC
decides what it may do. It has three moving parts. Subjects are the identities—human users,
groups, and service accounts. Roles describe permissions, and can be namespaced (Role) or
cluster-scoped (ClusterRole). Bindings attach roles to subjects, again in either namespaced (RoleBinding) or
cluster-wide (ClusterRoleBinding) form. The mental model is: a binding says “this subject may exercise these
verbs on these resources,” and the effective permission is the union of everything the subject
is bound to.
The first manifest in this section is the admin-role ClusterRole, and it should be treated as a
cautionary example rather than a template. With wildcards on apiGroups, resources, and verbs, it grants every
action on every resource in the cluster—including the ability to read Secrets, delete
namespaces, and modify RBAC itself. Bind that to a human and you have effectively given away
the cluster. Such a role should exist only for the small set of cluster operators who genuinely
need it, and its use should be audited.
The developer-role demonstrates the opposite approach: least privilege. It is scoped to the development namespace
only, and each rule lists precisely the resources and verbs a developer needs to work on
applications. Note the details—developers can read pods/log but not delete pods or secrets; they can
create Deployments and Jobs but not modify their own Roles. The final RoleBinding attaches this role to
both a named user and the developers group in the same namespace. Because a Role and its binding are
both namespaced, a developer with this role can operate freely in development but has no access
whatsoever to production. This namespace-level isolation is the cheapest, most effective
access-control decision you can make.
# cluster-role-admin.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: admin-role
rules:
- apiGroups: ["*"]
resources: ["*"]
verbs: ["*"]
- nonResourceURLs: ["*"]
verbs: ["*"]
---
# role-developer.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: development
name: developer-role
rules:
- apiGroups: [""]
resources: ["pods", "services", "configmaps", "secrets"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get", "list"]
- apiGroups: ["apps"]
resources: ["deployments", "replicasets", "statefulsets"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: ["batch"]
resources: ["jobs", "cronjobs"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
---
# role-binding.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: developer-role-binding
namespace: development
subjects:
- kind: User
name: john.doe
apiGroup: rbac.authorization.k8s.io
- kind: Group
name: developers
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: developer-role
apiGroup: rbac.authorization.k8s.io
Service Account Best Practices
Service accounts are the identities that pods carry when they talk to the API server. Every pod
automatically gets a default service account that, in older clusters, carried broad
permissions—a classic footgun. The best practice shown here is to create a dedicated service
account for each application and bind it to the smallest role it needs. The first manifest
defines app-service-account in the production namespace for the payment service, giving that workload a distinct
identity from every other pod in the namespace.
The role in the middle is interesting because of how precisely it is scoped. Instead of
granting access to all Secrets, it uses resourceNames to restrict the role to the single secret payment-db-credentials and
the single configmap payment-config. This resource-name-level restriction means the service account cannot
enumerate or read any other secret in the namespace, even if a second one appears later.
Combined with a read-only get verb, the payment service can fetch exactly its own database
credentials and nothing else. The final RoleBinding ties the pieces together, binding the service
account to the role within the production namespace.
There are a few additional rules worth applying everywhere. Never bind cluster-admin or wildcard roles to a service account unless the workload truly manages the whole cluster. Prefer a separate service account per deployment so that a compromise of one application does not inherit the credentials of its neighbor. And remember that service accounts can also carry projected credentials for cloud provider identity—many teams pair this pattern with workload identity (IRSA or Workload Identity) so pods authenticate to AWS or GCP without storing static keys at all.
# service-account.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: app-service-account
namespace: production
annotations:
description: "Service account for payment service"
---
# role-service-account.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: production
name: service-account-role
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["payment-db-credentials"]
verbs: ["get"]
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["payment-config"]
verbs: ["get", "watch"]
---
# role-binding-service-account.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: service-account-binding
namespace: production
subjects:
- kind: ServiceAccount
name: app-service-account
namespace: production
roleRef:
kind: Role
name: service-account-role
apiGroup: rbac.authorization.k8s.io
Pod Security Standards
PSS Policies
Pod Security Standards (PSS) are the successor to the deprecated Pod Security Policies (PSP).
Instead of a monolithic policy object, PSS defines three standardized profiles—privileged, baseline, and
restricted—that cluster operators apply to namespaces via labels. The privileged profile allows everything
and is reserved for system workloads like the kube-proxy or CNI daemonsets. baseline blocks the most
dangerous requests (privileged containers, host network, host PID) while remaining permissive
enough for legacy applications. restricted is the hardened default that all modern workloads should
aim for.
The Python class in this section documents the two profiles you will most commonly configure.
The restricted policy carries the full battery of constraints: it forbids running as root, forbids
privileged containers, requires all capabilities to be dropped, forbids hostPath volumes, and
requires a seccomp profile (either RuntimeDefault or a custom one). Notice that the policy object includes
separate enforce, warn, and audit keys for the same profile. This is a deliberate design: you can
start by only warning and auditing in a namespace, observe what breaks, then flip enforce on once
the workloads comply. Rolling out restricted without that warning phase typically results in a storm
of blocked pods and frustrated developers.
The baseline profile is included because real clusters always have exceptions. Legacy applications,
operators, and monitoring daemons often cannot run under restricted without significant changes. The
class expresses this as an explicit allowed and forbidden list: baseline permits running as non-root and
limited capabilities, but still forbids privileged containers, host network, and host PID
access. The takeaway is to standardize on restricted for new workloads, treat baseline as a temporary
migration state for older ones, and reserve privileged for components that genuinely cannot run
otherwise.
class PodSecurityStandards:
@staticmethod
def restricted():
return {
"policy": {
"name": "restricted",
"description": "Most restrictive policy",
"enforce": "Restricted",
"enforce_version": "latest",
"warn": "Restricted",
"warn_version": "latest",
"audit": "Restricted",
"audit_version": "latest"
},
"restrictions": [
"Cannot run as root",
"Cannot use privileged containers",
"Must drop all capabilities",
"Cannot mount hostPath volumes",
"Must use seccomp profile or RuntimeDefault"
]
}
@staticmethod
def baseline():
return {
"policy": {
"name": "baseline",
"description": "Baseline policy with minimal restrictions",
"enforce": "Baseline",
"enforce_version": "latest"
},
"allowed": [
"Running as non-root user",
"Specific capabilities allowed",
"HostPath with restrictions"
],
"forbidden": [
"Privileged containers",
"Host network access",
"Host PID namespace"
]
}
Applying PSS
Applying a Pod Security Standard is deliberately simple: it is nothing more than labels on a
namespace. The first manifest labels the production namespace with enforce: restricted, plus matching audit and warn
labels, each pinned with an explicit -version. The enforce label causes the admission controller to
reject pods that violate the profile. The audit label records violations in the audit log, and
the warn label surfaces a warning to the user running kubectl apply—so even when enforcement is active,
operators still see when a workload is drifting from the standard.
The version labels deserve attention. Pod Security Standards evolve between Kubernetes
releases, and without pinning a version, the namespace inherits whatever the cluster’s default
policy version happens to be. An explicit enforce-version: latest makes the behavior predictable and prevents
surprise breakage when the control plane upgrades and tightens the default. In practice, teams
often pin a specific release rather than latest so that a cluster upgrade does not silently start
rejecting previously accepted pods.
The second manifest shows the exception mechanism. The privileged namespace applies only baseline, with no
audit or warn labels, giving workloads like node agents the looser rules they need while still
blocking the worst offenses. The important design point is that exceptions are explicit and
namespace-scoped: a privileged namespace is a visible, reviewable decision rather than an
invisible flag somewhere in the workload manifests. Pair this with an ownership review—ask why
a namespace needs baseline, document the reason, and set a reminder to revisit it.
# pss-namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
---
# pss-exception.yaml
apiVersion: v1
kind: Namespace
metadata:
name: privileged
labels:
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/enforce-version: latest
Network Policies
Basic Network Policies
Network policies are the firewall that Kubernetes itself does not enforce by default. Without any policy, every pod can reach every other pod on the cluster network—an all-open posture that makes a single compromised workload the entry point to the entire application. NetworkPolicies change that by attaching allow rules to pods selected by labels. Critically, they are additive but deny-by-default within their scope: once a policy selects a pod, anything not explicitly allowed is dropped.
The first manifest, default-deny-all, is the foundation every production namespace should start with. With
an empty podSelector and both Ingress and Egress in policyTypes, it selects every pod in the namespace and denies all
traffic in both directions. This is the sandbox: nothing in, nothing out, until you add
exceptions. The allow-dns-egress policy then reopens exactly one narrow hole—UDP and TCP port 53 to the
kube-dns pods in kube-system. DNS is the one dependency every workload shares, so it is the first
exception you will always need. The pairing of default-deny plus allow-DNS is the canonical
minimal network policy set.
The third policy shows how application traffic is restored. allow-api-to-db selects the database pods and
allows ingress on port 5432 only from pods labeled app: api. Everything else—other namespaces, other
pods, even the same pod—is still blocked. This is where the real security value lives: the
database becomes reachable by exactly one consumer instead of being a broadcast port on the
cluster network. The cost is operational discipline, since every new service-to-service path
requires an explicit policy, which is precisely the point.
# default-deny-all.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
# allow-dns-egress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: production
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# allow-app-communication.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-to-db
namespace: production
spec:
podSelector:
matchLabels:
app: database
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: api
ports:
- protocol: TCP
port: 5432
Advanced Network Policies
The complex policy in this section shows a realistic service in the middle of a larger mesh.
The payment-service pod enforces both directions of traffic. On ingress it accepts traffic only from the
api-gateway and admin-panel pods on port 8080, plus a second rule admitting the Prometheus pod from the monitoring
namespace on port 9090. On egress it is restricted to the PostgreSQL and Redis pods on their
respective ports, and to DNS. The service can therefore be reached by exactly the two frontends
it serves, be scraped by exactly its monitor, and reach exactly its two dependencies—nothing
else.
Two techniques in this policy are worth highlighting. First, the ingress rules use podSelector on both
sides, which keeps the policy entirely within the production namespace. Second, the Prometheus rule
combines a namespaceSelector with a podSelector: it matches pods labeled app: prometheus that also live in a namespace labeled
name: monitoring. This cross-namespace selector is how you allow monitoring or mesh components from other
namespaces without opening the whole namespace. The same pattern appears in the DNS egress
rule, which targets kube-dns in kube-system.
The takeaway is that network policies compose. You build a default-deny base, layer in DNS, then add narrow point-to-point rules per service. The trade-offs mirror the benefit: the policy count grows with service count, and debugging a dropped connection now involves checking the selectors match the actual pod labels. This is why teams pair policies with a network policy analyzer or a service mesh that visualizes allowed paths—because a policy that silently drops production traffic is only slightly less damaging than a policy that allows everything.
# complex-network-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: complex-app-policy
namespace: production
spec:
podSelector:
matchLabels:
app: payment-service
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: api-gateway
- podSelector:
matchLabels:
app: admin-panel
ports:
- protocol: TCP
port: 8080
- from:
- namespaceSelector:
matchLabels:
name: monitoring
podSelector:
matchLabels:
app: prometheus
ports:
- protocol: TCP
port: 9090
egress:
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- protocol: TCP
port: 5432
- to:
- podSelector:
matchLabels:
app: redis
ports:
- protocol: TCP
port: 6379
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
Secrets Management
Kubernetes Secrets
A Kubernetes Secret is just a resource that stores small amounts of sensitive data, and the
most important thing to understand about it is what it is not: it is not encrypted by default.
The values in a Secret are base64-encoded, which is an encoding, not encryption—anyone who can
read the Secret from the API server or from etcd can decode it in milliseconds. Secrets should
therefore be treated as a convenience for separating sensitive data from Pod specs, not as a
vault. The first manifest in this section shows the typical Opaque secret with a stringData block for
human-readable values and a data block for base64-encoded values.
The second manifest shows the kubernetes.io/tls type, which stores a certificate and private key for
terminating TLS on an Ingress or in a service mesh. The type matters because the Ingress
controller knows to look for the tls.crt and tls.key keys by convention. Note how short the key
material is in these examples—real certificates and keys are orders of magnitude longer, and
they are exactly the kind of thing that leaks when a repository is committed carelessly or a
backup is mishandled.
Practical guidance around Secrets boils down to a few rules. Enable encryption at rest for etcd
(via --encryption-provider-config) so the raw values are not readable from the datastore. Restrict access to Secrets
with RBAC at the resource-name level, as shown in the service account section, because get on
all secrets is effectively a cluster takeover. Never put secrets in Git, container images, or
ConfigMaps. And for anything beyond a few static values, use an external secrets manager—the
pattern shown next—so that rotation, audit, and storage all happen in a system built for it.
# secret-generic.yaml
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
namespace: production
type: Opaque
stringData:
database-url: postgres://user:password@db:5432/mydb
api-key: sk_live_xxxxxxxxxxxxx
jwt-secret: verylongsecretkey
data:
# base64 encoded values
username: YWRtaW4=
password: cGFzc3dvcmQ=
---
# secret-tls.yaml
apiVersion: v1
kind: Secret
metadata:
name: tls-secret
namespace: production
type: kubernetes.io/tls
data:
# base64 encoded
tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t...
tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t...
External Secrets Operator
For production-grade secret handling, teams move the source of truth out of Kubernetes entirely
and use the External Secrets Operator (ESO) to synchronize secrets from cloud providers like
AWS Secrets Manager, GCP Secret Manager, or HashiCorp Vault into the cluster. The first
manifest, an ExternalSecret, declares what to fetch and where to put it. The secretStoreRef points at a previously
configured store; refreshInterval: 1h makes the operator periodically re-sync, so when a secret rotates in AWS,
the Kubernetes Secret is updated without redeploying anything. The target block controls the name
of the resulting Secret and its creationPolicy. The data list maps remote keys and properties to individual
keys in the local Secret, so you can assemble one local Secret from several remote values.
The second manifest, a ClusterSecretStore, defines how the operator authenticates to AWS. The provider block
selects Secrets Manager in us-east-1 and authenticates with a jwt identity tied to a service account
(external-secrets-sa). This is the workload-identity pattern: instead of storing AWS access keys, the service
account’s projected token is exchanged for AWS credentials through IRSA. No static keys ever
land in the cluster, which removes a whole class of credential-leak problems.
The benefits of this architecture are substantial. Secrets are removed from Git history and from raw Kubernetes manifests, because the manifests reference the provider, not the value. Rotation becomes automatic: change the secret in AWS and the operator re-syncs within the refresh interval. Auditing shifts to the provider’s own logs, where every read is recorded. The trade-offs are operational: ESO itself is a privileged operator that needs access to the cloud APIs, so its own service account must be locked down, and a cloud outage or misconfiguration can temporarily leave pods without updated credentials. Used carefully, it turns secrets management from a manual chore into a declarative, self-healing pipeline.
# external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: aws-secrets-manager
namespace: production
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: SecretStore
target:
name: app-secrets-from-aws
creationPolicy: Owner
data:
- secretKey: database-password
remoteRef:
key: prod/database
property: password
- secretKey: api-key
remoteRef:
key: prod/api-keys
property: payment-api
---
# secret-store.yaml
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: aws-secrets-manager
spec:
provider:
aws:
service: SecretsManager
region: us-east-1
auth:
jwt:
serviceAccountRef:
name: external-secrets-sa
namespace: external-secrets
Admission Controllers
Validating Webhooks
Admission controllers are the last checkpoint before a request is persisted to etcd, and
validating webhooks are how you plug your own policy into that checkpoint. The configuration
below registers a webhook service that is called with every CREATE and UPDATE of a Pod in the
cluster. The clientConfig tells the API server where the webhook lives—a service named security-webhook in kube-system at the
path /validate-pod—and the caBundle holds the CA certificate that signs the webhook’s TLS certificate, so the
API server can verify who it is talking to. The webhook returns either an admission response
that allows the request or a denial with a message the user sees.
The remaining fields tune how the webhook behaves under failure, and they are easy to get
wrong. failurePolicy: Fail means that if the webhook service is unreachable, the API server rejects the request
rather than silently allowing it. For a security policy this is usually the right choice—fail
closed beats fail open—but it also means a downed webhook can take down the whole cluster,
which is why timeoutSeconds: 10 and careful deployment health matter. sideEffects: None tells the API server that calling
the webhook has no side effects, which is a requirement for dry-run and background requests.
The rules array narrows the webhook’s scope to exactly the operations, groups, and resources it
cares about.
The general design rule is that admission controllers should do one thing well. A validating webhook is ideal for enforcing policies that depend on multiple fields or external state—things the CRD schema and Pod Security Standards cannot express. Before writing a webhook, check whether the same policy can be expressed as a network policy, a Pod Security Standard, or a signed-image check; every custom webhook is a piece of always-on infrastructure you must maintain, secure, and make highly available.
# validating-webhook.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: pod-security-validation
webhooks:
- name: pods.security.example.com
clientConfig:
service:
name: security-webhook
namespace: kube-system
path: "/validate-pod"
caBundle: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t...
rules:
- operations: ["CREATE", "UPDATE"]
apiGroups: [""]
apiVersions: ["v1"]
resources: ["pods"]
admissionReviewVersions: ["v1", "v1beta1"]
sideEffects: None
failurePolicy: Fail
timeoutSeconds: 10
OPA Gatekeeper
Gatekeeper brings policy-as-code to Kubernetes by wrapping the Open Policy Agent (OPA) engine
behind admission controllers. Its model has two layers. A ConstraintTemplate is a reusable policy definition:
it declares a new constraint kind and embeds the actual decision logic written in Rego, OPA’s
query language. The template in this section defines the K8sRequiredLabels kind, whose schema accepts a labels
parameter. The second object, a Constraint, instantiates the template with concrete values—in this
case requiring the app and environment labels on every Pod, Deployment, and StatefulSet.
The Rego snippet in the template is where the policy lives, and it reads almost like an English
sentence: “there is a violation if the labels provided by the request, minus the labels
required by the parameters, is non-empty.” The operator provided := input.review.object.metadata.labels extracts the incoming object’s
labels; missing := required - provided computes the set difference; and if count(missing) > 0, a violation message lists which labels are
absent. This set-based formulation is typical of Rego and is why Gatekeeper can express
constraints that a static schema cannot—such as “every Deployment must have a securityContext”
or “image registry must be allowlisted.”
The appeal of Gatekeeper over hand-written webhooks is that policy becomes data. Constraints
live in Git, can be reviewed like any other manifest, and can be tested with opa test. Teams build
libraries of templates for common requirements—required labels, resource limits, disallowed
registries, forbidden hostPath mounts—and reuse them across clusters. The cost is a new
toolchain: your team must learn Rego, and the admission latency for every request grows
slightly as policies evaluate. Starting with a small set of read-only policies and expanding
from there is the safest adoption path.
# gatekeeper-constraint-template.yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8srequiredlabels
spec:
crd:
spec:
names:
kind: K8sRequiredLabels
validation:
openAPIV3Schema:
type: object
properties:
labels:
type: array
items:
type: string
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8srequiredlabels
violation[{"msg": msg, "details": required_labels}] {
provided := input.review.object.metadata.labels
required := input.parameters.labels
missing := required - provided
count(missing) > 0
msg := sprintf("Missing required labels: %v", [missing])
}
---
# gatekeeper-constraint.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata:
name: require-app-label
spec:
match:
kinds:
- apiGroups: [""]
kinds: ["Pod", "Deployment", "StatefulSet"]
parameters:
labels:
- app
- environment
Runtime Security
Falco Configuration
Falco is the runtime security layer of defense-in-depth: instead of inspecting manifests, it
watches actual system calls at the kernel level and flags behavior that no static policy could
have predicted. The configuration shown here is a ConfigMap that controls Falco itself. The falco.yaml
section enables JSON output and configures the rule files Falco loads—the stock rules, local
custom rules, and the Kubernetes audit rules that watch API-server activity. It also enables
the k8saudit plugin so Falco consumes the cluster’s audit stream in addition to raw syscalls. The
syscall_event_drops block sets a tolerance threshold: if more than ten percent of syscall events are dropped,
Falco logs and alerts, because a sensor that is silently missing events is worse than no sensor
at all.
The second file in the ConfigMap, falco_rules.local.yaml, holds custom rules, and the two examples illustrate how
Falco rules work. Each rule has a condition, an output template, a priority, and tags. The first rule detects
a shell spawned inside a container: it matches execve events where the process is one of the
common shells and the container is not the host (container.id != host). The second rule detects privilege
escalation by matching setuid, setgid, setresuid, and related syscalls. These are behavioral signatures—an
attacker who shells out of an application or runs sudo inside a container triggers them no
matter how the image was reviewed.
The power and the pain of Falco are the same thing: tuning. Default rules generate noise on legitimate workloads, and over-alerting trains everyone to ignore alerts. The practical approach is to start with Falco in a logging-only mode, let it observe a running cluster for a week, then promote the rules that reliably fire on true threats to alerting. Falco integrates with alerting backends (Slack, PagerDuty, SIEM) and with the Kubernetes audit stream for a complete picture of both kernel-level and API-level activity. It should be considered the last line of defense—it detects, but does not prevent; prevention is the job of the earlier layers.
# falco-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: falco-config
namespace: falco
data:
falco.yaml: |
json_output: true
program_priority: "INFO"
log_level: info
rules_file:
- /etc/falco/falco_rules.yaml
- /etc/falco/falco_rules.local.yaml
- /etc/falco/k8s_audit_rules.yaml
plugins:
- name: k8saudit
library_path: libk8saudit.so
init_config:
.
.
default_rules_for_emitter: false
syscall_event_drops:
threshold: .1
actions:
- log
- alert
priority: warning
# Custom rules
falco_rules.local.yaml: |
- rule: Detect shell in container
desc: A shell was spawned inside a container
condition: >
evt.type = execve and
container.id != host and
(proc.name in (sh, bash, zsh, dash) or
proc.name = "sh" or
proc.aname in (sh, bash, zsh, dash))
output: "Shell detected in container (user=%user.name container_id=%container.id shell=%proc.name)"
priority: WARNING
tags: [container, shell]
- rule: Detect privilege escalation
desc: Privilege escalation detected
condition: >
evt.type = setuid or evt.type = setgid or
evt.type = setxid or evt.type = setresuid or
evt.type = setfsuid or evt.type = setreuid
output: "Privilege escalation detected (user=%user.name uid=%user.uid)"
priority: CRITICAL
tags: [container, privilege]
Seccomp Profiles
Seccomp constrains which system calls a container’s processes are allowed to make. Every
syscall is a potential attack surface, so limiting the set reduces the kernel’s exposure to
whatever code an attacker manages to run. Kubernetes applies seccomp through the seccompProfile field on a
pod’s security context. The first manifest uses type: RuntimeDefault, which asks the container runtime to
install its sensible default filter—typically blocking a handful of dangerous syscalls while
remaining permissive enough for most software. This is the baseline recommendation and costs
almost nothing.
The second manifest opts out of the default and loads a custom profile from the node: type: Localhost with
localhostProfile: profiles/custom-seccomp.json, referencing a profile file placed on each node’s seccomp directory. The profile itself is
a JSON document with two fields that form a decision table. The defaultAction is SCMP_ACT_LOG, meaning any syscall
not listed below is logged rather than executed—an ideal mode for discovering what a workload
actually needs. The syscalls array then explicitly allows the ones the application relies on: the
basic I/O and lifecycle calls (read, write, close, exit_group) and the networking calls (connect, accept, listen,
sendto, recvfrom).
The migration path for a real workload is to start in SCMP_ACT_LOG mode, run the application, collect
the logged syscalls, and build the allowlist from what it actually uses. Once confident, flip
the defaultAction to SCMP_ACT_ERRNO, which denies unlisted syscalls outright. The trade-off is precision for
fragility: an allowlist that omits one syscall crashes the process at runtime, and different
language runtimes and libraries have very different syscall footprints. Because profiles must
exist on every node, most teams manage them with a DaemonSet or a tool that ships the profile
files to the node filesystem. Seccomp is the strongest per-pod kernel defense available, and it
complements Falco’s detection with real prevention.
# seccomp-profile.yaml
apiVersion: v1
kind: Pod
metadata:
name: workload-with-seccomp
spec:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/custom-seccomp.json
---
# custom-seccomp.json
{
"defaultAction": "SCMP_ACT_LOG",
"syscalls": [
{
"names": [
"read",
"write",
"close",
"exit_group"
],
"action": "SCMP_ACT_ALLOW"
},
{
"names": [
"connect",
"accept",
"listen",
"sendto",
"recvfrom"
],
"action": "SCMP_ACT_ALLOW"
}
]
}
Image Security
Image Scanning Pipeline
Container images are the code that actually executes, so securing them is the first line of
defense. Scanning is a point-in-time check that matches the contents of an image against a
vulnerability database—Trivy, shown here, is one of the most widely used open-source scanners.
The ImageSecurityScanner class wraps a scanner and models two things: the scan result and the policy applied to
it. The scan_image method returns a report broken down by severity (critical, high, medium, low)
together with a timestamp and an overall pass/fail verdict. The enforce_scan_policy method then implements a
gate: the build fails if there is a single critical vulnerability, or if there are more than
five high-severity ones.
This class is the engine of what should be a CI/CD gate. The scan runs at image-build time, and the policy check turns a pipeline non-zero if the image fails—so a vulnerable image is never pushed to the registry in the first place. Because the enforcement happens at build time, developers get feedback in minutes instead of finding out about a vulnerability in production. The example thresholds are deliberately strict on criticals and lenient on highs, which is a reasonable starting posture; teams usually tune the thresholds per image, allowing more slack for rarely-exploitable transitive dependencies.
The important limitation to design around is that scanning is a snapshot. Base images and the operating system packages inside them change constantly, and new vulnerabilities are disclosed daily, so an image that passed last week can be vulnerable today. Production teams therefore re-scan on a schedule, track the full image provenance, and sign images so the registry can guarantee they were not tampered with between build and run. Scanning answers the question “is this image currently known to be unsafe?"—it does not answer “is this image safe?"—which is why admission-time image policy, shown next, is the enforcement companion to this pipeline.
class ImageSecurityScanner:
def __init__(self):
self.scanner = "trivy"
def scan_image(self, image: str) -> dict:
return {
"image": image,
"vulnerabilities": {
"critical": 0,
"high": 2,
"medium": 5,
"low": 10
},
"scanned_at": "2026-03-12T10:00:00Z",
"scan_result": "FAILED"
}
def enforce_scan_policy(self, image: str) -> bool:
result = self.scan_image(image)
critical_threshold = 0
high_threshold = 5
if result["vulnerabilities"]["critical"] > critical_threshold:
return False
if result["vulnerabilities"]["high"] > high_threshold:
return False
return True
Image Policy Enforcement
Scanning in CI is necessary but not sufficient, because nothing prevents an operator from
bypassing the pipeline and deploying an image that was never scanned. Image policy enforcement
moves the gate to admission time, where it cannot be skipped. The first manifest configures the
Kubernetes ImagePolicy webhook: it matches repositories under registry.example.com and requires that the images
carry an annotation proving they were approved. Only images coming from a namespace labeled trusted
(typically the CI namespace that produced them) are accepted, which closes the hole where
someone deploys a hand-built image directly to the cluster.
The second manifest shows a complementary tool: Kubewarden. Instead of a webhook you write
yourself, Kubewarden runs policy modules—compiled WebAssembly programs—as admission
controllers, so policies can be authored in a language your team knows and distributed from a
registry like any container image. The example policy is require-readonly-rootfs, which enforces that Pods run with
a read-only root filesystem and as a non-root user. Configuring it is just a ClusterAdmissionPolicy manifest:
point at the policy module, set settings, and declare which resources and operations it governs.
The architecture worth noting is the split of responsibilities. CI scanning catches vulnerabilities early and cheaply; admission-time policy guarantees that only approved, compliant images ever reach a node; and runtime monitoring (Falco) catches what slips past both. Each gate is intentionally redundant with the others because no single check can be trusted alone. When combined with image signing and verification, these admission policies also stop a supply-chain attack in its tracks: even if an attacker can push a malicious image, the cluster will refuse it at the door.
# image-policy-webhook.yaml
apiVersion: imagepolicy.k8s.io/v1alpha1
kind: ImagePolicy
metadata:
name: image-policy
spec:
repositories:
- name: "registry.example.com/*"
policy:
requirement:
oneOf:
- namespaceLabel: trusted
template:
metadata:
annotations:
imagetag: ".*"
injectAnnotations:
- name: image.approved
---
# kubewarden-policy.yaml
apiVersion: policies.kubewarden.io/v1
kind: ClusterAdmissionPolicy
metadata:
name: require-readonly-rootfs
spec:
module: registry://ghcr.io/kubewarden/policies/readonly-rootfs-psp:v0.1.5
settings:
runAsNonRoot: true
rules:
- apiGroups: [""]
apiVersions: ["v1"]
resources: ["pods"]
operations: ["CREATE", "UPDATE"]
Best Practices
Security Checklist
The checklist consolidates every control in this article into a single audit tool. It is
organized by domain rather than by threat, which mirrors how a team actually runs: each area
maps to an owner and a review cadence. The cluster_setup group covers the control plane and identity
foundation—RBAC, etcd encryption at rest, dedicated service accounts, API-server
authentication, and audit logging. These are the controls that protect the cluster itself, and
they are the first things a penetration test or a compliance audit will ask about. The container_security
group covers what runs inside the cluster: minimal base images, scanning, non-root execution,
read-only filesystems, dropped capabilities, seccomp, and resource limits.
The remaining groups extend the model outward. runtime_security is the reactive layer—Falco, admission
controllers, Pod Security Standards, image signing, and secrets encryption—that catches what
configuration cannot predict. network_security governs lateral movement: NetworkPolicies, mTLS between
services, restricted egress, DNS policies, and service mesh encryption. Finally, monitoring closes the
loop by ensuring every layer produces events someone actually sees: audit logs, Falco alerts,
API-server request monitoring, pod security events, and anomaly alerting.
Two points make this checklist practical rather than aspirational. First, treat it as a baseline, not a ceiling—CIS Benchmarks, NIST, and cloud-provider hardening guides extend it considerably. Second, make each item verifiable rather than aspirational: “enable audit logging” only counts if you have a query that proves events are being collected and shipped. Teams that convert these bullets into automated checks, run them in CI, and re-run them on a schedule turn a checklist into a continuously enforced security posture.
KUBERNETES_SECURITY_CHECKLIST = {
"cluster_setup": [
"Enable RBAC and restrict permissions",
"Enable etcd encryption at rest",
"Use dedicated service accounts",
"Configure API server authentication",
"Enable audit logging",
"Secure etcd with TLS and authentication",
"Use network policies to isolate namespaces"
],
"container_security": [
"Use minimal base images",
"Scan images for vulnerabilities",
"Run containers as non-root user",
"Use read-only root filesystem",
"Drop all capabilities",
"Enable seccomp profiles",
"Set appropriate resource limits"
],
"runtime_security": [
"Enable runtime protection (Falco)",
"Implement admission controllers",
"Use Pod Security Standards",
"Enable image signing verification",
"Implement secrets encryption",
"Monitor for suspicious activity"
],
"network_security": [
"Implement NetworkPolicies",
"Use mTLS between services",
"Restrict egress traffic",
"Enable DNS policies",
"Use service mesh for traffic encryption"
],
"monitoring": [
"Enable audit logging",
"Set up Falco alerts",
"Monitor API server requests",
"Track pod security events",
"Implement alerting for anomalies"
]
}
Hardening Guide
The final section translates the checklist into concrete flags and settings for the components
themselves, following the CIS Kubernetes Benchmark. The api_server list hardens the brain of the
cluster. Disabling anonymous authentication (--anonymous-auth=false) removes an unauthenticated attack surface
entirely. --authorization-mode=Node,RBAC enables the two authorization modes that workloads and users need, and --enable-admission-plugins=NodeRestriction
prevents a compromised kubelet from modifying anything other than its own node’s objects. The
encryption configuration turns on at-rest encryption for Secrets, and the audit flags make the
API server write, size-limit, and rotate audit logs—the record of everything that happens,
which is also the raw material for Falco’s k8saudit integration. Finally, --request-timeout=60s keeps a slow
client from holding the control plane hostage.
The kubelet flags harden the node agent that runs on every machine. --anonymous-auth=false and --authorization-mode=Webhook mean the kubelet
only trusts requests authorized by the API server’s own RBAC, rather than blindly accepting
them. --client-ca-file pins the CA that signs legitimate clients, and --read-only-port=0 disables the insecure HTTP port
that historically allowed unauthenticated read access to the node. Two flags deserve special
attention: --protect-kernel-defaults=true refuses to start if the node’s kernel parameters deviate from safe defaults, and
--make-iptables-util-chains=true keeps the kubelet’s networking chains consistent.
The containerd section lists runtime hardening measures: enabling SELinux confinement, disabling the
live-restore feature that could bypass container isolation, and enabling user namespace
remapping so containers run with an unprivileged user mapping instead of
root-in-the-kernel’s-view. The pattern across all three components is the same—strip anonymous
access, restrict every default to the least privilege that still functions, and enable logging
that produces evidence. These flags are the difference between a cluster that merely looks
secure and one that has actually closed the doors most attackers walk through.
HARDENING_GUIDE = {
"api_server": [
"--anonymous-auth=false",
"--authorization-mode=Node,RBAC",
"--enable-admission-plugins=NodeRestriction",
"--encryption-provider-config=encryption-config",
"--audit-log-path=/var/log/kubernetes/audit.log",
"--audit-log-maxsize=100",
"--audit-log-maxbackup=10",
"--request-timeout=60s"
],
"kubelet": [
"--anonymous-auth=false",
"--authorization-mode=Webhook",
"--client-ca-file=/etc/kubernetes/pki/ca.crt",
"--read-only-port=0",
"--protect-kernel-defaults=true",
"--make-iptables-util-chains=true"
],
"containerd": [
"Enable selinux",
"Disable live restore",
"Use user namespace remapping",
"Configure container defaults"
]
}
Resources
Conclusion
Kubernetes security requires a comprehensive, defense-in-depth approach spanning the entire container lifecycle. From securing container images to implementing runtime protection, every layer must be carefully configured and continuously monitored.
Where to begin depends on where you are today. If you are starting fresh, enable RBAC, set a
default-deny network policy, apply the restricted Pod Security Standard to new namespaces, and run
your images as non-root from the first deployment. If you are hardening an existing cluster,
start with the audit: confirm that audit logging is on, that etcd is encrypted, and that no
service account carries wildcard permissions. From there, work outward through network
isolation, secrets management, admission policies, and runtime monitoring—each layer you add
reduces the surface the next incident can exploit. Security in Kubernetes is not a destination;
it is a posture you re-verify every time the cluster changes.
This guide covered authentication and authorization with RBAC, Pod Security Standards, network policies, secrets management, admission controllers, runtime security with Falco and seccomp, and image security best practices. By implementing these security measures, you can significantly reduce the attack surface of your Kubernetes clusters and protect your applications from common threats.
Comments