DevOps ·

Kubernetes Security Hardening in Production: Pod Security Standards, RBAC, and Runtime Threat Detection

Most Kubernetes clusters run with default security settings that are far too permissive. This guide covers practical hardening: Pod Security Standards, RBAC design for multi-team clusters, network policies, secrets encryption, image admission control with Kyverno, and runtime threat detection with Falco.

Kubernetes Security Hardening in Production: Pod Security Standards, RBAC, and Runtime Threat Detection

A freshly provisioned Kubernetes cluster is not secure. It is a blank slate with a generous threat surface: pods that can run as root, service accounts with cluster-wide permissions, inter-pod traffic that flows freely, secrets stored as base64 in etcd, and no runtime visibility into what processes are actually executing inside containers.

Teams that deploy Kubernetes operationally often treat hardening as a post-launch cleanup item. It rarely gets that second pass. The result is production clusters that have the appearance of a modern infrastructure platform but the security posture of a server someone set up and never locked down.

This article covers the mechanisms that actually matter for production hardening: Pod Security Standards (the replacement for the deprecated PodSecurityPolicy), RBAC design for multi-team clusters, network policy for microsegmentation, secrets encryption at rest, admission control with Kyverno, and runtime threat detection with Falco. Each section includes YAML you can use directly.

Pod Security Standards: The Replacement for PodSecurityPolicy

PodSecurityPolicy was removed in Kubernetes 1.25. The replacement is Pod Security Standards (PSS), enforced through the Pod Security Admission (PSA) controller, which is built into Kubernetes 1.23+ and stable from 1.25.

PSS defines three policy levels:

  • privileged: No restrictions. Equivalent to running as root on the node.
  • baseline: Blocks the most dangerous configurations (host network, host PID, privileged containers) while allowing common workloads.
  • restricted: Hardened profile. Requires non-root user, drops all capabilities, enforces seccomp, and blocks volume types that could be used for host access.

You apply these at the namespace level with labels:

apiVersion: v1
kind: Namespace
metadata:
  name: payments-service
  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

The enforce mode blocks non-compliant pods. The audit mode logs violations without blocking. The warn mode returns warnings to kubectl at apply time. Run audit and warn first on existing namespaces to surface violations before switching to enforce.

For workloads that must comply with restricted, the pod spec needs explicit configuration:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: payments-api
  namespace: payments-service
spec:
  replicas: 2
  selector:
    matchLabels:
      app: payments-api
  template:
    metadata:
      labels:
        app: payments-api
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 3000
        fsGroup: 2000
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: api
          image: payments-api:v1.4.2
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop:
                - ALL
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir: {}

The readOnlyRootFilesystem: true setting catches a surprising number of applications that write to disk without the team realizing it. Having a dedicated emptyDir volume for /tmp is the standard workaround. If a container fails with this setting, it is often writing logs, caches, or pid files to the image filesystem, which is itself a design problem worth fixing.

RBAC Design for Multi-Team Clusters

The failure mode in RBAC is almost always over-permission: a service account with cluster-admin, a developer role that can read secrets across namespaces, or a CI system that can deploy to any namespace. These happen because creating a permissive role is faster than reasoning about the minimum required permissions.

For multi-team clusters, the structure that works in practice is:

  1. Namespace-scoped roles per team for workload deployment and debugging
  2. ClusterRoles for shared read access to cluster-wide resources (nodes, persistent volumes) with strict bindings
  3. Separate service accounts per workload rather than a shared account per namespace
  4. No cluster-admin for human users except break-glass accounts

A minimal developer role looks like this:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: developer
  namespace: payments-service
