Safeguard
Kubernetes Security

Default-deny NetworkPolicy: closing the pod-to-pod gap in Kubernetes

Kubernetes pods allow all traffic by default, and many clusters' NetworkPolicy YAML silently does nothing because the CNI plugin never enforces it.

Safeguard Research Team
Research
6 min read

A freshly created Kubernetes pod can talk to every other pod in the cluster, on any port, in any namespace — and it stays that way until a NetworkPolicy object explicitly selects it. That single design decision, documented plainly in the upstream Kubernetes docs, is the root of one of the most common and least visible misconfigurations in production clusters: teams write NetworkPolicy YAML, apply it with kubectl apply -f, watch it get accepted with no error, and assume pod-to-pod traffic is now locked down — when in fact nothing changed at all. The reason is that NetworkPolicy is only an API object; it has zero effect unless the cluster's CNI (Container Network Interface) plugin implements enforcement for it. Flannel, one of the most widely deployed CNIs — especially in kubeadm-based quickstarts and older development clusters — deliberately ships pod networking only, with no policy enforcement layer, and Kubernetes will not warn you that your policy is a no-op. This piece breaks down why the default posture is "allow all," which common setups leave that default silently in place, and how to design a default-deny NetworkPolicy scheme that actually restricts pod-to-pod traffic.

Why is pod-to-pod traffic allowed by default in Kubernetes?

Kubernetes was designed around flat, fully-routable pod networking, so every pod gets an IP that any other pod in the cluster can reach unless something explicitly says otherwise. The official Kubernetes documentation states this directly: pods are non-isolated by default, accepting all ingress and egress traffic, and a pod only becomes isolated for a given direction once at least one NetworkPolicy selects it in that direction. Critically, isolation is scoped per-direction and per-pod — applying an ingress-only policy to a pod does nothing to restrict its egress, and a pod with no matching policy at all remains fully open in both directions no matter how many policies exist elsewhere in the cluster. This model is workable at small scale, but in namespaces running dozens of unrelated services, it means a compromised low-privilege pod (say, a public-facing web frontend) can reach an internal payments service, a metrics database, or the Kubernetes API server directly, with nothing but application-layer auth standing in the way.

What makes a NetworkPolicy silently do nothing?

A NetworkPolicy is enforced entirely by the CNI plugin, not by the Kubernetes API server or kube-apiserver validation — the object is simply stored in etcd and watched, and it's up to the networking plugin to read it and program packet filtering rules accordingly. The Kubernetes documentation on network plugins is explicit that plugins vary in NetworkPolicy support, and applying a policy against a non-enforcing plugin produces no error, no event, and no admission warning. Flannel is the clearest recurring example: it provides VXLAN or host-gateway overlay networking but does not implement policy enforcement, so teams historically pair it with Calico specifically to gain that capability, or migrate outright to Calico or Cilium, both of which natively enforce standard NetworkPolicy. Cilium adds eBPF-based enforcement plus a CiliumNetworkPolicy CRD for DNS- and L7-aware rules; Calico offers its own GlobalNetworkPolicy and policy tiers on top of the standard API. On managed services the trap looks different but produces the same result: AWS documents that the Amazon VPC CNI on EKS only enforces NetworkPolicy once network policy support is explicitly enabled in the add-on configuration — it is not on by default on every EKS cluster.

How do teams verify their CNI actually enforces policy?

The only reliable check is an empirical one: apply a deny-all NetworkPolicy in a disposable namespace and confirm that a kubectl exec connection test between two test pods is actually blocked, rather than trusting the plugin's marketing name. Documentation review matters first — the Kubernetes network plugins page and each CNI's own docs state plainly whether NetworkPolicy is supported (Flannel: no, by itself; Calico and Cilium: yes) — but configuration drift is common, since a cluster can run a policy-capable CNI with enforcement features left disabled, as with EKS's opt-in VPC CNI network policy support. Cloud providers' own EKS documentation walks through enabling the feature explicitly, which implies the inverse: clusters provisioned before that step, or by teams unaware of it, have "supported" CNIs silently not enforcing anything they've written. Given how invisible the failure mode is — accepted YAML, no warnings, full traffic still flowing — verifying enforcement should be a one-time gate before any NetworkPolicy rollout, and a recurring check after any CNI or managed add-on upgrade.

What does a correct default-deny design actually look like?

A correct baseline starts by denying all traffic and then explicitly allowing only what's needed, applied per namespace in both directions rather than one. The standard pattern is a NetworkPolicy with an empty podSelector: {} (selecting every pod in the namespace) and both Ingress and Egress listed under policyTypes, with no corresponding ingress: or egress: rules — the empty policy types with no allow rules is what produces deny-all in each direction. From there, teams add narrowly scoped policies that allow traffic by label selector: a frontend pod can reach a backend pod only if the backend's policy explicitly permits ingress from pods carrying the role: frontend label, on the specific port the service uses. A gap seen just as often as missing policies entirely is asymmetric coverage: a namespace gets an ingress-only default-deny policy and calls it done, leaving egress fully open — meaning a compromised pod still has an unrestricted path to exfiltrate data outward or reach unrelated internal services, even though inbound access looks locked down on a dashboard.

What should a rollout sequence look like in practice?

Rolling out default-deny against a live namespace without first mapping real traffic will break services, so the practical sequence is: confirm CNI enforcement, observe actual pod-to-pod flows (via existing service mesh telemetry, CNI flow logs, or a short-lived audit-mode policy where the CNI supports one, such as Cilium's policy audit mode), then write allow rules from that observed traffic before flipping the namespace to default-deny. Namespace-by-namespace rollout, starting with the least business-critical namespace, limits blast radius from an incorrect allow rule. Combining namespaceSelector and podSelector in the same rule (rather than either alone) keeps allow rules from accidentally matching same-named pods in unrelated namespaces — a subtle but real gap given that podSelector alone matches labels cluster-wide within the rule's own namespace scope unless namespace is also constrained. The endpoint of this process is not a single policy file but an explicit allow-list of every legitimate pod-to-pod flow in the cluster, which incidentally also produces a live map of service dependencies that most teams don't otherwise maintain.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.