Kubernetes networking with Cilium

Cilium WireGuard transparent encryption, the secure way

You flipped encryption.enabled to true, the agents restarted, and a slide now says "all traffic encrypted". Pull up a chair: WireGuard in Cilium encrypts exactly what its table says, and your node traffic, your first packets and your IPv6 are not on it by default.

The short answer

Enable Cilium WireGuard with node encryption, then turn on strict mode: egress strict mode with your pod CIDR so pod traffic never leaves a node unencrypted, and ingress strict mode so unencrypted pod traffic is dropped on arrival. Open UDP 51871 only between nodes, restrict who can edit CiliumNode objects, and verify on the wire.

Updated Houssam Hammoudi, CTOTested with Cilium 1.20.2 on Kubernetes 1.34.0 (kind, three nodes)

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

With WireGuard enabled, each Cilium agent creates a key pair, publishes its public key on the node's CiliumNode object, and builds a WireGuard tunnel to every other node. Pod traffic to a pod on another node goes through that tunnel. That part works well.

The problems are the edges of that sentence:

  • Only pods, by default. Traffic from host-network pods, from the node itself, and to the node is not in the tunnel unless node encryption is on. That includes kubelet, many monitoring agents, and anything using hostNetwork: true.
  • Not the first packets. Cilium decides to encrypt based on what it knows about the destination. A pod that just started on another node is unknown for a moment. Until the information arrives, Cilium treats that address as outside the cluster and sends in clear.
  • Not control plane nodes. Node-to-node encryption opts control plane nodes out, so workers never lock themselves out of the API server.
  • Not IPv6 under strict egress. The strict egress CIDR must be IPv4.
  • Not node traffic under strict ingress. Strict ingress drops unencrypted pod traffic only; node-to-node WireGuard traffic is not protected by it.
  • Not every load balancer path. North-south traffic redirected between nodes with XDP acceleration or DSR (outside Geneve) is not encrypted.
  • Keys are only as safe as CiliumNode. Each node trusts the public key it reads from its peers' CiliumNode objects. Whoever can edit those objects can change which key a node encrypts to.

An attacker with a capture point between nodes (a compromised node, a hypervisor, a mirrored switch port) reads whatever falls into those gaps.

What the docs say

By default, WireGuard-based encryption only encrypts traffic between Cilium-managed pods.

Source: Cilium docs, WireGuard Transparent Encryption

In such a case there is a time window during which Cilium will send out the initial packets unencrypted, as it has to assume the destination IP address is outside of the cluster.

Source: Cilium docs, Transparent Encryption

The pod CIDR and therefore the encryption strict mode egress CIDR must be IPv4. IPv6 traffic is not protected by the strict mode and can be leaked.

Source: Cilium docs, Transparent Encryption

Packets are not encrypted when they are destined to the same node from which they were sent. This behavior is intended.

Source: Cilium docs, WireGuard Transparent Encryption

The quick-start in the docs is two Helm values. The strict modes, which turn "encrypted when Cilium knows" into "dropped when not encrypted", live on a different page, and the CiliumNode key distribution is mentioned once without any advice on who may write those objects.

The secure configuration

1. Cilium Helm values.

yaml
# cilium-values.yaml
routingMode: native
ipv4NativeRoutingCIDR: 10.244.0.0/16   # the pod CIDR
autoDirectNodeRoutes: true
encryption:
  enabled: true
  type: wireguard
  nodeEncryption: true                  # also node-to-node, pod-to-node, node-to-pod (beta)
  strictMode:
    egress:
      enabled: true                     # pod traffic to the pod CIDR never leaves unencrypted
      cidr: 10.244.0.0/16
      allowRemoteNodeIdentities: false  # needs native routing and non-overlapping node and pod CIDRs
    ingress:
      enabled: true                     # drop pod traffic that did not arrive through WireGuard

These render as enable-wireguard: "true", encrypt-node: "true", enable-encryption-strict-mode-egress: "true", encryption-strict-egress-cidr, and enable-encryption-strict-mode-ingress: "true" in the cilium-config ConfigMap. Roll them out with a Helm upgrade, then restart the agents:

bash
helm upgrade cilium cilium/cilium --version 1.20.2 \
  --namespace kube-system --reset-then-reuse-values -f cilium-values.yaml
kubectl -n kube-system rollout restart daemonset/cilium
kubectl -n kube-system rollout status daemonset/cilium