rules:
  - apiGroups: [""]
    resources: ["pods", "pods/log", "pods/exec"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["apps"]
    resources: ["deployments", "replicasets"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["events"]
    verbs: ["get", "list", "watch"]

Notice what is absent: create, update, delete on deployments. Developers can read and debug but not push changes manually. That gate belongs to CI.

CI service accounts follow the same principle, scoped to what the pipeline actually needs:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: payments-ci
  namespace: payments-service
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: payments-ci-deployer
  namespace: payments-service
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "update", "patch"]
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: payments-ci-deployer
  namespace: payments-service
subjects:
  - kind: ServiceAccount
    name: payments-ci
    namespace: payments-service
roleRef:
  kind: Role
  name: payments-ci-deployer
  apiGroup: rbac.authorization.k8s.io

For auditing RBAC state across a cluster, kubectl auth can-i --list --as=system:serviceaccount:payments-service:payments-ci is useful, but the output is verbose. Tools like rbac-tool (from alcide-io) or rakkess give a cleaner matrix view of what each service account can actually do.

The secret-access problem deserves specific attention. Any role with get on secrets in a namespace can read all secrets in that namespace. If your CI service account can read secrets (often because someone added it to debug a deployment), it can read database credentials, API keys, and other secrets it has no business touching. Prefer using projected service account tokens and external secret stores (External Secrets Operator with AWS Secrets Manager or Vault) over native Kubernetes secrets for sensitive values.

Network Policies for Microsegmentation

By default, all pods in a Kubernetes cluster can communicate with all other pods, across namespaces. Network policies are the mechanism for restricting this, but they only work if the CNI plugin supports them. Calico, Cilium, and Weave Net support network policies. Flannel in its default configuration does not.

The safe default is a deny-all ingress and egress policy per namespace, with explicit allowances added for known traffic:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: payments-service
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress

Then add specific policies for what is actually needed:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: payments-api-ingress
  namespace: payments-service
spec:
  podSelector:
    matchLabels:
      app: payments-api
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-nginx
          podSelector:
            matchLabels:
              app.kubernetes.io/name: ingress-nginx
      ports:
        - protocol: TCP
          port: 8080
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: payments-api-egress
  namespace: payments-service
spec:
  podSelector:
    matchLabels:
      app: payments-api
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: database
          podSelector:
            matchLabels:
              app: postgres
      ports:
        - protocol: TCP
          port: 5432
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53

The DNS egress rule (port 53 to kube-system) is commonly forgotten on the first pass. Pods that cannot reach kube-dns will fail to resolve service names, which surfaces as connection refused errors that look like application bugs. Always include a DNS egress rule when locking down egress.

Cilium’s Network Policy extends the standard with L7 HTTP rules, allowing you to restrict not just which pods can communicate but which HTTP paths and methods are allowed. For services that talk to each other over known APIs, that is a meaningful additional constraint.

Secrets Encryption at Rest

Kubernetes stores secrets in etcd as base64-encoded data by default. Anyone with etcd access reads your secrets. Encryption at rest adds a layer so that etcd contains ciphertext rather than encoded plaintext.

The encryption configuration is set in the API server:

apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets
    providers:
      - aescbc:
          keys:
            - name: key1
              secret: <base64-encoded-32-byte-key>
      - identity: {}

The identity provider at the end is the fallback for decrypting existing unencrypted secrets. After enabling this configuration, existing secrets are not automatically re-encrypted. Run the following to force re-encryption:

kubectl get secrets --all-namespaces -o json | kubectl replace -f -

For production, the preferred approach is using a KMS provider (AWS KMS, Google Cloud KMS) rather than a static AES key. The KMS envelope encryption wraps a per-secret data encryption key with a KMS-managed key, which means the actual key material never touches etcd or the API server disk:

providers:
  - kms:
      name: aws-kms
      endpoint: unix:///var/run/kmsplugin/socket.sock
      cachesize: 1000
      timeout: 3s
  - identity: {}

The KMS plugin runs as a daemonset on control plane nodes and handles the encrypt/decrypt calls. AWS maintains the kubernetes-sigs/aws-encryption-provider plugin for EKS clusters.

Admission Control with Kyverno

Pod Security Admission handles baseline workload security, but policy enforcement for organizational standards (required labels, image registry allowlists, required resource limits) needs an admission webhook. Kyverno and OPA Gatekeeper are the two main options. Kyverno uses native Kubernetes YAML for policies, which makes it more approachable for teams already working in YAML.

An image registry allowlist policy:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: restrict-image-registries
spec:
  validationFailureAction: Enforce
  background: true
  rules:
    - name: validate-registries
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        message: "Images must come from approved registries: registry.company.com or gcr.io/company-project"
        pattern:
          spec:
            containers:
              - image: "registry.company.com/* | gcr.io/company-project/*"
            =(initContainers):
              - image: "registry.company.com/* | gcr.io/company-project/*"

A policy requiring resource limits (essential for preventing a single noisy pod from starving a node):

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-resource-limits
spec:
  validationFailureAction: Enforce
  background: true
  rules:
    - name: check-container-resources
      match:
        any:
          - resources:
              kinds:
                - Pod
              namespaces:
                - "!kube-system"
                - "!kube-public"
      validate:
        message: "All containers must have CPU and memory limits defined."
        pattern:
          spec:
            containers:
              - resources:
                  limits:
                    memory: "?*"
                    cpu: "?*"

Kyverno also supports generate rules (automatically create NetworkPolicies when a namespace is created) and mutate rules (add default labels, set image pull policies). The generate rule for default-deny network policy on namespace creation is worth setting up so new teams automatically get the right baseline.

Runtime Threat Detection with Falco

Hardening prevents many attack paths, but it does not cover post-exploitation: a compromised process, a container escape attempt, or a cryptominer that somehow got onto the cluster. Falco gives you visibility into what is actually happening at runtime, using eBPF or kernel module probes to watch system calls.

Falco ships with a default ruleset that covers a reasonable set of threat behaviors. The rules you care most about in production:

  • Shell spawned in a container that is not expected to have shell access
  • File writes to sensitive paths (/etc/passwd, /etc/cron.d, binary directories)
  • Network connections initiated from unexpected containers
  • Privileged container execution
  • Kubernetes API access from a container without a service account that should have API access

Installing Falco via Helm:

helm repo add falcosecurity https://falcosecurity.github.io/charts
helm repo update

helm install falco falcosecurity/falco \
  --namespace falco \
  --create-namespace \
  --set driver.kind=ebpf \
  --set falcosidekick.enabled=true \
  --set falcosidekick.config.slack.webhookurl=<your-webhook>

Custom rules target your specific workloads. A rule that alerts when any process attempts to write to /etc in a container that should be immutable:

- rule: Write to /etc in immutable container
  desc: Detect any write to /etc directory in containers tagged as immutable
  condition: >
    open_write and container and
    fd.name startswith /etc and
    k8s.pod.label.immutable = "true"
  output: >
    Write to /etc in immutable container
    (user=%user.name command=%proc.cmdline
     file=%fd.name pod=%k8s.pod.name
     namespace=%k8s.ns.name image=%container.image.repository)
  priority: WARNING
  tags: [filesystem, container, immutable]

Falco Sidekick routes alerts to Slack, PagerDuty, Elasticsearch, or any webhook. For production, route high-priority Falco alerts to your incident management system alongside your application alerts, not to a separate tool that gets checked quarterly.

Audit Logging

The Kubernetes API server generates an audit log of every API request. With audit logging off (the default on most managed clusters), you have no way to answer “which service account created that secret” or “who deleted the deployment” after the fact.

Audit policy configuration:

apiVersion: audit.k8s.io/v1
kind: Policy
rules:
  # Do not log reads of configmaps and secrets in kube-system
  - level: None
    resources:
      - group: ""
        resources: ["configmaps", "secrets"]
    namespaces: ["kube-system"]
    verbs: ["get", "list", "watch"]

  # Log secret access at the Metadata level (no secret values in logs)
  - level: Metadata
    resources:
      - group: ""
        resources: ["secrets"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]

  # Log all changes to RBAC resources at Request level
  - level: Request
    resources:
      - group: "rbac.authorization.k8s.io"
        resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]

  # Log pod exec and portforward at RequestResponse level
  - level: RequestResponse
    resources:
      - group: ""
        resources: ["pods/exec", "pods/portforward", "pods/attach"]

  # Default: log metadata for all other requests
  - level: Metadata

