deep·tech·intuition
intermediate ·

WireGuard Deep Intuition

An experienced engineer's guide to WireGuard

1. One-Sentence Essence

WireGuard is a stateless cryptographic routing layer: it binds public keys to IP ranges, encrypts whatever you send over its network interface to whoever owns the destination IP, and treats the network like postal mail rather than a phone call.

That sentence is the whole game. Internalise it and most of WireGuard’s quirks become predictable. There is no “connection.” There is no “session you tear down.” There is no “client” or “server” in the protocol — those are just words humans use because one side typically has a public IP and the other doesn’t. There is a table of (public key → list of allowed IPs) entries, and the rule is: encrypt outgoing packets to whichever peer owns the destination IP, accept incoming packets only if the inner source IP matches the peer who sent it.

Everything else — the handshake, the rekeying, the keepalives, the timers — is plumbing to make that one idea work over the actual internet.


2. The Problem It Solved

By the mid-2010s, if you wanted an encrypted IP tunnel between two machines, your choices were grim.

IPsec is the “correct” answer in an academic sense, but in practice it’s a multi-protocol monstrosity. You have IKE (with versions 1 and 2, mutually incompatible), ESP, AH, transport mode, tunnel mode, ISAKMP, separate daemons (strongSwan, Libreswan), kernel-userspace state synchronisation, and a configuration surface that includes choosing among dozens of ciphers, MAC algorithms, DH groups, lifetimes, and rekey behaviours. The codebase across strongSwan and the kernel ESP stack runs into hundreds of thousands of lines. Two engineers can both correctly configure IPsec and have it still not interoperate because they disagreed on a Phase 2 lifetime. Auditing this thing for security is a multi-person, multi-month job.

OpenVPN is friendlier — it’s a single userspace daemon, configured with a single file, riding on OpenSSL. But “riding on OpenSSL” means inheriting OpenSSL’s history (Heartbleed and friends), its complexity, and its tendency to make TLS the foundation of an IP tunnel — a layer mismatch that costs you performance and adds attack surface. OpenVPN runs in userspace, which means every packet bounces between kernel and userspace and through OpenSSL’s cipher state machine. On a modern server, a single OpenVPN tunnel saturates one CPU core long before it saturates the network.

Both are configurable to a fault. Both are negotiated — they sit down and agree on which cipher to use, which means downgrade attacks are a thing, and which means your security depends on what the other side will accept, not just what you’ll accept.

Jason Donenfeld, in 2015, asked a different question: what if a VPN had no negotiation, one fixed cipher suite, ran as a tiny kernel module (a few thousand lines, auditable by one person in an afternoon), looked like a network interface to the OS, and used the cleanest available cryptographic constructions? The result was WireGuard. It went mainline in Linux 5.6 (2020) after years of review, and it’s been adopted everywhere from individual laptops to Cloudflare’s WARP to most modern commercial VPN services. The design philosophy is the point: less of everything.


3. The Concepts You Need

You can’t reason about WireGuard without these terms. The good news is there are only about a dozen, because the protocol is small.

Identity and keys

  • Static private key / static public key. Each peer has a long-lived Curve25519 keypair. The private key never leaves the machine; the public key is the peer’s identity. There is no certificate authority, no signing, no PKI. Public keys are just exchanged out-of-band — like SSH keys.
  • Ephemeral keypair. A throwaway Curve25519 keypair generated per handshake. This is what gives WireGuard forward secrecy: ephemerals are mixed into session keys, then erased. Even if your static private key leaks tomorrow, traffic captured last week stays unreadable.
  • Pre-shared key (PSK). Optional 32-byte symmetric secret mixed into the handshake. Adds a layer of post-quantum-style protection (a quantum computer that breaks Curve25519 still can’t decrypt without the PSK). Most people don’t use it.

The cryptokey routing table

  • Peer. An entry in your config: a public key plus its AllowedIPs list, optionally an endpoint. A peer is just data — there is no daemon per peer, no socket per peer, no state allocated until a packet involves them.
  • AllowedIPs. This is the most important concept and the most misunderstood. It’s a list of CIDR ranges, and it serves two simultaneous purposes:
    1. Outbound routing: “if a packet’s destination IP matches this peer’s AllowedIPs, encrypt it to this peer.”
    2. Inbound ACL: “if a decrypted packet’s source IP doesn’t match the peer’s AllowedIPs, drop it.” This dual role is why WireGuard calls itself “cryptokey routing.” The routing table is the access control list. Get this wrong and you get the most common production bug: handshake succeeds, traffic disappears.
  • Endpoint. The peer’s host:port on the underlying network. This is not identity — it’s just where you currently believe the peer can be reached. WireGuard updates it automatically when packets arrive from a new address (this is “roaming”).

Wire-level pieces

  • Tunnel interface. A virtual network device (wg0, wg1, …) that you treat like any other interface. You assign it an IP, add routes that point at it, and the kernel handles the rest. Packets sent to it get encrypted and shoved out a UDP socket; packets arriving at the UDP socket get decrypted and delivered to the interface.
  • Listen port. A UDP port. WireGuard speaks UDP only. No TCP, no SCTP, no DCCP — UDP.
  • Handshake. A two-message exchange (initiator → responder, responder → initiator) that establishes a pair of symmetric session keys. Uses the Noise framework’s IKpsk2 pattern. Performed roughly every two minutes during active use.
  • Session. The pair of symmetric keys plus a 64-bit counter, derived from a handshake. Sessions are throw-away. The protocol auto-rekeys on time or message count.

Lifecycle timers (the “automatic” part)

  • Rekey-after-time / reject-after-time. A session is replaced after roughly 2 minutes of use and is forcibly retired after about 3 minutes. You never manually rekey.
  • Persistent keepalive. Optional periodic empty packet (default off, recommended 25 seconds when needed) to keep NAT mappings alive. Discussed at length later — this is the single most important configuration option you’ll consciously think about.

Concepts WireGuard deliberately lacks

These omissions are the design and worth naming explicitly, because every other VPN you’ve seen has them:

  • No “connection state.” You can’t wg connect or wg disconnect. The interface is up; data either flows or it doesn’t.
  • No cipher negotiation. The construction string Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s is baked in. You don’t pick algorithms. If WireGuard ever changes them, that’s a new protocol version.
  • No user authentication. Just key-to-key. No usernames, no LDAP, no 2FA at the protocol layer. Identity = public key.
  • No NAT traversal helpers. No STUN, no TURN, no ICE. WireGuard has no opinion about how you get a packet to the peer’s endpoint; that’s the application’s problem. (Tailscale, NetBird, et al. solve this above WireGuard.)
  • No reconnection logic. Because there’s no concept of a connection, there’s nothing to reconnect.

4. The Distilled Introduction

This is the section that replaces ten tutorials. We’ll go from zero to a working tunnel to a real-world topology, and at every step you’ll know why the command is what it is.

Install

On any modern Linux (kernel 5.6+, which is everything from Ubuntu 20.04 onwards), WireGuard is in the kernel already. You just need the userspace tools:

sudo apt install wireguard-tools     # Debian/Ubuntu
sudo dnf install wireguard-tools     # Fedora/RHEL

The wireguard-tools package gives you two binaries that matter: wg (the low-level configuration tool) and wg-quick (the convenient wrapper that does interface-up/-down lifecycle in one command).

On macOS, Windows, iOS, Android, and BSDs, you install an app or the cross-platform userspace daemon (wireguard-go). The userspace daemon is meaningfully slower than the kernel module (an order of magnitude on Linux), but for client devices on home internet the bottleneck is your ISP anyway, so it doesn’t matter.

Generate keys

Every peer needs a Curve25519 keypair. Generation takes a fraction of a millisecond.

umask 077                                   # ensure private key is mode 0600
wg genkey | tee privatekey | wg pubkey > publickey

That’s it. There’s no certificate, no CSR, no expiry, no CA chain. The private key is 32 bytes of random data, base64-encoded. The public key is its Curve25519 point, also base64. Each peer keeps its own private key and shares its public key with the other peers it wants to talk to.

One private key per host, forever (or until you rotate). Treat the file like an SSH private key — same threat model.

The simplest possible tunnel: two peers

