
Nesting WireGuard Tunnels until it breaks for Fun and Profit
Jonas ScholzIf 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:
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.
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:
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:
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:
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:
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
- WireGuard overview / conceptual overview / cryptokey routing / built-in roaming
- Protocol & Cryptography
wg(8)configuration format- Cross-platform userspace notes for
wireguard-go - Routing & Network Namespaces
wg-quickLinux MTU logic- WireGuard kernel send path (
send.c) - WireGuard kernel socket path (
socket.c) - WireGuard message layout (
messages.h) - Linux skb COW helpers (
skbuff.h) - Linux skb reallocation internals (
skbuff.c) - Linux kmalloc size rounding (
slab_common.c)