The Metadata level logs who did what and when, without logging request or response bodies. Use RequestResponse sparingly because secret values in request bodies will appear in logs unless you explicitly exclude them.

Ship audit logs to a separate, append-only store that your cluster’s service accounts cannot write to. If a compromised workload can also delete audit logs, the audit trail is worthless.

Tradeoffs: Hardening Approaches

ApproachProtectionOperational CostWhen to Skip
Pod Security Standards (restricted)High: blocks host access, privilege escalationMedium: requires fixing existing workloadsLegacy apps that cannot be easily modified; use baseline instead
Network Policy (default deny)High: blocks lateral movementMedium: requires mapping all legitimate trafficIf CNI does not support network policies
RBAC least-privilegeHigh: limits blast radius of compromised accountMedium: requires mapping permissions per team and workloadNever skip this
Secrets encryption at rest (KMS)Medium: protects against etcd dump, not API accessLow: transparent to workloads after setupDev clusters where etcd security is not a concern
Kyverno admission controlMedium: blocks non-compliant workloadsMedium: policy maintenance ongoingClusters with a single team where operational standards are enforced socially
Falco runtime detectionMedium: visibility, not preventionLow: runs as daemonset, alert tuning requiredNever skip in production; tune the rules but keep it running
Audit loggingLow: forensic only, not preventiveLow: API server flag, needs log shippingDev and staging; always on in production