Imagine peer A has a public IP (203.0.113.10), peer B is a laptop behind NAT. Inside the tunnel, A will be 10.0.0.1 and B will be 10.0.0.2.

Peer A (/etc/wireguard/wg0.conf):

[Interface]
PrivateKey = <A's private key>
Address    = 10.0.0.1/24
ListenPort = 51820

[Peer]
PublicKey  = <B's public key>
AllowedIPs = 10.0.0.2/32

Peer B (/etc/wireguard/wg0.conf):

[Interface]
PrivateKey = <B's private key>
Address    = 10.0.0.2/24

[Peer]
PublicKey           = <A's public key>
AllowedIPs          = 10.0.0.1/32
Endpoint            = 203.0.113.10:51820
PersistentKeepalive = 25

Bring them up on both sides:

sudo wg-quick up wg0

Now ping 10.0.0.1 from B works. Several things are happening in that one command:

  1. wg-quick creates the interface (ip link add wg0 type wireguard).
  2. It applies the WireGuard-specific config (wg setconf wg0 …) — keys, peers, AllowedIPs, etc.
  3. It assigns the inner address (ip addr add 10.0.0.2/24 dev wg0).
  4. It adds routes for each peer’s AllowedIPs (ip route add 10.0.0.1/32 dev wg0).
  5. It brings the interface up (ip link set wg0 up).

Step 4 is the bit that surprises people: wg itself does not touch the routing table. The kernel only knows to send packets to wg0 if you (or wg-quick) tell it to via a normal ip route add. AllowedIPs only controls what happens inside the WireGuard interface — which peer to encrypt to, which source IPs to accept. It does not, by itself, get packets to the WireGuard interface in the first place. Section 7 expands on this trap.

The asymmetry between A and B in the config:

  • B has an Endpoint because B initiates (B knows where A is on the internet).
  • A has no Endpoint for B because A doesn’t know B’s NAT-mapped address. A learns it from B’s first handshake packet.
  • B has PersistentKeepalive = 25 because B is behind NAT — without periodic packets out, the NAT mapping ages out (typical NAT timeout is 30–180 seconds for UDP). A has no keepalive because A is publicly reachable and doesn’t need one.
  • B’s AllowedIPs = 10.0.0.1/32 is the strict version — only allow 10.0.0.1 from peer A. If you want to use A as a default-route gateway, you’d write AllowedIPs = 0.0.0.0/0, ::/0. More on this in a moment.

Inspect what’s happening

sudo wg show

You get something like:

interface: wg0
  public key: <A's public key>
  private key: (hidden)
  listening port: 51820

peer: <B's public key>
  endpoint: 198.51.100.42:54183     # B's NAT-mapped address, auto-learned
  allowed ips: 10.0.0.2/32
  latest handshake: 47 seconds ago
  transfer: 1.2 MiB received, 850 KiB sent
  persistent keepalive: every 25 seconds

The two facts you read off this output most often:

  • latest handshake — if it’s not in the last ~3 minutes during active use, the tunnel is dead and the kernel is silently failing to talk.
  • endpoint — is the address what you expect? If you see a public IP you don’t recognise, the peer is roaming through a different network than you thought.

The road-warrior pattern: many clients, one server

The single most common WireGuard topology. Server has a public IP. Many laptops/phones connect from anywhere. Server config:

[Interface]
PrivateKey = <server private key>
Address    = 10.0.0.1/24
ListenPort = 51820

# Forwarding rules so VPN clients can reach the internet
PostUp   = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE

[Peer]    # Alice's laptop
PublicKey  = <Alice's pubkey>
AllowedIPs = 10.0.0.2/32

[Peer]    # Bob's phone
PublicKey  = <Bob's pubkey>
AllowedIPs = 10.0.0.3/32

Each client gets one /32 in AllowedIPs on the server side. One IP per client. If two clients claimed the same AllowedIP, only one of them could ever route there.

Alice’s client config:

[Interface]
PrivateKey = <Alice's privkey>
Address    = 10.0.0.2/32
DNS        = 1.1.1.1

[Peer]
PublicKey           = <server pubkey>
AllowedIPs          = 0.0.0.0/0, ::/0
Endpoint            = vpn.example.com:51820
PersistentKeepalive = 25

The 0.0.0.0/0, ::/0 AllowedIPs is what makes this a “full-tunnel” VPN — Alice’s laptop will route all traffic through the server. (Set it to a specific subnet for “split-tunnel”.)

wg-quick does something clever for the full-tunnel case: it doesn’t actually add a default route through wg0 (which would cause an infinite loop — the encrypted WireGuard packets themselves would try to go through the tunnel). Instead, it uses fwmark and policy routing. WireGuard marks its own encrypted UDP packets with a magic fwmark (the listen port, usually 51820), and wg-quick adds a routing rule that says “unmarked packets go through the wg-tunnel routing table; marked packets use the main table.” That’s how the encrypted-tunnel packets escape via your real interface while everything else gets tunneled.

You almost never need to think about this — it just works — but knowing it exists explains why you’ll see strange ip rule entries when you inspect a tunnel system.

A site-to-site setup

Two offices, each with their own subnet (192.168.10.0/24 and 192.168.20.0/24), connected by a tunnel. Each office’s WireGuard gateway has a public IP.

Office A’s WireGuard gateway:

[Interface]
PrivateKey = <A's privkey>
Address    = 10.0.0.1/30
ListenPort = 51820

[Peer]
PublicKey  = <B's pubkey>
Endpoint   = office-b.example.com:51820
AllowedIPs = 10.0.0.2/32, 192.168.20.0/24

Office B’s WireGuard gateway:

[Interface]
PrivateKey = <B's privkey>
Address    = 10.0.0.2/30
ListenPort = 51820

[Peer]
PublicKey  = <A's pubkey>
Endpoint   = office-a.example.com:51820
AllowedIPs = 10.0.0.1/32, 192.168.10.0/24

Notice how AllowedIPs carries the full reachability picture: not just the peer’s tunnel IP, but the whole subnet behind it. The packet for 192.168.20.50 arrives at A’s gateway, the kernel routes it to wg0 (you’ll have set up ip route add 192.168.20.0/24 dev wg0 for this), WireGuard looks at the destination, matches it against the AllowedIPs of peer B, and encrypts the packet to B. B decrypts it, checks the inner source against A’s AllowedIPs (must match 192.168.10.0/24), and forwards it on its LAN.

You’ll also need IP forwarding enabled (sysctl -w net.ipv4.ip_forward=1) and firewall rules to allow traffic between wg0 and the LAN interfaces.

The handshake from the outside

You don’t need to think about this day-to-day, but it’s good to know what’s actually happening on the wire. Roughly every two minutes during active use, this happens:

  1. Initiator sends a handshake message containing its ephemeral public key, an encrypted form of its static public key (so identity is hidden from passive observers on the wire), and an encrypted timestamp.
  2. Responder decrypts, verifies the timestamp is newer than any it’s seen from this peer, generates its own ephemeral, sends back a handshake response.
  3. Both sides derive a pair of symmetric ChaCha20-Poly1305 keys (one for each direction) and a starting counter of 0.

After that, data packets flow. Each is a 4-byte type-and-flags field, a 4-byte receiver index, an 8-byte counter, and the encrypted payload (the original IP packet, with a 16-byte authentication tag appended).

There’s a piece of subtle but important state-machine behaviour here: when the responder finishes the handshake, it can decrypt but won’t send using the new session until it has received one data packet on it. This is “key confirmation” — it prevents the responder from sending a stream of packets the initiator can’t read. In practice you’ll never observe it because the initiator sends an empty keepalive packet right after the handshake response to provide that confirmation.

Bringing it down, persisting it

sudo wg-quick down wg0
sudo systemctl enable --now wg-quick@wg0    # start on boot

That’s it. That’s the whole tutorial. You now know more about WireGuard than 90% of people who use it daily.


5. The Mental Model

Four ideas. Internalise these and you can predict almost everything.

Core Idea 1: The cryptokey routing table is one table, but it answers two questions.

For outbound packets, it answers: “given this destination IP, which public key should I encrypt to?” For inbound packets, it answers: “given that this packet came from peer X (proven by decryption), is its inner source IP one that peer X is allowed to claim?”

