Nesting WireGuard Tunnels until it breaks for Fun and Profit

Nesting WireGuard Tunnels until it breaks for Fun and Profit

Jonas Scholz - Co-Founder von sliplane.ioJonas Scholz
8 min

If you've ever used Tailscale, or a corporate VPN that didn't make you want to throw your laptop out the window, that was probably WireGuard under the hood.

It started in the Linux kernel, speaks UDP, and keeps the whole thing aggressively simple. That's probably the main reason so many products build on top of it. Linus Torvalds called it a "work of art". We use it at Sliplane to securely connect our bare metal machines over untrusted networks, fly.io and railway.com do the same. It's lightweight, easy to manage, and usually stays out of your way.

So naturally, one late night I asked myself:

How many WireGuard tunnels can you nest inside each other before it all falls apart?

To be clear, there might be usecases where nesting 2-3 tunnels make sense, but everything after that is ridiculous (I think, if you have a legit usecase pls do let me know)! If its not clear to you why, let me do a brief introduction into WireGuard:

WireGuard in 60 seconds

WireGuard works by adding a network interface (or multiple), just like eth0 or lo, except virtual. When you create a WireGuard interface (usually called wg0), you configure it with your private key and your peers' public keys, then it encrypts whole IP packets and ships them as UDP packets to the other side.

This is all simplified a lot, skip this section if you already used WireGuard

A minimal config looks like this:

[Interface]
PrivateKey = yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=
Address = 10.0.0.1/24
ListenPort = 51820

[Peer]
PublicKey = xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=
AllowedIPs = 10.0.0.2/32
Endpoint = 203.0.113.2:51820

In a simple two-peer setup, the other side can look like this:

[Interface]
PrivateKey = <side-2-private-key>   # public key matches xTIBA5... above
Address = 10.0.0.2/24
ListenPort = 51820

[Peer]
PublicKey = <side-1-public-key>     # derived from yAnz5... above
AllowedIPs = 10.0.0.1/32
Endpoint = 203.0.113.1:51820

Each side has its own PrivateKey and lists the other side's PublicKey under [Peer]. Endpoint is the remote UDP address to start with, but it's optional and WireGuard updates it to the peer's most recent authenticated source address. AllowedIPs lists which destination IPs get routed to that peer and which source IPs are accepted from it.

That's it for the toy example. A single interface can have multiple peers, and the actual key exchange happens automatically at runtime using the Noise_IK handshake. I think the simplicity is also the main selling point of WireGuard. AllowedIPs does double duty, it's both the routing table (which IPs go through this tunnel) and the access control list (which source IPs are allowed from this peer).

When your app sends a packet to 10.0.0.2, this is what happens:

your app
sends to 10.0.0.2
encrypts IP packet
wg0
wireguard tunnel
wraps in outer ipv4+udp+wg
eth0
udp → 203.0.113.2:51820
internet

The original IP packet goes in, gets encrypted, and comes out as a UDP packet carrying a WireGuard transport message. The peer decrypts it and forwards it.

The Overhead

That wrapping costs space. In the specific IPv4-over-IPv4 lab setup in this post, each extra layer adds:

  • 20B outer IPv4 header
  • 8B UDP header
  • 16B WireGuard transport header
  • 16B authentication tag
  • enough padding to reach a 16-byte boundary

For the packets in this setup, that worked out to 64 bytes per layer. That's a property of this exact setup, not a universal WireGuard constant: the exact number depends on outer IP version and padding, and wg-quick on Linux even uses a conservative MTU - 80 default.

1500B ethernet MTU · ipv4 lab case
IPv4
20B
 
UDP
8B
 
WG data hdr
16B
type, receiver idx, counter
inner packet
N
encrypted IP packet
padding
0-15B
4B in this example
auth tag
16B
poly1305
fixed overhead: 20+8+16+16 = 60Bpadding: 0-15B, 4B here= 64B in this exampleinner packet budget: 1436B

In this lab model, one tunnel takes you from 1500 to 1436 bytes of inner packet budget, not a problem.

But when you nest tunnels, each layer wraps the previous one and the overhead stacks. Drag the slider to see what happens:

layers3
outer mtu · 1500B · ipv4 lab case
L1 · ipv4+udp+wg · 64B here
L2 · ipv4+udp+wg · 64B here
L3 · ipv4+udp+wg · 64B here
1308B
payload
example overhead 192B (3×64B) inner packet budget 1308B
This uses the post's ipv4/icmp model where each extra layer happens to add 64B.

At 23 layers, in this exact model, you're down to 28 bytes. That's the theoretical ceiling for this setup.

I vibecoded a tool (thanks, Opus) to find the actual limit. Weirdly enough, Kernel WireGuard taps out at 19 layers, and wireguard-go (the userspace implementation) reaches 22.

The Test Setup

I vibe coded a Go tool (code42cate/nested-wireguard) that sets up nested WireGuard tunnels on a single Linux machine using network namespaces. No VMs, no extra servers, just two namespaces (wg-client and wg-server) connected by a veth pair.

go build -o wireguard-nesting .
sudo ./wireguard-nesting --layers 30

The tool creates two namespaces and connects them with a veth pair at 1500 MTU. Then it starts stacking WireGuard tunnels on top:

layers3
linux host
wg-client/veth-c
192.168.0.1
wg-server/veth-s
192.168.0.2
wg110.1.0.1
budget 1436B
wg1s10.1.0.2
budget 1436B
wg210.2.0.1
budget 1372B
wg2s10.2.0.2
budget 1372B
wg310.3.0.1
budget 1308B
wg3s10.3.0.2
budget 1308B