Production Security Checklist

Before considering a cluster production-ready from a security standpoint:

Pod Security

  • Pod Security Admission enabled with enforce: restricted on all application namespaces
  • All containers run as non-root with explicit runAsUser
  • allowPrivilegeEscalation: false on all containers
  • capabilities: drop: ALL on all containers
  • readOnlyRootFilesystem: true on all containers (with explicit emptyDir for write paths)
  • Seccomp profile set to RuntimeDefault or a custom profile

RBAC

  • No human users with cluster-admin except break-glass accounts (audited separately)
  • Every workload has its own service account (no default service account usage)
  • CI service accounts scoped to specific namespaces and resources
  • automountServiceAccountToken: false on service accounts that do not call the API

Network

  • Default deny-all ingress and egress policy per namespace
  • Explicit allow policies for all known traffic paths
  • CNI plugin confirmed to support network policies

Secrets

  • etcd encryption at rest enabled with KMS provider
  • Existing secrets re-encrypted after enabling encryption config
  • Sensitive secrets in external secret store (Vault, AWS Secrets Manager) via External Secrets Operator

Admission Control

  • Image registry allowlist enforced via Kyverno ClusterPolicy
  • Resource limits required on all containers
  • Required labels enforced (team, environment, component)

Runtime

  • Falco deployed with eBPF driver
  • High-priority Falco alerts routed to incident management
  • Custom rules written for workload-specific threat behaviors

Audit

  • API server audit logging enabled with appropriate policy
  • Audit logs shipped to append-only external store
  • Audit log retention meets your compliance requirements

The Compounding Problem

Each of these controls addresses a different layer of the threat surface. None of them is sufficient on its own. A pod running as root with cluster-wide secrets access undermines your network policies. Perfect network segmentation does not help if an attacker compromises a service account with broad RBAC permissions. Runtime detection without audit logging means you can see that something happened but cannot reconstruct what led to it.

The practical approach is to implement them in order of implementation cost versus blast radius reduction. RBAC scoping and Pod Security Standards give the most protection for the least operational disruption. Network policies require mapping your actual traffic patterns, which is valuable even outside the security context. Falco and audit logging are largely transparent to workloads and should be on from day one.

The cluster that ships with all defaults is not secured. The cluster that ships with hardening applied incrementally, starting at day one, avoids the retrofit problem entirely. The retrofit is always harder.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
DevOps ·

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code

A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
DevOps ·

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
DevOps ·

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop

A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
DevOps ·

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution

A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.