This means AllowedIPs is simultaneously your routing table and your firewall. The same line of config decides where outbound traffic goes and what inbound traffic is accepted. There is no other ACL.

Predictions from this:

  • If you have two peers with overlapping AllowedIPs, only the most specific match (longest prefix) wins for outbound. Inbound, both peers are individually allowed to send from their respective ranges, but the kernel’s routing table determines reply paths, so overlap usually means broken bidirectional flow.
  • You can’t make a “deny” rule in WireGuard. Either an IP is allowed (matches some peer’s AllowedIPs) or it’s not. To exclude a sub-range you’d have to assign it to no peer, which means traffic to it gets dropped at the interface.
  • A typo that puts 10.0.0.2/32 under the wrong peer means that peer can now spoof as 10.0.0.2. Identity is per-peer, but the ACL is per-IP-range.
  • If you set AllowedIPs = 0.0.0.0/0 on one side, that side claims every IP. It will accept any inner source from that peer. This is correct for a client treating its server as a gateway. It is catastrophically wrong if there are other peers — you’ve just given that one peer permission to impersonate all of them.

Core Idea 2: There is no connection. There is a packet table and a timer.

WireGuard maintains, per peer: a pair of session keys (if a handshake has completed recently), a counter, and a “last handshake at” timestamp. There is no concept of “the peer is connected” or “the peer is disconnected.” There are just packets that flow when both sides have current session keys, and packets that don’t when one side’s session has expired.

Predictions:

  • “Reconnection” is not a thing. If the network goes away and comes back, the next packet someone tries to send triggers a new handshake automatically. You don’t need to do anything.
  • Peer roaming (mobile from wifi to cellular) is free. The peer’s endpoint is updated whenever a correctly-decrypted packet arrives from a new address. No reconnection logic. No “session resume.”
  • If you bring an interface up with no peer reachable, nothing happens. No error, no warning. WireGuard doesn’t probe. It just waits for someone to send a packet through the interface. This is sometimes confusing — “I brought up the tunnel, why isn’t it working?” Because nothing has tried to use it yet.
  • If a NAT mapping ages out and there’s no PersistentKeepalive, an “outside” peer can’t reach an “inside” peer until the inside peer sends something first. This is a bug in your understanding of the protocol, not a bug in WireGuard.

Core Idea 3: WireGuard is silent and stateless to the wire.

The protocol is designed to be invisible to anyone who doesn’t already hold a valid key. If you send a random UDP packet to a WireGuard port, you get nothing back. No “I’m a WireGuard server.” No ICMP unreachable. No TCP RST. The port behaves identically to a closed port, except that it’s open and listening — for the right people.

This is what the protocol calls “stealthiness” and it’s a deliberate design choice with two huge implications:

  • DoS surface is minimal. An attacker can’t probe you to discover you exist. They can’t make you compute a Curve25519 multiplication unless they have at least your public key (which is needed to compute mac1 on the first handshake message). Without mac1 matching, the message is dropped before any expensive work.
  • No state is created until authentication succeeds. The kernel doesn’t allocate per-attempt structures on receipt of an unverified packet. SYN-flood-equivalent attacks on WireGuard are just network bandwidth attacks, not memory-exhaustion attacks.

Predictions:

  • You will not see WireGuard handshake failures in your logs the way you see TLS failures. There’s nothing to log. Most attempts that fail just… don’t get a response.
  • Port-scanning a WireGuard host will not reveal the WireGuard port. nmap shows it as closed/filtered.
  • If your tunnel breaks, you cannot ask the other side “why?” by sending an unauthenticated diagnostic packet. There is no diagnostic packet. You debug from your own side or you cooperate via another channel.

Core Idea 4: Cryptography is baked in, not negotiated.

The construction is Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s. That’s Curve25519 for DH, ChaCha20-Poly1305 for AEAD, BLAKE2s for hashing, and the IKpsk2 handshake pattern from the Noise framework. No alternatives are offered. No negotiation occurs.

This is a profound design choice, not a limitation. It means:

  • No downgrade attacks — an attacker can’t trick both sides into agreeing on a weaker cipher because there’s no negotiation surface.
  • No misconfiguration of the cipher suite — you can’t accidentally allow RC4 because you can’t allow anything.
  • No code paths for unused algorithms — the implementation only contains one cipher’s worth of code, which is part of why the codebase is so small (~4,000 lines in the Linux kernel module).
  • The protocol can’t evolve in-place. When (not if) Curve25519 or ChaCha20 needs replacing, that’s a new protocol, requiring all parties to upgrade. There is no smooth migration. The hope is that this happens approximately never.

Predictions:

  • Post-quantum security requires bolting on a separate KEM and feeding its output into the PSK slot, or wrapping WireGuard in a PQ-secure outer protocol. There’s no in-protocol upgrade path.
  • Hardware-accelerated AES doesn’t help WireGuard (it doesn’t use AES). ChaCha20 is fast in software on every general-purpose CPU, including phones and routers — that was the point of choosing it.
  • FIPS 140 compliance is a problem. ChaCha20-Poly1305 isn’t in the approved list for many compliance regimes. If your auditor requires AES-GCM, WireGuard is not your protocol.

6. The Architecture in Plain English

Let’s narrate what happens end-to-end when you send a packet through WireGuard. Say you ping 10.0.0.1 from your laptop, where 10.0.0.1 is the inner address of your VPN server.

On the laptop:

  1. The kernel produces an ICMP echo packet destined for 10.0.0.1.
  2. The kernel’s routing table is consulted. There’s a route 10.0.0.0/24 dev wg0. So the packet is handed to wg0.
  3. The wg0 interface is a WireGuard interface. WireGuard looks at the destination IP (10.0.0.1) and consults its cryptokey routing table for a peer whose AllowedIPs contain 10.0.0.1. It finds the server peer.
  4. WireGuard checks: do we have a valid session with this peer? If yes, skip to step 7. If not (or the session is expired), it triggers a handshake.
  5. Handshake: WireGuard generates an ephemeral keypair, builds a handshake initiation message, and sends it via UDP from its socket to the server’s known endpoint. Then it queues the ping packet to be sent once the handshake completes.
  6. The server receives the handshake, validates it, generates its own ephemeral, sends back a handshake response. Both sides derive session keys.
  7. WireGuard takes the ping packet, encrypts it with ChaCha20-Poly1305 using the current sending key and the current counter (incrementing afterwards), and prepends a small header (type=4, the receiver’s session index, the counter). The result is a UDP payload.
  8. The UDP packet is sent from the laptop’s WireGuard socket to the server’s endpoint, using the laptop’s real outbound interface (not wg0 — that would loop forever). wg-quick handled the policy routing that makes this work.

On the server:

  1. The UDP packet arrives at the server’s WireGuard listen port.
  2. WireGuard looks at the receiver index, finds the corresponding session, decrypts using the session’s receiving key.
  3. Decryption succeeds with a valid auth tag. The inner packet (the ICMP echo) is exposed.
  4. WireGuard reads the inner source IP and verifies it’s in the AllowedIPs of the peer this session belongs to. If not, drop.
  5. WireGuard notes that this packet arrived from a UDP address it didn’t expect (e.g., the laptop is on a new wifi network) and updates the peer’s “current endpoint” to that new address. This is roaming.
  6. The decrypted ICMP echo is injected onto wg0. From the kernel’s perspective, an ICMP echo just arrived on wg0 from 10.0.0.2.
  7. The kernel processes it as if it had arrived on any other interface — sends an echo reply, which the routing table sends back out wg0, and the whole process happens in reverse.

Where the state lives

This is the architectural punchline: almost all state is per-peer, and most of it is tiny.

For each peer, the kernel keeps:

  • The static public key (32 bytes).
  • The AllowedIPs (a list, stored as a trie for fast lookup).
  • Current endpoint (an IP and port).
  • Up to three session keypairs at a time (current, previous, next-being-negotiated) — each is two 32-byte keys plus a counter and a sliding replay window.
  • Timestamps for the last handshake, last send, last receive.
  • A handful of timers (rekey, persistent keepalive).

That’s it. There’s no per-packet state, no per-flow state, no connection table. Memory scales linearly with peers, not with traffic. A WireGuard server with 10,000 peers uses single-digit megabytes for WireGuard’s own state.

