
If you run a Kubernetes cluster today, there's a good chance any pod can talk to any other pod, in any namespace, on any port. That's the default. Most teams never change it, and most teams who say "we're covered because we run a service mesh" haven't actually checked what that mesh is enforcing.
What NetworkPolicy actually is
NetworkPolicy is a built-in Kubernetes API object that controls which pods can send or receive traffic, and from/to where. It is not a separate product and it is not Kyverno, Calico, or Cilium policy — those are optional engines that can enforce the standard NetworkPolicy object (or their own extended CRDs), but the object itself ships in the core Kubernetes API (networking.k8s.io/v1).
Here's the part that trips people up: creating a NetworkPolicy object does nothing by itself. Kubernetes only defines the API — it's your Container Network Interface (CNI) plugin that has to actually read these objects and drop packets accordingly. Flannel, in its default configuration, does not enforce NetworkPolicy at all. If your CNI doesn't support it, every NetworkPolicy you write is a no-op, silently.
Check whether your CNI even enforces policy
Before writing a single policy, confirm enforcement works:
# Quick sanity check: deploy two pods and try to block traffic between them
kubectl run netpol-test --image=busybox:1.36 --restart=Never -- sleep 3600
kubectl run netpol-target --image=nginx:1.27 --restart=Never --labels="app=target"
kubectl expose pod netpol-target --port=80
# From a shell in netpol-test, curl the target
kubectl exec netpol-test -- wget -qO- --timeout=2 netpol-target
If that works, apply a deny-all policy (below) and run the same wget again. If it still succeeds, your CNI is not enforcing NetworkPolicy, and nothing else in this post will help until you fix that layer — switch to Calico, Cilium, or another CNI with enforcement support, or confirm your managed cluster's networking add-on has policy enforcement turned on (EKS needs the Amazon VPC CNI network policy agent or Calico; GKE needs Dataplane V2 or Calico; AKS needs Azure CNI with Calico or Cilium).
The default posture: allow-all
With no NetworkPolicy objects in a namespace, every pod can reach every other pod across the entire cluster, including pods in other namespaces, as long as the CNI allows it (which it does, by default). There is no built-in segmentation between a frontend namespace and a namespace holding a database, a Redis cache, or an internal admin API. A single compromised pod — through a vulnerable dependency, an exposed debug endpoint, or a leaked credential — can scan and connect to anything else east-west inside the cluster.
This matters because the most common actual breach path inside Kubernetes isn't "attacker breaks into the control plane." It's "attacker compromises one low-value workload, then moves laterally to something that actually has data." Default-deny network policy is the single control that most directly blocks that movement.
Why "we have a service mesh" is not an answer
A service mesh like Istio or Linkerd operates at Layer 7, mostly through sidecar proxies, and its traffic rules (AuthorizationPolicy in Istio, for example) apply to traffic that goes through the mesh's proxies. That has real gaps relevant here:
- Mesh coverage is often partial. Namespaces without sidecar injection enabled are outside mesh enforcement entirely. New namespaces, debug pods, jobs, and anything deployed before injection was turned on commonly fall outside it.
- A pod can usually still reach the raw Pod IP or a Service's ClusterIP directly, bypassing the proxy's enforcement in some mesh configurations, depending on how traffic capture (iptables/eBPF redirection) is set up.
- Mesh policy is an application-layer control; NetworkPolicy is a network-layer control. They answer different threat models. A mesh can authenticate "service A is allowed to call service B's endpoint" with mTLS identity. NetworkPolicy answers "can any packet from this set of pods even reach this set of pods at all," which is the layer that stops a compromised pod from port-scanning the cluster or reaching something the mesh never saw configured.
- Non-mesh traffic exists. DNS lookups, metrics scraping, health checks from kubelet, and traffic to cloud metadata endpoints (like the infamous 169.254.169.254) typically aren't mesh-mediated at all.
Defense in depth means both. A mesh doesn't replace default-deny NetworkPolicy any more than a web application firewall replaces input validation.
Step 1 — Default-deny all ingress and egress, per namespace
Start with the two policies that establish the baseline. Apply this in every namespace you care about:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: payments
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
An empty podSelector: {} means "selects every pod in this namespace." With no ingress or egress rules listed, but both policyTypes declared, the effect is: no traffic in, no traffic out, for every pod in payments, unless another policy explicitly allows it. NetworkPolicies are additive — multiple policies that select the same pod are combined with a logical OR, so you build this up incrementally by adding more specific "allow" policies alongside the deny-all.
Step 2 — Allow only what you need, explicitly
With default-deny in place, nothing works until you add allow rules. Start with DNS, since almost every pod needs it:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: payments
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector: {}
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
Next, allow a specific app-to-app path. Say your api pods need to reach a postgres pod on port 5432, and nothing else should:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-to-postgres
namespace: payments
spec:
podSelector:
matchLabels:
app: postgres
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: api
ports:
- protocol: TCP
port: 5432
Note this policy is attached to the postgres pods (it governs their ingress). You typically need a matching egress policy on the api side too, since you set policyTypes: [Ingress, Egress] in the deny-all:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-egress-to-postgres
namespace: payments
spec:
podSelector:
matchLabels:
app: api
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- protocol: TCP
port: 5432
Ingress and egress are evaluated independently and both sides generally need to agree for traffic to flow: the sender's egress rules and the receiver's ingress rules.
Step 3 — Cross-namespace traffic
Pods often need to reach something in another namespace — a shared logging stack, an ingress controller, or a platform API. Combine namespaceSelector with podSelector:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-ingress-controller
namespace: payments
spec:
podSelector:
matchLabels:
app: 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
Kubernetes automatically labels every namespace with kubernetes.io/metadata.name matching its own name (since 1.21+), which makes this kind of cross-namespace targeting reliable without having to hand-label namespaces yourself. When a rule lists both namespaceSelector and podSelector inside the same block, they're ANDed together — the source must match both.
Step 4 — Don't forget egress to the internet and cloud metadata
A commonly missed case: blocking egress to the cloud provider's instance metadata service. If an attacker compromises a pod and that pod can reach 169.254.169.254, they may be able to pull IAM/role credentials for the node. Deny it explicitly even under default-deny, since some CNI configurations treat link-local ranges specially:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-metadata-egress
namespace: payments
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 169.254.169.254/32
This says "allow egress to anywhere except the metadata address" — you'd combine it with your more specific allow rules rather than relying on this alone, since a blanket allow-to-everywhere-except-X is still broad. In most production setups, you want specific egress allow rules (to your database, your API dependencies, your DNS resolver) and no general internet egress rule at all.
Common pitfalls
- Forgetting DNS egress. This is the single most common cause of "my app broke after I added default-deny." Every pod needs port 53 UDP/TCP egress to resolve service names.
- Applying deny-all without a rollout plan. Apply allow rules for all the traffic you need before flipping to deny-all in a live namespace, or do it in a maintenance window and watch logs.
- Assuming NetworkPolicy spans clusters or works with hostNetwork pods. NetworkPolicy only governs pod network traffic within one cluster. Pods using
hostNetwork: truebypass it because they use the node's network namespace directly. - Mixing up ingress and egress direction. Ingress rules live on the receiving pod's policy and describe allowed sources. Egress rules live on the sending pod's policy and describe allowed destinations. Both commonly need a matching rule for traffic to flow.
- Thinking a mesh's mTLS settings are a substitute. As covered above, they operate at a different layer and often have coverage gaps.
Troubleshooting
- Traffic blocked that should be allowed: use
kubectl describe networkpolicy <name> -n <namespace>to confirm the selectors match the labels you think they do — a typo in a label value is the most common cause. Runkubectl get pods --show-labels -n <namespace>to double check. - Not sure if enforcement is even active: check your CNI's controller logs (for Calico,
calico-node; for Cilium,cilium-agent) for errors processing policy objects. - Need to see what's actually being dropped: Cilium exposes Hubble, which can show denied flows in near real time; Calico has
calicoctland flow logs through its enterprise tooling or via the open-source policy controller's logs. - Policy seems to apply to the wrong pods: remember an empty
{}selector matches everything in the namespace, which is easy to apply by accident when you meant to scope it.
Where to go next
Once default-deny is in place namespace by namespace, look at tooling that generates policies from observed traffic (Cilium's policy editor, or network policy recommendation engines in some commercial tools) so you're not hand-writing every rule from scratch. Then look at whether your CNI supports Layer 7 policy (Cilium does, via its CiliumNetworkPolicy CRD) if you need to restrict traffic by HTTP path or method, not just IP and port.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