If you use tunnel routing (VXLAN or Geneve), or your node and pod CIDRs overlap, the docs require allowRemoteNodeIdentities: true, which allows unencrypted traffic to and from node addresses. Prefer native routing with separate CIDRs.

2. Firewall. WireGuard listens on UDP 51871. In your cloud firewall or security groups, allow UDP 51871 from the node subnet to the node subnet, and from nowhere else.

3. Close the "unknown destination" window with egress policy. Strict egress covers the pod CIDR. For everything else, a pod that may only reach known external addresses cannot leak to a not-yet-known pod IP either:

yaml
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: egress-allowlist
  namespace: team-a
spec:
  endpointSelector: {}
  egress:
    - toEndpoints:
        - {}                                  # any pod in team-a
    - toEndpoints:
        - matchLabels:
            io.kubernetes.pod.namespace: kube-system
            k8s-app: kube-dns
      toPorts:
        - ports:
            - port: "53"
              protocol: UDP
            - port: "53"
              protocol: TCP
    - toCIDR:
        - 203.0.113.25/32                     # the one external API this namespace needs
      toPorts:
        - ports:
            - port: "443"
              protocol: TCP

4. Keep CiliumNode writable by Cilium only. Check which subjects can change it, and give everyone else a read-only role:

bash
kubectl auth can-i patch ciliumnodes.cilium.io [email protected]
kubectl auth can-i update ciliumnodes.cilium.io \
  --as=system:serviceaccount:ci:deployer
yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: ciliumnode-viewer
rules:
  - apiGroups: ["cilium.io"]
    resources: ["ciliumnodes"]
    verbs: ["get", "list", "watch"]

5. Keep IPv6 pod traffic in mind. Strict egress does not cover IPv6. On a dual-stack cluster, restrict IPv6 egress with policy or run IPv4-only pod networking until it does.

Prove it

Run on a three-node lab (one control plane, two workers) after the Helm upgrade above. Every block below is the real output.

1. WireGuard is on, with a peer for every other node:

bash
kubectl -n kube-system exec <cilium pod on each node> -c cilium-agent -- cilium-dbg status | grep Encryption
text
Encryption:              Wireguard       [NodeEncryption: Enabled, cilium_wg0 (Pubkey: aEJ8R2yd5m50yLLuMOL4WI96zHraM4RISqf589ZQMmM=, Port: 51871, Peers: 2)]
Encryption:              Wireguard       [NodeEncryption: OptedOut, cilium_wg0 (Pubkey: H2geelKPa395s0wF4+Ae4HL1/GVLFG1L8FsieKd7rAw=, Port: 51871, Peers: 2)]
Encryption:              Wireguard       [NodeEncryption: Enabled, cilium_wg0 (Pubkey: tcvZf/aR1sV6sULlhMAZC6+tkmre5qpNhBvACZ4V6Tg=, Port: 51871, Peers: 2)]

Three nodes, two peers each. The OptedOut line is the control plane: see the mistakes below.

2. Strict mode is in the running configuration:

bash
kubectl -n kube-system get configmap cilium-config -o yaml \
  | grep -E 'enable-wireguard|encrypt-node|strict'
text
  enable-encryption-strict-mode-egress: "true"
  enable-encryption-strict-mode-ingress: "true"
  enable-wireguard: "true"
  encrypt-node: "true"
  encryption-strict-egress-allow-remote-node-identities: "false"
  encryption-strict-egress-cidr: 10.244.0.0/16

3. Each peer has a key, an endpoint, the pod addresses of its node and a recent handshake:

bash
kubectl -n kube-system exec ds/cilium -c cilium-agent -- \
  cilium-dbg debuginfo --output json | jq .encryption