The threading model on Linux is also simple: WireGuard uses the kernel’s existing UDP/network stack and parallel workqueues, so a single tunnel can saturate multi-gigabit links on a modern server. Unlike OpenVPN, there’s no single-threaded bottleneck.


7. The Things That Bite You

These are the bugs you’ll hit in the first six months. Each one comes back to a mental-model concept.

7.1 “Handshake succeeds but no traffic flows.” (The AllowedIPs trap.)

What you’d expect: if wg show says “latest handshake: 5 seconds ago,” everything works.

What actually happens: handshake success means the keys are right and UDP can reach the endpoint. It says nothing about whether the routing table sends packets to wg0, or whether AllowedIPs is broad enough on the receiving end, or whether the receiving end’s kernel knows what to do with the inner packet.

Almost always one of these:

  • The route is missing. You configured WireGuard but forgot the ip route add for the destination subnet, so packets to the remote subnet never arrive at wg0. (wg-quick adds these automatically from AllowedIPs; if you’re not using wg-quick, you have to add them yourself.)
  • AllowedIPs is too narrow on one side. Say you configured peer B with AllowedIPs = 10.0.0.2/32 on the server, and B sends a packet whose inner source is 192.168.5.10 (because B is forwarding from its LAN). The server decrypts it, sees the source isn’t in AllowedIPs, drops it silently. You see the handshake working but the LAN-to-LAN traffic disappearing.
  • The remote end has no return route. B’s traffic reaches A, A replies, but A doesn’t have a route for B’s subnet pointing at wg0, so the reply goes out the default interface and either gets dropped or comes back unencrypted and ignored.

Mental model link: Core Idea 1 — the cryptokey table is both the routing table and the ACL, and it’s also separate from the kernel’s outer routing table. Both layers must agree.

Fix: check three things, in order. (1) ip route get <destination> on the sender — does it actually go through wg0? (2) sudo wg show on the receiver — does the handshake time show the packet arrived? (3) tcpdump -i wg0 on the receiver — does the decrypted packet show up? If yes to all three but no reply, the problem is the receiver’s return path.

7.2 MTU silently breaks large packets.

What you’d expect: as long as the tunnel works at all, packets of any size up to the underlying MTU should flow.

What actually happens: WireGuard adds 60 bytes of overhead over IPv4 (20 IP + 8 UDP + 32 WireGuard headers/tag), or 80 bytes over IPv6. So a 1500-byte inner packet becomes a 1560 or 1580-byte outer packet — too big for a typical 1500-byte path MTU. Either the kernel fragments (slow, sometimes blocked entirely), or path MTU discovery kicks in (which requires ICMP to work end-to-end, and ICMP is frequently blocked), or the packet is silently dropped.

The default wg-quick MTU is 1420, which assumes a 1500-byte underlying MTU over IPv4. If your underlying connection has a smaller MTU (PPPoE knocks it to 1492; cloud overlays like AWS’s VXLAN knock it lower; running WireGuard over IPv6 needs another 20 bytes shaved off; running WireGuard inside another VPN tunnel knocks it lower again), the default is wrong and you get the classic symptom: small packets (ping, SSH login) work, large packets (HTTPS pages, file transfers, SCP) hang or fail.

Mental model link: WireGuard is a packet-in-packet protocol with fixed overhead. Forgetting the overhead is forgetting what the protocol is.

Fix: set the MTU explicitly in the config: MTU = 1380 for safety, or MTU = 1280 for maximum compatibility (this is also the IPv6 minimum MTU, so it works almost everywhere). For server-side TCP traffic specifically, MSS-clamp:

iptables -t mangle -A FORWARD -i wg0 -p tcp --tcp-flags SYN,RST SYN \
    -j TCPMSS --clamp-mss-to-pmtu

This rewrites TCP SYN packets so both endpoints agree on a segment size that fits. UDP and ICMP aren’t helped by this — they need real MTU configuration.

7.3 The “I brought it up and nothing happens” silence.

What you’d expect: bringing up a tunnel does something visible.

What actually happens: wg-quick up wg0 creates the interface and configures it, but WireGuard does not initiate a handshake on its own. A handshake only fires when there’s a packet to send. If you haven’t generated traffic, wg show shows no handshake yet — and the interface looks dead.

Mental model link: Core Idea 2 — no connection, just packets and timers. The protocol is event-driven.

Fix: send a packet. ping the remote inner IP. The first ping will block for ~1 second while the handshake completes, then start succeeding.

7.4 NAT mappings die and the “client” can’t be reached.

What you’d expect: once the tunnel is established, it stays established.

What actually happens: if a peer is behind NAT and stops sending packets for ~30–180 seconds (varies by router), the NAT mapping ages out. The remote endpoint the public peer has stored for it is now stale. The public peer tries to send a packet, it hits a closed NAT, the packet is dropped, and the tunnel appears one-way-dead. The behind-NAT peer has no way to know; it can still send packets out (which establish a new mapping), but until it does, it’s unreachable.

Mental model link: WireGuard “roams” automatically, but only when the behind-NAT side initiates. The protocol has no NAT keepalive by default.

Fix: set PersistentKeepalive = 25 on the peer entry that points to the behind-NAT peer (or, more commonly, on the behind-NAT peer’s config pointing at the public peer, so the keepalive packets refresh the NAT mapping from the inside). 25 seconds is conservative; most NATs hold UDP mappings for at least 30. If you don’t need to receive unsolicited packets at the behind-NAT peer, you don’t need keepalive — leaving it off saves a tiny amount of bandwidth and CPU.

7.5 You “fixed” the keys but they’re still broken.

What you’d expect: change the private key, the public key changes, you redeploy the new public key everywhere, done.

What actually happens: WireGuard has no concept of a key rotation event. There’s no “old key” period. If you change peer A’s private key, all peers that had A’s old public key still won’t accept A’s traffic — handshakes silently fail (because A’s static public key in handshakes is now different) and no error is generated anywhere. You’ll discover this in production when traffic stops.

Mental model link: Core Idea 3 — silence is the protocol’s response to “I don’t know you.”

Fix: rotate by orchestration, not by surprise. Generate the new keypair, update all peers that need to talk to the rotated peer, then swap in the new private key on the rotated peer. There’s no atomic protocol-level way to do this; it’s your job to coordinate.

7.6 Two peers, same AllowedIPs, the second one wins (or doesn’t).

What you’d expect: WireGuard rejects the config or fails loudly.

What actually happens: when you add a second peer that claims an IP already claimed by another peer, wg removes that IP from the first peer’s AllowedIPs and gives it to the second. There’s a warning, but only if you’re looking. The tunnel to the first peer is now subtly broken — outbound packets to that IP go to peer 2 (which can’t decrypt them), and inbound packets from peer 1 claiming that source IP are dropped.

Mental model link: Core Idea 1 — AllowedIPs is unique to a peer in the cryptokey table. Two peers cannot both claim the same range.

Fix: treat AllowedIPs as your IP allocation plan and enforce it before deploying. In multi-peer deployments this is exactly the bookkeeping problem that pushes people to Tailscale/NetBird/Headscale, which manage the allocation centrally.

7.7 The “wg-quick added routes I didn’t expect” mystery.

What you’d expect: wg-quick brings up an interface; routes are obvious.

What actually happens: wg-quick is opinionated. It does several things by default:

  • Adds a route per AllowedIPs entry pointing at wg0 (which is what you usually want).
  • For AllowedIPs = 0.0.0.0/0, it uses fwmark-based policy routing and adds two ip rule entries to avoid routing loops. You’ll see them in ip rule show and they may look alien.
  • Sets DNS via resolvconf or systemd-resolved, which on some systems leaks DNS queries to the new server (the DNS = directive is a wg-quick thing, not a WireGuard protocol thing).
  • Runs PostUp/PostDown scripts you put in the config, which can do nearly anything — and if they fail, the tunnel may be partly up and partly broken.

Mental model link: wg is the protocol; wg-quick is a shell script. They have different responsibilities and different surprise factors.

