← Back to Blog

NetworkPolicy: Why Default Deny Doesn't Deny Egress

You wrote a default-deny NetworkPolicy. You ran a connectivity test. Pods can still reach the internet, hit the cloud metadata service, talk to other namespaces. The policy did not work. It probably is working as documented. The documentation is just not what you thought it said. Here is what NetworkPolicy actually controls, why "default deny" is a partial control, and the configuration that actually denies egress in production.

The mental model that is wrong

The mental model: "I applied a default-deny policy in this namespace, so no pod in the namespace can talk to anything unless I explicitly allow it."

Reality: NetworkPolicy is enforced by a CNI plugin (Calico, Cilium, Antrea, AWS VPC CNI, etc.). The Kubernetes API just stores the policy object. Whether and how it gets enforced depends entirely on which CNI you run.

Common surprises:

  • EKS with the default AWS VPC CNI does not enforce NetworkPolicy at all unless you also install Calico or use the AWS Network Policy add-on (released 2023, requires opt-in).
  • GKE Standard mode requires you to enable Network Policy at cluster creation. It is off by default.
  • AKS requires Calico or Azure NetworkPolicy add-on at cluster creation.
  • Self-managed clusters with Flannel or Cilium have NetworkPolicy support varying by version.

If your CNI does not enforce NetworkPolicy, your policy YAML is decorative. kubectl apply succeeds. The policy is stored. Nothing actually filters packets.

How to verify your CNI actually enforces policy

The simplest test:

# Apply a deny-all policy in a test namespace
kubectl create namespace netpol-test
kubectl apply -n netpol-test -f - <

If the curl succeeded, your "default deny" was decorative. Time to fix the CNI.

The "default deny" YAML that is incomplete

Even with a properly enforcing CNI, this YAML is incomplete:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-all-incomplete
  namespace: production
spec:
  podSelector: {}
  policyTypes:
  - Ingress

This denies all ingress to pods in the namespace. It does not deny egress. Pods can still call out to anywhere, including the cloud metadata service (a common path for credential theft via SSRF).

The complete deny-all needs both policy types listed:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-all
  namespace: production
spec:
  podSelector: {}
  policyTypes:
  - Ingress
  - Egress              # ← without this, egress is unrestricted

Listing the policy types under policyTypes AND not specifying any ingress or egress rules creates the actual deny-all behavior.

The DNS gotcha

You correctly applied default deny including egress. Pods can no longer resolve DNS. Half your services break because they cannot find each other.

You always need to allow DNS egress to kube-dns / CoreDNS:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: production
spec:
  podSelector: {}
  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

Apply this alongside the default-deny. Without it, name resolution breaks.

Blocking egress to the cloud metadata service

The cloud metadata service (169.254.169.254) is the most-attacked endpoint in cloud Kubernetes clusters. SSRF in your application can reach it and exfiltrate IAM credentials. Default deny + allow-DNS does not block this if your other policies allow general egress.

The explicit block:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-metadata-service
  namespace: production
spec:
  podSelector: {}
  policyTypes:
  - Egress
  egress:
  - to:
    - ipBlock:
        cidr: 0.0.0.0/0
        except:
        - 169.254.169.254/32   # ← AWS, GCP, Azure metadata
        - 169.254.170.0/23     # ← AWS ECS task metadata + IPv6 fe80::a9fe:a9fe variants
        - 100.100.100.200/32   # ← Alibaba Cloud
    ports:
    - protocol: TCP
      port: 443
    - protocol: TCP
      port: 80

Combined with IMDSv2 enforcement at the EC2/instance level (which requires session tokens for metadata access), this stops the SSRF-to-IAM-credential attack chain.

Note: Calico and Cilium have richer egress filtering than the upstream NetworkPolicy spec. With Calico's GlobalNetworkPolicy or Cilium's CiliumClusterwideNetworkPolicy, you can express the metadata-block rule cluster-wide instead of per-namespace.

The full production-ready set

