How NetworkPolicy isolation works and who enforces it
A NetworkPolicy is a namespaced object that selects pods by label and lists the traffic those pods may send or receive. Pods start non-isolated: all ingress and egress is allowed. A pod becomes isolated for ingress only when at least one policy selects it and includes Ingress in policyTypes. The same applies to Egress. Isolation in one direction leaves the other direction open.
The core API has no deny rules. Every rule is an allow. Once a pod is isolated, the effective policy is the union of all policies that select it. A packet passes if any one of them allows it. As a result, you cannot write a policy that overrides another policy. "Deny" in core NetworkPolicy only means that a pod is isolated and no rule matches the traffic.
Kubernetes stores policies but does not enforce them. The CNI plugin's agent on each node watches the API server and turns policies into packet filtering on that node, for example iptables rules or eBPF programs. Calico and Cilium both enforce NetworkPolicy. If your CNI has no policy support, the API server still accepts the policies, and they have no effect.
Diagram: where enforcement happens
| Layer | Role | Enforces? |
|---|---|---|
| API server | Stores NetworkPolicy objects | No |
| CNI agent (one per node) | Watches policies and programs that node's datapath | Yes |
| Pods | Send and receive traffic through the node's datapath | No |
Confirm that your cluster enforces policies before you rely on any of them:
- Create a test namespace with a server pod and a Service named
web. - Apply an ingress policy to that namespace that selects all pods and contains no ingress rules.
- Run `kubectl run np-test --rm -it --restart=Never --image=curlimages/curl -- curl -m 3 from inside the same namespace.
The request should time out. If it succeeds, your CNI is not enforcing policies, and every policy in the cluster is doing nothing.
#!/usr/bin/env bash
# Check that the CNI actually enforces NetworkPolicy before trusting any policy.
NS=np-check
# 1. Test namespace with a server pod behind a Service named "web".
kubectl create namespace "$NS"
kubectl -n "$NS" create deployment web --image=nginx
kubectl -n "$NS" expose deployment web --port=80
kubectl -n "$NS" rollout status deployment/web
# Baseline: with no policy, pods are non-isolated, so this should print the nginx page.
kubectl -n "$NS" run np-test --rm -it --restart=Never \
--image=curlimages/curl -- curl -sS -m 3
# 2. Default-deny ingress: select every pod, list Ingress, define no ingress rules.
kubectl apply -f - <<'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: np-check
spec:
podSelector: {}
policyTypes:
- Ingress
EOF
# 3. Same request again. It should now time out after 3 seconds.
# If it still returns the nginx page, the CNI is not enforcing NetworkPolicy.
kubectl -n "$NS" run np-test --rm -it --restart=Never \
--image=curlimages/curl -- curl -sS -m 3
# Clean up the test namespace and everything in it.
kubectl delete namespace "$NS"
This script runs a curl test pod against a Service before and after a default-deny ingress policy is applied. If the request still succeeds after the policy is in place, the CNI plugin is not enforcing NetworkPolicy.
Why and when to use default deny, and when to hold off
Default deny changes the cluster's trust model. Without it, any compromised pod can reach every other pod, so one vulnerable dependency becomes a foothold for lateral movement. With it, a pod can only reach what a policy explicitly permits. That turns your service dependencies into reviewable manifests rather than tribal knowledge. The gains are largest in multi-tenant clusters, in regulated workloads that must show segmentation to auditors, and for platform teams that run shared clusters and cannot vouch for every tenant's code.
Hold off in three situations:
- Your CNI does not enforce policies. The policies will apply cleanly and do nothing, which gives you false confidence.
- Nobody can describe the namespace's traffic. You will find the missing allow rules during an outage.
- DNS, metrics scraping and log shipping paths are not yet mapped. These are the paths that break first.
Ingress and egress deny carry very different risk:
| Default-deny ingress | Default-deny egress | |
|---|---|---|
| What it blocks | Unsolicited inbound connections to pods | All outbound connections from pods |
| Typical breakage | Missing allows for callers, probes from other namespaces, metrics scrapers | DNS, cloud metadata endpoint, external APIs, package and image fetches at runtime |
| Blast radius of a mistake | The services that receive traffic | Nearly every pod, often immediately |
| Reasonable starting point | Yes | After you have mapped ingress and outbound dependencies |
Starting with ingress only is a sound choice. It delivers most of the lateral-movement protection, and its failures are easier to diagnose.
NetworkPolicy also has a limited scope. It works at L3/L4: it sees IPs, ports and pod labels, not identities, HTTP paths or methods. It complements mTLS and application-level authorization rather than replacing them.
Writing a namespace-wide default deny and restoring DNS
Start with a single policy that selects every pod in the namespace and allows nothing. The manifest sets podSelector: {}, which matches all pods in the namespace where the policy lives, including pods created later. It sets policyTypes to both Ingress and Egress and contains no ingress or egress rules. A pod selected for a direction with no matching allow rule is isolated in that direction, so this policy blocks all inbound and outbound traffic.
The policyTypes field matters more than it looks. If you omit it, Kubernetes always treats the policy as Ingress and adds Egress only when the policy contains egress rules. A rule-less "deny all" without policyTypes therefore denies ingress only, and egress stays open. Listing only Ingress has the same effect. Many teams assume they have locked down outbound traffic when they have not, so state both types explicitly.
Once egress is denied, pods can no longer resolve names, and almost everything breaks. The second policy also uses podSelector: {} with policyTypes: [Egress]. It contains one egress rule that allows UDP and TCP port 53. TCP is needed because DNS falls back to it for truncated or large responses. The destination is a single to entry that combines two selectors:
- A
namespaceSelectormatchingkubernetes.io/metadata.name: kube-system. The control plane sets this label automatically, so it cannot drift or be forgotten. - A
podSelectormatching the DNS pods, commonlyk8s-app: kube-dns. Check the labels in your cluster first.
Keep both selectors in the same list element. They then combine with AND, so the rule matches only DNS pods inside kube-system.
If your cluster runs a node-local DNS cache, pods may query a link-local address instead of the DNS pods. In that case the destination needs an ipBlock.
# 1) Default deny: selects every pod in the namespace (now and later)
# and isolates both directions. No rules = nothing allowed.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: payments # placeholder namespace
spec:
podSelector: {}
policyTypes: # list both, or egress stays open
- Ingress
- Egress
---
# 2) Restore DNS: allow egress on port 53 to the DNS pods only.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: payments
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
# One list element with both selectors = AND:
# pods labelled k8s-app=kube-dns inside kube-system.
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system # set by the control plane
podSelector:
matchLabels:
k8s-app: kube-dns # verify the DNS pod labels in your cluster
ports:
- protocol: UDP
port: 53
- protocol: TCP # fallback for truncated/large responses
port: 53
A namespace-wide default-deny policy covering both Ingress and Egress, plus a policy that restores DNS by allowing UDP and TCP port 53 egress only to the kube-dns pods in kube-system, selected with an ANDed namespaceSelector and podSelector.
Allowing service-to-service traffic without falling into the selector trap
A NetworkPolicy never references a Service. Selectors match pod labels, and ports refers to the port the container listens on, not the Service port. Traffic sent to a ClusterIP is translated to a pod IP and target port before policy is evaluated. If your api Service maps port 80 to container port 8080, the rule must allow 8080. A named container port such as http avoids the mismatch.
Frontend → api → database
With default deny on both ingress and egress, each hop needs two rules:
- api: ingress from pods labelled
app: frontendon 8080. - frontend: egress to
app: apion 8080. - database: ingress from
app: apion 5432. - api: egress to
app: databaseon 5432.
The sender's egress policy decides whether a packet may leave its pod. The receiver's ingress policy decides whether it may arrive. If either side lacks a rule, the connection fails. Return traffic for an allowed connection is permitted automatically, so you do not write rules for replies.
The one-dash trap
When the caller lives in another namespace, the indentation of the from block changes the rule's meaning:
| YAML shape | List elements | Effect |
|---|---|---|
- namespaceSelector: {team: web} with podSelector: {app: frontend} indented beneath it |
One | AND: only frontend pods inside team: web namespaces |
- namespaceSelector: {team: web} and - podSelector: {app: frontend} as separate items |
Two | OR: every pod in team: web namespaces, plus frontend pods in the policy's own namespace |
The second form looks almost identical in review. It quietly admits every workload in the matched namespaces.
# AND: one list element. Admits only app=frontend pods running in namespaces labelled team=web.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-allow-frontend-and
namespace: shop
spec:
podSelector:
matchLabels: {app: api} # selects receiver pods by label, never a Service
policyTypes: ["Ingress"]
ingress:
- from:
- namespaceSelector:
matchLabels: {team: web}
podSelector: # no dash: same element as namespaceSelector, so ANDed
matchLabels: {app: frontend}
ports:
- protocol: TCP
port: 8080 # container targetPort, not the Service's port 80
---
# OR: two list elements. Admits EVERY pod in team=web namespaces,
# plus app=frontend pods in this policy's own namespace (shop).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-allow-frontend-or
namespace: shop
spec:
podSelector:
matchLabels: {app: api}
policyTypes: ["Ingress"]
ingress:
- from:
- namespaceSelector:
matchLabels: {team: web}
- podSelector: # extra dash: a new element, so ORed with the line above
matchLabels: {app: frontend}
ports:
- protocol: TCP
port: 8080
Two otherwise identical ingress policies for the api pods in which a single extra dash before podSelector turns 'frontend pods in team=web namespaces' (AND) into 'any pod in team=web namespaces, or frontend pods in shop' (OR).
Egress outside the cluster and the alternatives to core NetworkPolicy
For destinations outside the cluster, an egress rule uses ipBlock with a cidr and an optional except list that carves narrower ranges out of it. A typical use is allowing 0.0.0.0/0 while excluding the cloud metadata address and the internal RFC 1918 ranges. ipBlock is meant for cluster-external addresses. Pod IPs change whenever pods are rescheduled, and source or destination NAT can rewrite addresses before the policy engine sees them. How ipBlock behaves for in-cluster traffic is therefore implementation-dependent, so use pod and namespace selectors for internal traffic.
External IPs drift as well. SaaS APIs, managed databases and CDNs change addresses without notice. A rule pinned to yesterday's IPs breaks silently, and teams often respond by widening it to a large provider range or 0.0.0.0/0, which removes most of the protection the rule was meant to provide. The core API cannot help here. It has no FQDN-based rules, no explicit deny, no rule ordering or priority, and no cluster-wide default. Every policy is namespaced and purely additive.
| Option | DNS-name egress | Explicit deny / ordering | Cluster-wide policy | Portability cost |
|---|---|---|---|---|
| Core NetworkPolicy | No | No | No | None, works with any enforcing CNI |
| Calico CRDs | Yes, depending on edition | Yes | Yes | Manifests tied to Calico |
| Cilium CRDs | Yes (toFQDNs) |
Yes | Yes | Manifests tied to Cilium |
| Service-mesh authorization | Partly, through egress configuration | Yes, at L7 | Yes | Tied to the mesh and its sidecar or proxy model |
| Cloud firewall / egress gateway | Varies by provider | Yes | Yes, at the network edge | Tied to the cloud provider. Coarse, not per pod |
A CNI-specific CRD solves real problems, but switching CNIs later means rewriting those policies. Keep baseline isolation in core NetworkPolicy and use vendor CRDs only for the rules the core API cannot express.
# Egress from the 'payments' pods: DNS inside the cluster, HTTPS to the internet.
# Private ranges and the cloud metadata endpoint are carved out.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: payments-egress-external
namespace: payments
spec:
podSelector:
matchLabels:
app: payments-api
policyTypes:
- Egress
egress:
# In-cluster traffic uses selectors, not ipBlock. Pod IPs are not stable.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# External HTTPS. This allows any public IP because the core API cannot name
# an FQDN. The except list removes only the destinations we never want.
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 169.254.169.254/32 # cloud instance metadata
- 10.0.0.0/8 # RFC 1918 private ranges
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- protocol: TCP
port: 443
A core NetworkPolicy that allows DNS to kube-dns through selectors and allows HTTPS to external addresses through an ipBlock whose except list removes the metadata endpoint and the RFC 1918 ranges, which is as specific as the core API can get without FQDN rules.
A safe rollout process and the things that commonly break
Roll out in this order so that no workload is ever isolated before its allow rules exist:
- Inventory real traffic. Use flow logs from your CNI if it has them, or a service-mesh graph. Do not rely on design docs, because they miss cron jobs and sidecars.
- Apply allow policies first. They change nothing until something isolates the pod, so they are safe to merge early.
- Apply default deny last, one namespace at a time. This keeps any outage contained to one namespace.
- Test from ephemeral pods. Use
kubectl runwith the right labels, orkubectl execinto an existing pod, to check both allowed and denied paths. - Keep rollback trivial. Rollback means deleting the default-deny policy. Store all policies in version control next to the workloads they protect, so a revert is a single commit.
| Commonly broken | Why it breaks | How to allow it |
|---|---|---|
| Kubelet probes | Whether node-to-pod traffic is exempt varies by CNI | Verify on your CNI; if needed, allow node CIDRs with ipBlock |
| Ingress controller / load balancer | They run in another namespace; source NAT can replace client IPs with node IPs | Use namespaceSelector for the controller; don't rely on client ipBlock |
| Metrics scraping | The scraper runs in a monitoring namespace | Allow ingress from that namespace on the metrics port |
| Admission webhooks | The API server often sits outside the pod network | Allow the control-plane source IPs on the webhook port |
| Egress to the API server | Policies usually match post-DNAT endpoint IPs, not the service IP | Allow the API server endpoint IPs and port |
Test these edge cases on your own CNI. Check host-network pods, which usually bypass pod policy. Also check what happens to established connections when a policy changes: the spec leaves this undefined, so existing connections may survive a change that new connections would not.
Debugging blocked traffic and the impact on cost and engineering time
The first diagnostic step is to find which policies actually select the pod. Run kubectl get networkpolicy -n <namespace> and compare each policy's pod selector with the output of kubectl get pod <pod> --show-labels. Then run kubectl describe networkpolicy <name> on every match to read its rules. Allows are additive. Once any policy selects a pod for a direction, only traffic that some policy explicitly allows gets through.
Next, rule out failures that look like policy drops:
- Policy drop. This usually shows up as a timeout. "Connection refused" points elsewhere.
- DNS failure. If the hostname fails to resolve but the pod IP is reachable, DNS egress is missing.
- Service with no endpoints. If the Service's EndpointSlices are empty, the cause is a selector or readiness problem, not a policy.
- Wrong target port. Policies match the destination pod's port, not the Service port. A rule for port 80 won't admit traffic that a Service forwards to 8080.
For flow-level evidence, CNIs such as Calico and Cilium provide flow logs or policy-verdict views.
Cost and engineering time
Every new dependency now requires a policy change. Without clear owners, tests and naming conventions, teams end up with sprawl or quiet allow-all exceptions. The payoff is that dependencies are documented as code, incidents have a smaller blast radius, security reviews go faster, and namespace templates are reusable.
Illustrative estimate (assumed numbers, not a benchmark):
- Monthly cost = new dependencies × minutes per change. Assuming 10 dependencies at 30 minutes each, that is 300 minutes.
- Monthly savings = reviews × minutes saved per review. Assuming 6 reviews with 60 minutes saved each, that is 360 minutes.
Plug in your own numbers to see where you land.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to kubernetes networkpolicy default deny, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.