Fix: when troubleshooting weirdness, run wg-quick strip wg0 to see the “real” WireGuard config (without the wg-quick-specific Address, DNS, PreUp/PostUp directives), then inspect ip route, ip rule, and resolvectl status to see what wg-quick actually did.

7.8 ECN, DSCP, and that one weird flow.

What you’d expect: WireGuard is transparent to the IP packets it carries.

What actually happens: by default WireGuard does not copy the inner DSCP (QoS) bits to the outer packet — deliberately, to avoid leaking traffic-type information. But it does copy ECN bits (per RFC 6040). If you have QoS classification on your underlying network expecting to see the inner DSCP, it won’t see it, and your QoS rules silently don’t fire.

Fix: apply DSCP marking on the outer packet via iptables -t mangle if you need it, or accept that QoS-class information is one of the things WireGuard intentionally hides.

7.9 Time travel breaks the handshake.

What you’d expect: a clock skew of a few seconds is harmless.

What actually happens: the handshake includes a TAI64N timestamp, and the responder keeps the greatest timestamp seen per peer. If your clock jumps backwards (or was wildly off when you generated the first handshake), the next handshake’s timestamp is less than the stored one and is rejected. You’re stuck until either time advances past the bad timestamp or the server-side state is cleared.

Mental model link: the timestamp exists for replay protection (see Section 5 on stealthiness — the protocol can’t afford to be tricked by replayed handshake initiations). Trading clock-resilience for replay resistance is the explicit choice.

Fix: keep NTP working. On systems with no clock source (some embedded gear, fresh VMs without a hardware clock), this matters more than you’d think.


8. The Judgment Calls

These are the decisions experienced operators make differently from beginners. None is “always X” — but each has a usually-right answer and a clear signal for when to deviate.

8.1 Raw WireGuard vs. Tailscale / NetBird / Headscale

The decision: do you run WireGuard directly with hand-edited config files, or do you adopt a layer above it that adds a control plane (peer discovery, key distribution, ACLs, NAT traversal)?

Raw WireGuard wins when: you have a small number of peers (2–10), they’re long-lived, you control both sides, and you want maximum protocol transparency. Site-to-site VPN between two known-IP datacenters is the perfect fit.

A coordinated layer wins when: you have many peers, they roam, they may all be behind NAT (and so need hole-punching), team members come and go, or you want SSO/MFA at sign-in.

What experienced people choose: for personal use or small homelab — raw WireGuard. For team-of-10+ developer environments — Tailscale unless you specifically need self-hosting, in which case Headscale or NetBird. The “I’ll just script peer management myself” path almost always gets abandoned within a year for one of the above tools, because the bookkeeping is genuinely tedious.

Signal: if you’re writing a script to generate WireGuard configs from a database, stop and switch to NetBird.

8.2 Full-tunnel vs. split-tunnel

Full tunnel: AllowedIPs = 0.0.0.0/0, ::/0. All client traffic, including to the public internet, goes through the WireGuard server.

Split tunnel: AllowedIPs = 10.0.0.0/8, 192.168.0.0/16 (or whatever your private ranges are). Only traffic destined for those ranges goes through the tunnel; the internet goes via the local network normally.

Full tunnel is right when: the threat model includes a hostile local network (coffee shop wifi, hotel wifi, sketchy ISPs), or when you specifically want to appear to come from the VPN exit IP, or for privacy reasons.

Split tunnel is right when: the threat model is “give devs access to private resources” and you don’t want or need to backhaul the rest. Performance is better (no extra hop), the server’s egress bandwidth budget is smaller, and you avoid the DNS leak class of bugs.

What experienced people choose: split tunnel by default for corporate networks (better performance, lower cost, fewer support tickets), full tunnel only when the threat model demands it. Mixed: full tunnel for laptops on untrusted networks, split for everything else.

8.3 Set MTU explicitly or rely on the default

The decision: leave MTU unset (defaults to 1420 on Linux) and hope, or set it explicitly?

Default works when: all peers are on standard ethernet, IPv4, no PPPoE, no underlying tunnels.

Explicit is needed when: any of the above isn’t true. And in a deployment of any size, some of your users have PPPoE, some are on cellular with weird MTUs, some are inside a corporate network with internal tunneling.

What experienced people choose: MTU = 1280 for any deployment with diverse client networks. 1280 is the IPv6 minimum, works through everything, and the throughput cost (more packets per byte) is negligible at the megabit scales most VPNs operate at. For datacenter-to-datacenter with known infrastructure: measure path MTU and set it precisely (often you can do 1500 if both sides are on the same provider’s backbone with jumbo frames).

Signal: complaints about “HTTPS pages don’t load but ping works” → MTU. “File transfers hang” → MTU. “SSH session works but scp fails” → MTU.

8.4 Use a pre-shared key or not

The decision: add PresharedKey = ... to peer entries for an extra layer of symmetric crypto.

PSK gives you: a small additional layer of compromise resistance (an attacker would need both the long-term private key and the PSK to forge a session), and a hedge against a future cryptographic break of Curve25519 (specifically post-quantum hedging).

PSK costs you: a 32-byte secret to distribute and rotate per peer pair, with no protocol-level help. If you have N peers in a mesh, that’s roughly N²/2 PSKs.

What experienced people choose: skip it for typical deployments. Use it when (a) you have a regulatory or threat-model reason to layer crypto, (b) you’re worried about future quantum threat to data captured today and decrypted later. Tailscale and NetBird don’t use it by default.

8.5 Where to terminate the tunnel

The decision: run WireGuard on the gateway/router, or on individual hosts?

On the gateway: the LAN behind it is automatically reachable through the tunnel. Fewer endpoints to manage. But the gateway becomes a single point of failure and a man-in-the-middle for traffic on its LAN.

On the host: each host has its own keypair, its own identity, its own trust boundary. Aligns with zero-trust thinking (and is the model Tailscale advocates strongly). But more endpoints, more configuration surface.

What experienced people choose: in 2026, host-based is winning. The Tailscale model — every host is a tunnel endpoint, every host has its own identity, the network is flat at the overlay layer — is more flexible, more secure, and not meaningfully more complex once you have a control plane. The exception is when you have IoT devices or appliances that can’t run WireGuard themselves; for those, you put WireGuard on the gateway and bridge.

8.6 ListenPort: random or fixed?

Fixed listen port (often 51820): easier to firewall, easier to document, easier to debug.

Random listen port (default for clients without ListenPort): no incoming-port to firewall, slightly more annoying to lock down. But: clients usually don’t accept unsolicited connections anyway, so it doesn’t matter.

What experienced people choose: fixed port (51820 by convention, or any UDP port — picking 443 helps in some censorship scenarios where only common ports work) on servers; let clients use ephemeral. If you’re being firewall-creative, 443 UDP is the obvious choice because few networks block it (it’s the same port QUIC uses).

8.7 Should you tunnel TCP over TCP?

WireGuard is UDP-only by design. In environments where UDP is blocked or heavily throttled (some corporate networks, some hotel networks, some countries’ national firewalls), you can wrap WireGuard’s UDP in a TCP carrier using tools like udp2raw or udptunnel.

What experienced people choose: avoid it unless you have to. TCP-over-TCP is famously bad (both stacks try to do congestion control, with terrible interactions). When you must, prefer a properly obfuscated UDP-over-TCP wrapper that uses raw sockets to avoid the worst of it, and accept that performance will be a fraction of native.

The official position is that obfuscation is “a layer above WireGuard.” That’s correct, but it does mean you’re now operating two protocols, not one.

8.8 Key rotation cadence

WireGuard keys don’t expire. There’s no protocol mechanism to force rotation.

What experienced people choose:

  • Static keys: rotate annually for normal threat models; quarterly or on personnel change for higher ones. Treat key rotation like rotating an SSH key — it’s manual and tedious, which is why you batch it.
  • PSKs (if used): rotate more frequently — every 90 days is reasonable.
  • Session keys: rotate every ~2 minutes automatically. You don’t manage these.

If you find yourself wanting to rotate static keys monthly, you’ve outgrown raw WireGuard and need a control plane.

8.9 Multiple WireGuard interfaces or one with many peers?

The decision: when connecting to multiple distinct WireGuard networks, do you use one wg0 with many peers, or wg0, wg1, wg2 for each?