A typical production namespace needs four policies:

  1. deny-all: deny all ingress and egress by default.
  2. allow-dns-egress: allow DNS to kube-dns (mandatory for service discovery).
  3. allow-app-traffic: allow specific service-to-service communication based on labels.
  4. allow-egress-to-internet (filtered): allow HTTPS to the internet but exclude metadata service.

The order matters less than completeness. NetworkPolicies are additive: if any policy allows a connection, it is allowed (with one exception: Cilium/Calico have explicit deny rules that can override).

The "I have policies but my service is still wide open" diagnostic

If you wrote policies and traffic still flows where it should not:

  1. Verify the CNI enforces NetworkPolicy. Run the deny-all test from earlier.
  2. Verify both policy types are in the spec. policyTypes: [Ingress] alone does not deny egress.
  3. Check for namespace-level vs cluster-level policy conflicts. Calico's GlobalNetworkPolicy can override namespace policies.
  4. Check labels match. podSelector not matching means the policy applies to no pods.
  5. Verify CNI version. Older CNI versions had bugs in egress enforcement. Calico < 3.20 had several. Cilium pre-1.10 had different bugs.
  6. Check for hostNetwork pods. Pods with hostNetwork: true bypass NetworkPolicy entirely. Ingress controllers, monitoring agents, and CSI drivers often use hostNetwork.

The hostNetwork bypass is particularly insidious: a malicious pod with hostNetwork=true is invisible to NetworkPolicy. Restrict hostNetwork via Pod Security Admission policies (Restricted profile prohibits it).

Testing your policies are actually working

Manual testing:

# Test pod-to-pod denial
kubectl run -n production source --image=curlimages/curl --restart=Never -- sleep 3600
kubectl run -n production target --image=nginx --restart=Never
sleep 5

# Get the target IP
TARGET_IP=$(kubectl get pod -n production target -o jsonpath='{.status.podIP}')

# Try to reach it (should fail with default deny + no allow rule)
kubectl exec -n production source -- curl -s --max-time 3 http://$TARGET_IP

# Test pod-to-internet denial
kubectl exec -n production source -- curl -s --max-time 3 https://www.google.com

# Test metadata service
kubectl exec -n production source -- curl -s --max-time 3 http://169.254.169.254/latest/meta-data/iam/security-credentials/

Automated testing: tools like Kyverno can validate NetworkPolicies exist for every namespace. Cilium has its own connectivity test suite. Calico has policy preview that simulates policy effect before apply.

Recommended baseline policies for every cluster

Apply these to every namespace as a starting point, then layer in app-specific allows:

  1. default-deny-all: ingress + egress.
  2. allow-dns: to kube-system kube-dns on UDP/TCP 53.
  3. allow-kube-api: if your apps use the K8s API, to the apiserver on 443.
  4. deny-cloud-metadata: explicit deny on 169.254.169.254/32 even if other rules allow general egress.
  5. allow-internet-https (filtered): if apps need internet access, allow 0.0.0.0/0:443 except metadata, RFC1918 ranges (already blocked by ingress rules), and any other restricted ranges.

Securely share K8s admin credentials

Setting up cluster-wide NetworkPolicies typically requires sharing kubeconfig admin credentials. Share through zero-knowledge encryption with auto-expiring links instead of email.

Create Encrypted Paste

The bottom line

Default-deny NetworkPolicy in Kubernetes works only if (1) your CNI actually enforces it, (2) your policy spec includes both Ingress and Egress in policyTypes, (3) you allow DNS egress, and (4) you explicitly block the cloud metadata service. Most teams get one or two of these right and have decorative policies that do not actually deny lateral movement. Run the deny-all test today on a non-production namespace; the answer will tell you whether your cluster's network controls are real or theatrical.

Related reading: Kubernetes Security Best Practices, Debug Kubernetes Pods Without Exposing Secrets, Kubernetes Secrets Management, Kubernetes Ingress Explained, and AWS IAM Permission Boundaries.