The Default Kubernetes Trust Problem
A freshly provisioned Kubernetes cluster has no NetworkPolicy resources. That means every pod can send traffic to every other pod — across namespaces, across applications, across environments. If an attacker compromises a single pod (via a vulnerable application, a supply chain implant, or a stolen container image), they have immediate network reachability to your databases, internal APIs, and other workloads.
This is not a theoretical risk. Container escape and pod-to-pod lateral movement are well-documented in real-world Kubernetes attacks. The 2020 Tesla cryptomining incident, the 2022 TraderTraitor Kubernetes compromises, and multiple ransomware attacks against container environments all involved lateral movement across workloads that had no network isolation between them.
NetworkPolicy resources let you define what traffic is permitted between pods — but there’s a critical prerequisite: your CNI plugin must enforce them. The vanilla Kubernetes kubenet plugin does not. You need Calico, Cilium, Weave Net, or a cloud-managed equivalent (AWS VPC CNI with policy enforcement, Azure CNI with Calico, GKE Dataplane V2 which uses Cilium).
NetworkPolicy Fundamentals
A NetworkPolicy selects pods using podSelector labels and defines allowed ingress and/or egress traffic. The key behaviours:
- A pod with no matching NetworkPolicy is non-isolated — all traffic is allowed
- A pod selected by any NetworkPolicy is isolated to the traffic explicitly permitted by that policy
- Policies are additive: multiple policies selecting the same pod combine their allow rules
The minimal structure:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: example-policy
namespace: production
spec:
podSelector:
matchLabels:
app: api-server
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
egress:
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- protocol: TCP
port: 5432
This policy: allows ingress to api-server pods only from frontend pods on port 8080; allows egress from api-server pods only to postgres pods on port 5432. All other traffic is denied.
Step 1: Default-Deny as the Foundation
Start with a namespace-wide default-deny policy for both ingress and egress. This ensures that any new pod without an explicit policy is isolated by default:
# Apply to every namespace where isolation is required
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {} # empty selector matches ALL pods in namespace
policyTypes:
- Ingress
- Egress
Apply this immediately to all application namespaces. Exempt system namespaces (kube-system) initially, but plan to harden them too.
Verify it works:
# Deploy a test pod and confirm it cannot reach another pod
kubectl run test-client --image=busybox --rm -it --restart=Never -- \
wget -T 3 -O- http://api-server.production.svc.cluster.local:8080
# Should time out after 3 seconds if NetworkPolicy is enforced
Step 2: Allow Required Traffic Explicitly
For each workload, create explicit allow policies. Think in terms of a minimal allow list:
# Allow frontend to reach api-server
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-api
namespace: production
spec:
podSelector:
matchLabels:
app: api-server
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
---
# Allow api-server to reach postgres
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-to-postgres
namespace: production
spec:
podSelector:
matchLabels:
app: postgres
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: api-server
ports:
- protocol: TCP
port: 5432
Step 3: DNS Egress — A Common Footgun
After applying default-deny egress, your pods will immediately lose DNS resolution. You must explicitly allow egress to kube-dns:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: production
spec:
podSelector: {} # all pods need DNS
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
Step 4: Cross-Namespace Policies
Use namespaceSelector when traffic crosses namespace boundaries — for example, an ingress controller in the ingress-nginx namespace sending traffic to application pods:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-controller
namespace: production
spec:
podSelector:
matchLabels:
app: frontend
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
podSelector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
ports:
- protocol: TCP
port: 80
Note the YAML structure: namespaceSelector and podSelector in the same list item (from) are AND conditions (both must match). In separate list items, they are OR conditions.
Step 5: Monitoring and Auditing NetworkPolicy Gaps
Find pods not covered by any NetworkPolicy
# List all pods in a namespace and check which have matching policies
kubectl get pods -n production -o json | jq -r '.items[].metadata.labels'
# Check which policies exist
kubectl get networkpolicies -n production -o wide
For at-scale auditing, use kube-network-policies-audit or netfetch CLI tools:
# netfetch — scan cluster for uncovered pods
docker run --rm -v ~/.kube:/root/.kube nettrace/netfetch scan --namespace production
Cilium Network Policy Observability
If using Cilium, enable Hubble for flow-level policy visibility:
# Install Hubble CLI
cilium hubble enable
# Watch denied flows in real time
hubble observe --namespace production --verdict DROPPED --follow
# Identify what traffic is hitting the default-deny
hubble observe --namespace production --verdict DROPPED \
--output jsonpb | jq '.flow | {src: .source.pod_name, dst: .destination.pod_name, port: .l4.TCP.destination_port}'
Cloud Provider Differences
| Provider | CNI Default | NetworkPolicy Enforcement |
|---|---|---|
| EKS | VPC CNI (no enforcement) | Must install Calico or enable VPC CNI network policy mode |
| AKS | Azure CNI (no enforcement) | Must select “Azure CNI with Calico” or “Cilium” at cluster creation |
| GKE | Standard (no enforcement) | Must enable Dataplane V2 (Cilium-based) or GKE Network Policy |
EKS — enable VPC CNI network policy mode:
aws eks update-addon \
--cluster-name my-cluster \
--addon-name vpc-cni \
--configuration-values '{"enableNetworkPolicy": "true"}'
GKE — enable Dataplane V2 at cluster creation:
gcloud container clusters create my-cluster \
--enable-dataplane-v2 \
--region us-central1
Common Mistakes
Forgetting egress policies: Default-deny ingress only is a half-measure. A compromised pod can still reach external attacker-controlled infrastructure if egress is unrestricted.
Using namespace labels that don’t exist: namespaceSelector silently fails (matches nothing) if the label doesn’t exist on the target namespace. Verify namespace labels with kubectl get namespaces --show-labels.
Not testing isolation: Apply the policy, then test connectivity from the intended paths (should work) and from unintended paths (should fail). Don’t assume the policy works until you’ve verified it blocks.
Permissive monitoring exceptions: Prometheus scrapers, service mesh sidecars, and log agents all need network access. Add explicit policies for them rather than disabling isolation for the whole namespace.
Terraform Snippet
resource "kubernetes_network_policy" "default_deny" {
metadata {
name = "default-deny-all"
namespace = kubernetes_namespace.production.metadata[0].name
}
spec {
pod_selector {}
policy_types = ["Ingress", "Egress"]
}
}
Kubernetes NetworkPolicy is a foundational zero-trust control, not a silver bullet — it operates at Layer 3/4 and doesn’t inspect application-layer content. Combine it with service mesh mTLS (Istio, Linkerd), pod security admission, and container runtime security (Falco) for defence in depth.