One interface with many peers: cleaner if all peers are part of the same logical network. AllowedIPs prevents cross-talk.

Multiple interfaces: needed when the IP ranges overlap between networks (you can’t have 10.0.0.0/24 belonging to two different peers), when you need different MTUs or DNS settings, or when you want to bring networks up and down independently.

What experienced people choose: one interface per logical network. Don’t try to make wg0 represent four different VPN providers — make wg0, wg1, wg2, wg3, and use policy routing or firewall rules to send traffic into the right one.

8.10 Kernel module vs. wireguard-go

On Linux, you have a choice: the in-tree kernel module (fast) or the userspace wireguard-go reference implementation (slower but portable). On other OSes there’s no choice — userspace is all you get.

Kernel module wins when: you care about throughput. The kernel module saturates 10Gbps without breaking a sweat. wireguard-go will give you 1-2Gbps single-flow.

Userspace wins when: you’re in a container without privileged kernel access, or on a non-Linux system, or you specifically want the code in userspace (easier debugging, easier security audit).

What experienced people choose: kernel module by default on Linux. Userspace for non-Linux clients, embedded gateways without WG kernel support, or environments where you can’t load kernel modules.


9. The Commands/APIs That Actually Matter

This is the curated quick-reference for the 80% of operational work.

Keys

wg genkey                       # generate a private key (32 bytes, base64)
wg pubkey < privkey             # derive a public key from a private key
wg genpsk                       # generate a 32-byte PSK

Always umask 077 before generating keys. The private key file should be mode 600. There is no “encrypted at rest” for it; treat it like an SSH key.

Interface lifecycle

wg-quick up wg0                 # bring interface up using /etc/wireguard/wg0.conf
wg-quick down wg0               # tear it down
systemctl enable wg-quick@wg0   # bring it up on boot
systemctl restart wg-quick@wg0  # reload after editing config

If you’re not using wg-quick:

ip link add dev wg0 type wireguard         # create interface
wg setconf wg0 wg0.conf                    # apply WG-specific settings
ip addr add 10.0.0.1/24 dev wg0            # assign address
ip link set up dev wg0                     # bring up
ip route add 10.0.0.0/24 dev wg0           # add routes for peers (not auto)

Inspection

wg show                                 # all interfaces, all peers, key state
wg show wg0                             # one interface
wg show wg0 dump                        # machine-parseable form (useful for monitoring)
wg show wg0 latest-handshakes           # just the timestamps
wg show wg0 transfer                    # just the byte counts per peer

The fields you care about: latest handshake (should be recent during active use), transfer (should be incrementing), endpoint (should match where you expect the peer to be), persistent keepalive (should be set if peer is behind NAT).

Live modification

wg set wg0 peer <pubkey> allowed-ips 10.0.0.5/32       # add or update a peer
wg set wg0 peer <pubkey> remove                        # delete a peer
wg set wg0 listen-port 51820                           # change port
wg set wg0 private-key /etc/wireguard/privatekey       # rotate key (will break handshakes!)

These take effect immediately, no restart needed. Useful for orchestration tooling.

Saving running config

wg showconf wg0 > /etc/wireguard/wg0.conf    # dump current live config
wg-quick save wg0                            # save then preserve wg-quick directives

Debugging

sudo dmesg | grep wireguard                  # kernel module messages (rare but useful)
sudo tcpdump -i wg0 -nn                      # what's flowing through the tunnel (decrypted)
sudo tcpdump -i eth0 udp port 51820 -nn      # encrypted traffic on the underlying interface

# Enable verbose kernel debug logging
echo module wireguard +p | sudo tee /sys/kernel/debug/dynamic_debug/control

# For userspace wireguard-go
LOG_LEVEL=verbose wireguard-go wg0

The dynamic-debug knob is invaluable for “why isn’t the handshake working” investigations — you’ll see exact reasons for handshake rejections.

MTU testing

ip link set dev wg0 mtu 1380                          # change tunnel MTU
ping -M do -s 1352 10.0.0.1                           # test: -M do = don't fragment, -s = payload
# 1352 + 8 (ICMP) + 20 (IP) = 1380, which should fit if MTU is 1380
# Subtract 28 from your target MTU to get the -s value

If the ping with -M do fails but smaller pings succeed, you’ve found the path MTU empirically.


10. How It Breaks

A taxonomy of failure modes and how to recognise each.

10.1 Handshake never completes

Symptoms: wg show shows no recent handshake (or never), no decrypted traffic on wg0, the peer is unreachable.

Likely causes:

  • Wrong public key on one side (typo, copy-paste error including a trailing newline).
  • Wrong endpoint — the IP or port you have for the peer is wrong, or there’s a firewall in between blocking UDP.
  • Wrong PSK (if used) — one side has it, the other doesn’t.
  • Clock skew — handshake timestamp rejected because it’s older than what the responder has stored.

Diagnose:

  1. sudo tcpdump -i eth0 -nn udp port 51820 on both sides — are packets even arriving? If they leave the sender but never reach the receiver, the network between them is the problem.
  2. Enable kernel debug logging — you’ll see specific reasons (“invalid mac1”, “invalid handshake initiation”, etc).
  3. Try toggling the tunnel: wg-quick down wg0; wg-quick up wg0 on both sides.

Fix: depends on the specific reason from step 2.

10.2 Handshake works, data doesn’t (or works one-way only)

The classic. Covered in Section 7.1 in depth.

Diagnose:

  • tcpdump -i wg0 on the sender: do you see the outgoing decrypted packets?
  • tcpdump -i wg0 on the receiver: do you see them arrive?
  • If they arrive but no reply: check ip route on the receiver — is there a route back?
  • If they don’t arrive but the sender shows them leaving: check AllowedIPs on the receiver — is the inner source IP allowed?

Fix: make AllowedIPs match what you actually send. Add return routes.

10.3 The tunnel works initially then dies after some time

Symptoms: new connections fine for the first hour, then nothing. Restarting wg0 fixes it temporarily.

Likely causes:

  • NAT mapping expired and no PersistentKeepalive (Section 7.4).
  • The peer’s public IP changed (mobile, dynamic DNS) and your config has the old one as Endpoint. WireGuard updates endpoints from received packets, but only after first contact; if the change happens before the handshake, you’re stuck with the old endpoint.

Fix: add PersistentKeepalive = 25. For dynamic-DNS situations, use a wrapper like wireguard-dynamic-endpoint-updater that periodically re-resolves the hostname.

10.4 Slow throughput

Symptoms: the tunnel works but is slow — maybe 50 Mbps when the underlying link is 1 Gbps.

Likely causes:

  • You’re using wireguard-go (userspace) when you could use the kernel module.
  • MTU is wrong, causing fragmentation.
  • CPU bottleneck — old CPU, hot CPU, single-thread limit hit because of how flows are distributed across kernel queues.
  • The underlying network is itself the bottleneck.

Diagnose:

iperf3 -c <peer-inner-ip>             # raw throughput over the tunnel
iperf3 -c <peer-outer-ip>             # raw throughput over the underlying network
top                                   # CPU usage on both sides during the test

If tunnel throughput is much less than underlying throughput and CPU is pegged on one core, you’re CPU-bound (likely userspace or single-flow limit). If CPU isn’t pegged but throughput is still low, suspect MTU.

10.5 DNS resolves to wrong place / DNS leaks

Symptoms: the tunnel is up, but DNS queries go to your real ISP, or vice versa.

Cause: DNS = in wg-quick calls resolvconf/systemd-resolved, which on some systems doesn’t actually restrict DNS to the WireGuard-specified server — it just adds it to the pool. Or vice versa, when the tunnel comes down the DNS config doesn’t restore.

Fix: use resolvectl status to see what’s actually configured. Consider using systemd-resolved explicitly with per-interface DNS, or run your own resolver on the WireGuard server and point clients at it.

10.6 General debugging workflow

When you don’t know what’s wrong, run these in order:

  1. sudo wg show — handshake recent? Bytes transferring?
  2. ip addr show wg0 — interface up? Right IP?
  3. ip route get <inner destination> — does the kernel route this through wg0?
  4. sudo tcpdump -i wg0 -nn -c 20 — is decrypted traffic flowing?
  5. sudo tcpdump -i <real interface> -nn udp port <listen-port> -c 20 — is encrypted traffic flowing?
  6. On the other end: same five steps.

