Skip to main content

Kubernetes Security: Securing Container Orchestration

Published: March 12, 2026 Updated: May 8, 2026 Larry Qu 34 min read

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

👍 Was this article helpful?