Layer 1's endpoints point at the veth IPs (192.168.0.x). Every layer after that points at the tunnel IPs of the layer below it. So layer 3's traffic gets encrypted, stuffed into layer 2, encrypted again, stuffed into layer 1, encrypted one more time, and finally sent over the veth pair. On the wire, a layer 3 packet looks like this:

layers · ipv4 lab case3
Eth
IP→.0.2
UDP
veth
WG
IP→10.1.0.2
UDP
L1 · +64B here
WG
IP→10.2.0.2
UDP
L2 · +64B here
WG
IP→10.3.0.2
UDP
L3 · +64B here
inner packet
1280B

For each layer, the tool runs a handshake, pings to check connectivity, and binary-searches for the maximum payload size that still fits without fragmentation.

The Results

With WireGuard running in the kernel, everything works up to layer 19. Handshakes complete, pings go through, payload sizes match what the MTU math predicts.

At layer 20, packets just vanish. No error, no kernel log, no indication that anything went wrong. Pings go out and nothing comes back. Also notice how the max payload jumps from 516 to 196 bytes? At this point I was super confused.

I then ran the same test with wireguard-go, the userspace implementation, and it goes all the way to layer 22, right where the MTU math says it should stop. Weird, right?

Here's the wireguard-go performance data across all 22 layers:

11.9k8.9k5.9k3.0k0mbps11.9k8.2k5.6k3.9k3.0k2.4k2.0k1.6k3132311671471279587704039192212412345678910111213141516171819202122 layer

Throughput drops off a cliff after layer 8 (from ~1600 Mbps to ~300 Mbps), and keeps falling to 4.4 Mbps at layer 22. Latency roughly scales linearly, going from 0.4ms to 3.6ms.

So something specific to the kernel implementation breaks at 19. The MTU still has room, the config is valid, the handshake works. The packets just disappear.

Why Does the Kernel Break?

I want to be upfront here: kernel networking internals aren't exactly my day job. What follows is my best attempt at understanding what's going on, pieced together with ftrace, kprobes, reading kernel code and getting bad explanations from Claude. Take everything here with a grain of salt! If you're a kernel dev and I got something wrong, please let me know!

Kernel WireGuard really does encrypt packets in-place, but the interesting part happens before the packet is handed to the outer UDP transmit path. In encrypt_packet(), WireGuard first calculates padding, then calls skb_cow_data(... trailer_len ...) to make the tail writable and create room for padding plus the auth tag, then calls skb_cow_head(... DATA_PACKET_HEAD_ROOM) to make room for the WireGuard data header and outer network stack headers. Only after that does it push the WireGuard header, append the trailer, and encrypt the scatter-gather buffer in place.

So the interesting part probably is not the outer UDP tunnel transmit path itself. The more likely failure point is the repeated skb_cow_* / pskb_expand_head() style reallocation in atomic context while each nested layer is preparing an already-encapsulated packet for the next layer.

The kernel send path:

wg_packet_encrypt_worker
  encrypt_packet
    calculate_skb_padding
    skb_cow_data(... trailer_len ...)
    skb_cow_head(... DATA_PACKET_HEAD_ROOM)
    skb_push(sizeof(struct message_data))
    pskb_put(... trailer_len)
    chacha20poly1305_encrypt_sg_inplace
  wg_socket_send_skb_to_peer
    send4 / send6
      udp_tunnel_xmit_skb / udp_tunnel6_xmit_skb
        iptunnel_xmit / ip6tunnel_xmit

DATA_PACKET_HEAD_ROOM is only on the order of ~100 bytes, so this doesn't look like one giant allocation suddenly blowing up. Each layer only asks for a bit more head/tail room, but it does that repeatedly and in atomic context.

WireGuard also pads data packets to a 16-byte boundary before encryption. That means some payload boundaries add another 16 bytes at a layer, and once you stack enough layers those jumps can accumulate faster than the simple MTU math suggests.

So my best guess is: the failure around layer 20 happens during that repeated skb COW/expand work in the kernel TX preparation path. I haven't pinned it to one exact allocation yet, and I'd expect the exact layer to vary across kernels and machines.

Why wireguard-go Doesn't Have This Problem

wireguard-go works completely differently. It runs in userspace and creates a virtual TUN device instead of using the in-kernel WireGuard implementation, while speaking the same WireGuard protocol and config interface. In this test it reaches the limit you'd expect from the MTU math for this setup.

That's why it reaches 22 layers, exactly where the math says the payload hits zero.

Conclusion

Nobody needs 19 layers of WireGuard tunnels. But debugging why stuff breaks in edgecases is a great opportunity to learn!

The short version:

  • Kernel WireGuard breaks at 19 layers, userspace at 22
  • The failure seems to happen in the kernel TX preparation path before outer UDP transmit
  • WireGuard's 16-byte padding probably contributes to the sharp step changes
  • wireguard-go sidesteps this by using a completely different userspace/TUN implementation path
  • For real-world use, 2-3 nested tunnels work fine and have legitimate use cases (multi-hop routing, network segmentation)
  • The kernel wasn't designed for 20 levels of tunnel nesting, and that's completely fine

If you want to try it yourself, the code is at code42cate/nested-wireguard.

Cheers,

Jonas, Co-Founder sliplane.io

Sources

Welcome to the container cloud

Sliplane makes it simple to deploy containers in the cloud and scale up as you grow. Try it now and get started in minutes!