If all six look good but it’s still broken, you have an application-layer or higher-level routing problem, not a WireGuard problem.


11. The Downsides / Disadvantages

WireGuard is excellent. It is also not free of cost. The following are structural disadvantages — they don’t go away with experience, configuration tuning, or newer versions. Walk in knowing them.

11.1 No protocol-level user identity, expiry, or PKI

Identity in WireGuard is a public key, full stop. There’s no user-bound identity, no expiry on keys, no certificate revocation list, no group membership. If an employee leaves, you must manually delete their public key from every peer that lists it. There’s no protocol-level mechanism for “this key is invalid now” — silence is the only mechanism, and silence is hard to coordinate across machines.

Where it comes from: deliberate design choice. WireGuard chose to be “key distribution is out of scope” rather than absorb the complexity of certificates. The whole class of CA/PKI complexity is wished away.

What it costs you: in a one-person homelab, nothing. In a team, you need an external mechanism (a config-management system, or one of the control-plane tools like Tailscale/NetBird/Headscale) to coordinate key state. That mechanism is not part of WireGuard. You build or buy it.

When it’s a dealbreaker: in any environment with regulatory identity requirements (HIPAA, PCI, SOC 2 with strict access auditing), running raw WireGuard fails the audit. You need a control plane on top.

What people think mitigates it but doesn’t: scripting peer management with Ansible or Terraform. It works for low-churn environments but every churn event has a window during which deletes haven’t propagated. Real solutions involve a central control plane with revocation that propagates to all peers within seconds.

11.2 No native NAT traversal

Two peers behind symmetric NAT cannot directly establish a WireGuard tunnel. WireGuard has no STUN, no TURN, no ICE. It assumes at least one side is publicly reachable, or both sides can punch through their NATs (which requires non-symmetric NAT and PersistentKeepalive-style cooperation).

Where it comes from: WireGuard does one thing — encrypt packets — and refuses to absorb the complexity of NAT traversal. The official position is “obfuscation and traversal happen above WireGuard.”

What it costs you: for any peer-to-peer scenario where both sides are behind enterprise NAT or carrier-grade NAT, you need a relay server. That’s another piece of infrastructure to deploy and pay for. This is why Tailscale runs DERP relays — it’s not WireGuard’s job.

When it’s a dealbreaker: when your topology is genuinely peer-to-peer (laptops trying to reach laptops, IoT devices trying to reach IoT devices, both behind cellular). You will end up running a relay.

11.3 Fixed cipher suite is great until it’s not

ChaCha20-Poly1305, Curve25519, BLAKE2s. No alternatives. This is wonderful — until the day the cryptography community shows that one of these is broken or weakened. Then you have a non-upgradable protocol.

Where it comes from: the same design principle that gives WireGuard its no-downgrade security — no negotiation surface means no negotiation upgrade either.

What it costs you: if Curve25519 falls to a major quantum advance, every WireGuard deployment must roll out a protocol-incompatible v2. There’s no smooth migration path. Today this is hypothetical, but for long-lived infrastructure (industrial control systems with 20-year deployment horizons), it’s a real concern.

When it’s a dealbreaker: defense, critical infrastructure, anything where you need a “we can swap the cipher with a protocol negotiation update” assurance.

What people think mitigates it but doesn’t: the PSK slot. PSKs help only against passive harvest-now-decrypt-later attacks, and only if you’ve been mixing in PSKs since deployment. They don’t help against active attackers who can compel new sessions and don’t help against weaknesses in BLAKE2s or ChaCha20.

11.4 No FIPS compliance

WireGuard’s chosen primitives aren’t on the FIPS 140 approved list. If you’re in a regulatory environment that mandates FIPS, WireGuard cannot meet that requirement without invasive modifications that defeat the purpose of using WireGuard.

What it costs you: you cannot deploy WireGuard in many US federal contexts, healthcare environments with strict FIPS requirements, or financial-services environments under certain audit regimes. You’re stuck with IPsec.

When it’s a dealbreaker: federal, defense, healthcare-with-strict-audit. There’s no workaround.

11.5 Operational opacity in the silent-by-default model

WireGuard’s stealthiness is a security feature, but it’s an operational anti-feature. There are no error responses to look at, no failure logs at the protocol layer for unauthenticated packets, no built-in metrics or observability. To know if your tunnel is healthy, you have to poll wg show and compare timestamps. There’s no event stream, no SNMP, no Prometheus metrics out of the box, no notifications when a peer disappears.

Where it comes from: WireGuard refuses to leak information to unauthenticated parties, and “operational telemetry” has historically been a great way to leak information.

What it costs you: every production deployment ends up with a custom Prometheus exporter (or one of the open-source ones — prometheus_wireguard_exporter is the common choice), a custom alerting setup (“alert if peer X hasn’t handshaken in 5 minutes”), and a custom dashboard. You build your monitoring layer; WireGuard doesn’t help.

When it’s a dealbreaker: never quite — you can always build observability on top — but it’s a hidden tax on every deployment.

11.6 The protocol is opinionated past the point of usefulness for some workloads

WireGuard insists on UDP. It insists on a small fixed overhead per packet. It insists on its own cipher suite. It insists on no negotiation. For most cases, these are correct opinions. But:

  • UDP-blocked networks (some corporate, some hotel, some national firewalls) require wrapping it in TCP. You’re then operating two protocols.
  • Very low-MTU links (satellite, some IoT) make WireGuard’s fixed overhead proportionally costly.
  • Stateful firewalls that pattern-match on the WireGuard packet signature can block it specifically. Obfuscation tools exist but live outside WireGuard.

Where it comes from: the simplicity-over-flexibility tradeoff that defines the project.

When it’s a dealbreaker: when operating in adversarial network conditions (censorship, restrictive corporate networks). You can wrap WireGuard, but at that point its simplicity advantage is gone.

11.7 The development pace and surface

The protocol is small and stable, which is great. But the ecosystem — wg-quick, the various userspace implementations, the configurator UIs, the control-plane tools — develops at very different rates. The Linux kernel module is rock-solid. wireguard-go is solid but slower. Windows and macOS native apps are decent but have lagged behind Linux historically. Mobile apps are good. Configurator tooling varies wildly in quality.

What it costs you: cross-platform deployments mean cross-platform testing, because behaviour differs subtly. A wg-quick config that works on Linux may not be directly usable on macOS where wg-quick exists but is shell, not the systemd-integrated version.

What people think mitigates it but doesn’t: assuming “they all use the same protocol so they all behave the same.” The protocol is the same; the operational shell around it is not.

11.8 Cannot do everything IPsec can do, by design

WireGuard is layer 3 only. No layer 2 bridging. No transport mode. No multicast (without overlaying something on top). No native multi-hop routing protocols. No protocol-level QoS. No protocol-level fragmentation control. These are all things some IPsec deployments rely on. WireGuard says no to all of them.

When it’s a dealbreaker: when you’re replacing IPsec in a deployment that uses any of those features. You may not be able to.


12. The Taste Test

What does experienced WireGuard usage look like? Here’s how to read a config (or a deployment) and tell the level of the person who built it.

Beginner config

[Interface]
PrivateKey = ...
Address = 10.0.0.2

[Peer]
PublicKey = ...
AllowedIPs = 0.0.0.0/0
Endpoint = some-server:51820

What’s wrong: no /CIDR on Address (defaults to /32, which is fine but ambiguous about intent); AllowedIPs = 0.0.0.0/0 from a client to a single server is correct only if the user actually wants full-tunnel; no PersistentKeepalive despite the client almost certainly being behind NAT; no MTU; no DNS specification; no understanding of whether the server is publicly reachable.

Experienced config

[Interface]
PrivateKey = ...
Address = 10.99.0.42/32
MTU = 1280
DNS = 10.99.0.1
ListenPort = 51820

[Peer]
PublicKey = ...
PresharedKey = ...                # (optional; explicit choice based on threat model)
AllowedIPs = 10.99.0.0/24, 192.168.7.0/24
Endpoint = gateway.example.com:51820
PersistentKeepalive = 25