text
{
  "wireguard": {
    "interfaces": [
      {
        "listen-port": 51871,
        "name": "cilium_wg0",
        "peer-count": 2,
        "peers": [
          {
            "allowed-ips": [
              "172.18.0.4/32",
              "10.244.2.0/24",
              ...
            ],
            "endpoint": "172.18.0.4:51871",
            "last-handshake-time": "2026-09-24T22:49:49.477Z",
            "public-key": "aEJ8R2yd5m50yLLuMOL4WI96zHraM4RISqf589ZQMmM=",
            "transfer-rx": 1196,
            "transfer-tx": 1748
          },

4. Nothing readable on the wire between nodes. A pod on one worker calls a pod on the other worker on port 8080 with ?password=hunter2 in the URL, while tcpdump runs on the receiving node's eth0:

bash
tcpdump -ni eth0 -w wireguard-on.pcap 'tcp port 8080 or udp port 51871'
text
22:51:10.462900 IP 172.18.0.4.51871 > 172.18.0.2.51871: UDP, length 96
22:51:10.463131 IP 172.18.0.2.51871 > 172.18.0.4.51871: UDP, length 96
22:51:13.378575 IP 172.18.0.4.51871 > 172.18.0.2.51871: UDP, length 96
22:51:13.379023 IP 172.18.0.2.51871 > 172.18.0.4.51871: UDP, length 96
packets: 31, udp/51871: 31, tcp/8080 on eth0: 0, hunter2 visible: 0

Download the capture (pcap, 5 KB)

Every packet between the nodes is WireGuard on UDP 51871. On cilium_wg0, the inside of the tunnel, the same request is readable, as it should be:

text
22:51:34.785923 IP 10.244.2.145.34424 > 10.244.1.124.8080: Flags [P.], ... length 98: HTTP: GET /?password=hunter2 HTTP/1.1

Mistakes people make

Upgrading the chart with --reuse-values

helm upgrade --reuse-values keeps the values of the old chart version. When the new version adds keys, they arrive empty and the upgrade fails the new schema before anything changes:

text
Error: UPGRADE FAILED: values don't meet the specifications of the schema(s) in the following chart(s):
cilium:
- at '/endpointPolicyUpdateTimeoutDuration': got null, want string
- at '/encryption/ztunnel/image/digest': got null, want string

That was a 1.19.3 to 1.20.2 upgrade. Use --reset-then-reuse-values, which starts from the new chart's defaults and then applies your values.

Expecting the control plane to be encrypted too

With nodeEncryption: true, the control-plane node still reports NodeEncryption: OptedOut. That is by design:

Cilium automatically disables node-to-node encryption from and to Kubernetes control-plane nodes

Source: Cilium docs, WireGuard transparent encryption

The label selector is the node-encryption-opt-out-labels option, which defaults to node-role.kubernetes.io/control-plane. Pod traffic on the control plane is still encrypted; node traffic to and from it is not.

Calling it "encrypted everywhere"

The default encrypts pod-to-pod between nodes. Node traffic, host-network pods, control plane nodes, same-node traffic and some load balancer paths are different rows in Cilium's table. Read the table for your configuration and write down what is not covered.

Skipping strict mode because it sounds like a performance flag

Without strict mode, "encrypted" means "encrypted when Cilium already knows the destination". Strict egress and strict ingress make an unencrypted pod packet a drop instead of a leak.

Leaving 51871 open to the world

WireGuard only answers peers with known keys, but an open port is still attack surface and noise. Allow UDP 51871 between nodes only.

Treating WireGuard as workload identity

WireGuard proves which node sent a packet, not which workload. A compromised pod on a trusted node sends through the same tunnel. Use network policy for who may talk, and a mesh with mTLS if you need workload identity.

Letting CI edit CiliumNode

Broad write access to cilium.io resources, often granted so a pipeline can apply policies, also covers CiliumNode, where the WireGuard public keys live. Scope pipeline roles to the policy resources they need.

Checklist

  • encryption.enabled: true and encryption.type: wireguard are set.
  • encryption.nodeEncryption: true is set, and you have listed which nodes opt out.
  • encryption.strictMode.egress.enabled: true with the pod CIDR as cidr.
  • encryption.strictMode.ingress.enabled: true.
  • allowRemoteNodeIdentities is false, with native routing and separate node and pod CIDRs.
  • UDP 51871 is allowed only between node addresses.
  • Every agent shows a peer count equal to nodes minus one.
  • Peer public keys match each node's own key.
  • Only the Cilium agent and operator can write CiliumNode objects.
  • IPv6 pod egress is restricted or IPv6 pod networking is off.
  • A capture on a node's physical interface shows no cleartext pod traffic between nodes.

WireGuard does its part with very little ceremony. Strict mode and a few honest lines about what is not covered are what turn it into a claim you can defend in an audit.

H2-CSPE

Learn it on a live range

Cluster networking and policy, in Secure Platform Engineering: a real host in your browser, and every objective checked on the machine.

Start free

The Secure Way

More on kubernetes networking with cilium

Default-deny network policy, transparent encryption and egress control with Cilium and Hubble.

All kubernetes networking with cilium guides