What’s good: explicit /32 on the host address (no accidental subnet claim); MTU set defensively to 1280; DNS explicitly configured; precise AllowedIPs (not the lazy 0.0.0.0/0 unless full-tunnel is the actual intent); persistent keepalive; a stable endpoint with a DNS name (which is resolved at startup and won’t follow DNS changes — see, you can already see what they’ll next run into).

Operational signals

What you seeWhat it tells you
AllowedIPs = 0.0.0.0/0 and there are other peers in the same wg0Beginner. The wildcard peer will steal traffic from every other peer.
PersistentKeepalive set everywhere, including on the server-side peer entriesBeginner over-application. Only the NATed side needs it; the publicly-reachable side wastes bandwidth doing it.
wg-quick configs with elaborate PostUp/PostDown firewall rulesMature deployment. They’ve thought about return-path filtering, fwmark, MSS clamping.
One config file per peer, generated from a scriptEither a small careful deployment (good) or one outgrowing raw WireGuard (about to switch to Tailscale or similar).
Per-host private keys checked into gitDisaster. Treat private keys like SSH keys.
Monitoring on latest_handshake timestampsExperienced. They’ve learned the hard way that “handshake more than 3 minutes old” is the practical liveness signal.
Distinct interfaces (wg0, wg1) for distinct logical networksExperienced. They know putting everything on wg0 causes AllowedIPs sprawl.
MTU set to 1280Experienced. They’ve fought MTU and decided to stop fighting.
No DNS configuredEither intentional (split-tunnel where local DNS is fine) or a bug waiting to happen (full-tunnel with DNS leaks).

The single best signal

Watch how someone reacts to “the handshake works but no traffic flows.” A beginner says “the tunnel is broken, let me restart it.” An experienced operator says “AllowedIPs on the receiver, return route on the receiver, MTU. Let me check those three.”


13. Where to Go Deeper

A small curated list, opinionated.

  • The technical whitepaperhttps://www.wireguard.com/papers/wireguard.pdf — read this once. It’s about 12 pages and is the single best document on WireGuard’s design rationale. Especially good for understanding why the handshake looks the way it does. Read it after you’ve used WireGuard enough to have questions.

  • The Noise Protocol Framework specificationhttps://noiseprotocol.org/noise.pdf — read this if you want to understand WireGuard’s handshake (it’s based on Noise’s IKpsk2 pattern). Read the framework first, then re-read the relevant section of the WireGuard whitepaper. It will click.

  • The wg(8) and wg-quick(8) man pageshttps://git.zx2c4.com/wireguard-tools/about/src/man/ — read both, end to end. They’re short, dense, and authoritative. The wg-quick man page in particular documents config-file options that are not part of the WireGuard protocol — important to know which side of the line each directive lives on.

  • The Linux kernel module sourcehttps://git.zx2c4.com/wireguard-linux-compat/ — the whole kernel module is roughly 4,000 lines. Skim noise.c, messages.h, and timers.c. Even without deep kernel-development knowledge, you can read what the protocol is doing in concrete terms. Few production network protocols offer this kind of audit-by-skim accessibility.

  • The Tailscale bloghttps://tailscale.com/blog/ — Tailscale’s engineers write the best practical content on WireGuard at scale. Especially “How Tailscale Works” and the various NAT-traversal posts.

  • ip-route(8) and Linux policy routinghttps://man7.org/linux/man-pages/man8/ip-route.8.html — to debug WireGuard you have to be comfortable with Linux routing. Pay particular attention to fwmark, ip rule, and how multiple routing tables interact. This is the actual knowledge gap that bites most WireGuard operators.

  • Formal verification of WireGuardhttps://www.wireguard.com/papers/wireguard-formal-verification.pdf — only if you care about the cryptographic proofs. Skip otherwise.

  • Hands-on: build a 3-node mesh from scratch — three VMs, full-tunnel from a “client” through one “gateway” to internet, plus site-to-site between the two non-client nodes. Make every mistake described in Section 7 of this document on purpose, recover from each, and you’ll have permanent intuition.


14. The Final Verdict

WireGuard is the protocol the VPN world should have had thirty years ago. It is the first VPN where the right answer for the small case (two machines, a few lines of config, working in minutes) and the right answer for the big case (an entire fleet, with a control plane like Tailscale on top) share the same wire format and the same security model. That is a remarkable achievement.

What it gets profoundly right: the choice to not be a framework. WireGuard is one cipher, one handshake, one wire format. By refusing to be configurable in the dimensions where configurability historically created bugs, it eliminated an entire class of failure modes — downgrade attacks, cipher mismatches, hard-to-audit code paths, three-way arguments about whether to use SHA-1 or SHA-256. It is the rare piece of network software whose source you can read in an afternoon and understand completely. That is worth more than most people realise; “understandable” is a security property.

The other thing it gets right is statelessness from the wire’s point of view. No connection state means roaming is trivial, NAT-mapping changes are absorbed automatically, restarts are free. The protocol’s hardest moments — handshake, key rotation — are made invisible by the timer state machine. You bring the interface up and forget about it. There’s a calm to a working WireGuard tunnel that no previous VPN achieved.

What it gets wrong, or what it costs you, is essentially one thing wearing several costumes: WireGuard refuses to absorb the complexity of the real world, and that complexity goes somewhere. Key distribution: your problem. NAT traversal: your problem. User identity and revocation: your problem. Operational observability: your problem. Censorship resistance: your problem. The protocol is small and clean because everything that would make it bigger and messier has been pushed out of scope. In a one-person homelab, that pushing-out costs nothing. In a serious deployment, it means you either build the missing layers yourself or buy them from Tailscale/NetBird/Cloudflare. There is no third path. The “just use raw WireGuard at scale” path is the path of the person who has not yet hit churn, multi-NAT, MTU diversity, or audit.

Who should reach for this: anyone connecting a small number of long-lived endpoints (site-to-site VPN, personal device-to-server access, simple home networking). Anyone whose threat model is “I want a strong, modern, well-reviewed crypto tunnel and I don’t trust IPsec to be correctly configured.” Anyone building a layer on top of WireGuard — the protocol is exactly the right primitive for a higher-level coordinator to compose with. And anyone replacing OpenVPN, where the speed and simplicity wins are enormous and the migration is mostly painless.

Who shouldn’t: anyone under FIPS or similar regulatory regimes that mandate AES-GCM and IPsec. Anyone whose VPN must traverse heavily censored networks where UDP is blocked — you can wrap WireGuard in obfuscation, but at that point you’re really running an obfuscation protocol that happens to carry WireGuard inside it. Anyone needing layer-2 bridging, multicast, or protocol-native QoS. Anyone whose deployment depends on certificate-based identity with revocation built in.

What you should now believe:

  • Believe that simplicity is a security property — WireGuard’s small surface and fixed cipher suite are the reason it has had zero protocol-level vulnerabilities since reaching kernel-quality maturity, while IPsec stacks keep finding new ones decades in.
  • Believe that the silence-by-default model is correct — it’s operationally annoying and security-essential, in that order. The annoyance is real; build your observability layer; do not weaken the silence.
  • Believe that AllowedIPs is the protocol — the cryptokey routing table isn’t a feature of WireGuard, it is WireGuard. If you don’t have crisp intuition about AllowedIPs, you don’t yet understand WireGuard.
  • Don’t believe that raw WireGuard scales to teams — it doesn’t, and the people who claim it does have either small teams or hidden custom tooling.
  • When someone says “WireGuard is fast” — believe them, but know the comparison. It’s not magically fast; it’s that everything else is slow because of accumulated history. The right comparison isn’t “WireGuard vs. theoretical limit” — it’s “WireGuard vs. OpenVPN/IPsec,” where WireGuard wins by 5-10× and uses far less CPU.

The hard-won line: WireGuard is the rare piece of software where the gap between “I can use it” and “I deeply understand it” is small enough that you can close it deliberately, in a weekend, and never have to take it on faith again. That’s the gift. The cost is that everything WireGuard refuses to handle — every NAT, every churn event, every observability question, every odd network — comes back to you in person. Don’t mistake the protocol’s simplicity for your deployment’s simplicity. They are not the same thing.


The ideas are mine. The writing is AI assisted