Networking
The WireGuard Protocol
A whole VPN in 4,000 lines — one cipher suite, no knobs to get wrong
WireGuard is a lean, fast VPN protocol — about 4,000 lines of kernel code — built on a fixed modern crypto suite (Curve25519, ChaCha20-Poly1305, BLAKE2s) and the Noise IKpsk2 handshake, giving a stateless, kernel-friendly encrypted tunnel.
- Kernel code size≈ 4,000 lines
- HandshakeNoise IKpsk2 (1-RTT)
- Key agreementCurve25519 ECDH
- Data cipherChaCha20-Poly1305
- TransportUDP, any port
- Rekey interval≈ 2 minutes
Interactive visualization
Press play, or step through manually. The visualization is yours to drive — try it before reading on.
Watch the 60-second explainer
A condensed visual walkthrough — narrated, captioned, under a minute.
The intuition: a VPN with no settings to misconfigure
Most VPN incidents are not broken math — they are broken configuration. A weak cipher left enabled, a renegotiation bug, a downgrade path, a certificate the operator forgot to revoke. WireGuard's founding idea, designed by Jason Donenfeld and first released in 2016, is to delete the configuration surface where those mistakes live. There is exactly one cipher suite, exactly one handshake, and almost nothing to tune.
The entire mental model is two numbers and a table. Each peer has a long-term Curve25519 key pair. You identify a peer by its public key — that is its name, its address, and its access-control identity all at once. Then you write down, for each peer, the list of IP ranges that peer is allowed to send and receive: its AllowedIPs. That table is called cryptokey routing, and it is the whole protocol's brain.
Outbound: when a packet needs to leave for 10.0.0.7, WireGuard looks up which peer's AllowedIPs contain 10.0.0.7, encrypts the packet to that peer's public key, and sends it over UDP. Inbound: a decrypted packet is dropped unless its source IP falls inside the sending peer's AllowedIPs. Routing and firewalling are the same lookup. There is no separate identity, no username, no certificate authority — the key is the identity.
The other deliberate choice is silence. A WireGuard endpoint never answers a packet it cannot cryptographically authenticate. Send it garbage and it sends nothing back. To a port scanner, a live WireGuard server is indistinguishable from a closed UDP port — no banner, no version string, no unauthenticated reply to probe.
The mechanism: a 1-RTT Noise handshake
WireGuard's handshake is the Noise IKpsk2 pattern from Trevor Perrin's Noise Protocol Framework. "IK" means the initiator already knows the responder's static public key (Initiator transmits its static key, responder's static is Known in advance); "psk2" means an optional pre-shared symmetric key is mixed in at the second message for post-quantum hedging. The exchange is a single round trip: the initiator sends a handshake initiation, the responder sends a handshake response, and both sides can now send encrypted data.
Underneath, the handshake performs four Curve25519 Diffie-Hellman operations and folds each result into a running hash chain with BLAKE2s-based HKDF. Writing DH(x, Y) for the shared secret from private key x and public key Y, with s for static and e for ephemeral keys:
DH(e_i, s_r) initiator ephemeral × responder static → binds to responder identity
DH(s_i, s_r) initiator static × responder static → mutual authentication
DH(e_i, e_r) initiator ephemeral × responder ephemeral → forward secrecy
DH(s_i, e_r) initiator static × responder ephemeral → authenticates initiator
Each result is mixed into a chaining key C via HKDF, and a transcript hash H absorbs every public field, so any tampering with the messages changes the keys and the AEAD tags stop verifying. Because two of the four operations involve a fresh ephemeral key that is discarded after the session, WireGuard has perfect forward secrecy: stealing a static private key today does not decrypt yesterday's captured traffic.
Two more defenses ride along. The initiation carries an encrypted timestamp (TAI64N) so a replayed initiation is detected and ignored — this is the anti-replay guard on the handshake itself. And to keep the handshake cheap under flood, expensive DH work is gated by a stateless cookie: under load the responder replies with a MAC cookie derived from the initiator's IP, forcing the initiator to prove address ownership before the server spends a single Curve25519 multiply. The result is a denial-of-service-resistant, identity-hiding, forward-secret key exchange that completes in one round trip.
The data path: counter nonces, not handshakes
Once keys are established, every data packet is a small fixed header plus a ChaCha20-Poly1305 sealed payload:
message_data {
u8 type = 4
u8 reserved[3]
u32 receiver_index // which session this is for (no IP lookup needed)
u64 counter // monotonically increasing nonce
u8 encrypted_payload[] // ChaCha20-Poly1305(key, counter, inner_IP_packet)
}
The 64-bit counter doubles as the AEAD nonce, so there is no per-packet random nonce to mishandle. The receiver runs a sliding-window replay filter (the same idea as IPsec ESP, default window 2048 packets): a counter that is too old, or one already seen, is silently dropped. The receiver_index lets the kernel find the right session in O(1) without a routing lookup, which is part of why the data path is fast.
Rekeying is automatic and quiet. A session is retired after roughly 2 minutes or 260 messages, whichever comes first, and the initiator triggers a new handshake before that limit. Keep-alive packets (off by default, commonly set to 25 seconds) keep NAT mappings warm. And because the data header carries no source identity beyond the session index, a roaming laptop can move from Wi-Fi to LTE and the server simply updates the peer's endpoint to the new IP and port the moment an authenticated packet arrives from it.
When to choose WireGuard
- Site-to-site and point-to-point tunnels where you control both ends and can exchange public keys out of band.
- Roaming clients — phones and laptops that change networks constantly. WireGuard's endpoint roaming and tiny handshake reconnect almost instantly.
- High-throughput links where OpenVPN's userspace crypto is the bottleneck. WireGuard's in-kernel ChaCha20-Poly1305 routinely beats it severalfold.
- Embedded and battery-powered devices — a stateless data path and short, silent handshakes mean less CPU and radio wake-up.
It is a worse fit when you need centralized user authentication (RADIUS, SSO, per-user certificates with revocation), dynamic IP assignment like a DHCP-style VPN pool, or deep policy per session — WireGuard intentionally has none of that, and you bolt it on with userspace tooling (Tailscale, Netmaker, wg-quick plus scripts) rather than the protocol. If you must traverse a network that blocks UDP entirely, WireGuard needs a wrapper (udp2raw, a TCP tunnel) because it has no built-in TCP fallback.
WireGuard vs OpenVPN vs IPsec
| WireGuard | OpenVPN | IPsec (IKEv2) | |
|---|---|---|---|
| Codebase size | ≈ 4,000 lines (kernel) | ≈ 100,000+ lines (+OpenSSL) | Hundreds of thousands (StrongSwan/XFRM) |
| Crypto agility | None — one fixed suite | Fully negotiable | Fully negotiable (many proposals) |
| Handshake round trips | 1-RTT (Noise IKpsk2) | TLS handshake, multi-RTT | IKE_SA_INIT + IKE_AUTH (2-RTT) |
| Transport | UDP only | UDP or TCP | UDP (4500/500), ESP |
| Identity model | Public key = identity | X.509 certs or PSK | Certs, PSK, EAP, RADIUS |
| Roaming / IP change | Built-in, seamless | Reconnect required | MOBIKE extension |
| Runs where | Kernel (Linux/BSD), userspace (Go/Rust) | Userspace | Kernel data path, userspace IKE |
| Unauthenticated reply surface | None — silent | TLS error responses | IKE responds to probes |
| Throughput (typical 1 Gbps link) | Near line rate | CPU-bound, often < 500 Mbps | Near line rate (kernel ESP) |
The headline trade-off is agility for simplicity. OpenVPN and IPsec let you negotiate ciphers, swap auth backends, and integrate enterprise identity — at the cost of a vast attack surface and a long history of downgrade and renegotiation bugs. WireGuard removes the knobs: if a primitive ever breaks, the fix is a new protocol version, not a per-connection negotiation. For enterprises that need user-level policy, IPsec still wins; for everyone tunneling between machines they own, WireGuard is smaller, faster, and harder to misconfigure.
What the numbers actually say
- ≈ 4,000 lines of kernel code versus ~100,000 for OpenVPN plus OpenSSL. The small surface is the security argument — Donenfeld's design goal was an implementation one person can audit in a sitting, and it merged into mainline Linux 5.6 on 29 March 2020 after Linus Torvalds called it "a work of art."
- One handshake message is 148 bytes (initiation) and the response 92 bytes — the entire key exchange is well under a single Ethernet frame, so it completes in one round trip with no fragmentation.
- Data overhead is 32 bytes per packet: a 16-byte WireGuard header plus a 16-byte Poly1305 authentication tag, on top of the UDP/IP outer headers.
wg-quicktherefore defaults the tunnel interface MTU to 1420 bytes (the 1500-byte path MTU minus 80 bytes of worst-case IPv6 + UDP + WireGuard overhead). - ChaCha20-Poly1305 is a software cipher by design. It hits roughly 1–3 GB/s per core on commodity CPUs without AES hardware, which is why WireGuard performs well on ARM phones and routers where AES-NI is absent — and why it can saturate a 1 Gbps link where userspace OpenVPN often stalls below 500 Mbps.
- Rekey every ~120 seconds, anti-replay window of 2048 packets, session cap of 260 messages. These bounds keep the 64-bit counter far from wraparound and bound the blast radius of any single session key.
JavaScript: cryptokey routing and the replay window
The protocol's crypto belongs in a vetted library, but the two pieces of plumbing you actually configure — longest-prefix cryptokey routing and the sliding-window replay filter — are short enough to show end to end.
// Cryptokey routing: pick the peer whose AllowedIPs has the
// longest prefix matching the destination (like IP routing).
class CryptokeyRouter {
constructor() { this.peers = []; } // { pubkey, cidrs: [{ip, mask, bits}] }
addPeer(pubkey, allowedIPs) {
const cidrs = allowedIPs.map(s => {
const [ip, bitsStr] = s.split('/');
const bits = parseInt(bitsStr, 10);
const ipNum = ip.split('.').reduce((a, o) => (a << 8 | (+o)) >>> 0, 0);
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
return { ip: ipNum & mask, mask, bits };
});
this.peers.push({ pubkey, cidrs });
}
// Outbound: which peer should encrypt a packet for `dest`?
routeOutbound(dest) {
const d = dest.split('.').reduce((a, o) => (a << 8 | (+o)) >>> 0, 0);
let best = null, bestBits = -1;
for (const peer of this.peers)
for (const c of peer.cidrs)
if ((d & c.mask) === c.ip && c.bits > bestBits) { best = peer; bestBits = c.bits; }
return best; // null ⇒ no route, drop the packet
}
// Inbound: a decrypted packet from `peer` is only legal if its
// SOURCE address lies inside that peer's AllowedIPs.
acceptInbound(peer, src) {
const s = src.split('.').reduce((a, o) => (a << 8 | (+o)) >>> 0, 0);
return peer.cidrs.some(c => (s & c.mask) === c.ip);
}
}
// Sliding-window anti-replay (WireGuard default window = 2048).
class ReplayWindow {
// last = -1 means "no packet seen yet"; the first data packet's nonce is 0.
constructor(size = 2048) { this.size = size; this.last = -1n; this.seen = new Set(); }
check(counter) { // returns true if packet is fresh
if (counter > this.last) { // newest so far — slide forward
const lo = counter - BigInt(this.size);
for (const c of [...this.seen]) if (c <= lo) this.seen.delete(c);
this.last = counter; this.seen.add(counter); return true;
}
if (this.last - counter >= BigInt(this.size)) return false; // too old
if (this.seen.has(counter)) return false; // replayed
this.seen.add(counter); return true;
}
}
Two things to notice. The router uses longest-prefix match, exactly like a kernel routing table — a peer with 0.0.0.0/0 is the catch-all "route everything through the VPN," while a more specific /24 peeling off wins for its subnet. And the replay window enforces freshness, not order: out-of-order packets within the 2048-wide window are accepted once, but a counter the receiver has already recorded is dropped, defeating replays without forcing strict in-order delivery.
Python: deriving session keys with the Noise chain
This is a faithful sketch of how WireGuard turns the four Diffie-Hellman results into a pair of transport keys, using HKDF over BLAKE2s. The DH itself uses Curve25519 (X25519); the mixing logic below is the heart of the Noise IKpsk2 handshake.
import hashlib, hmac
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
CONSTRUCTION = b"Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s"
def blake2s(data): # WireGuard's hash primitive
return hashlib.blake2s(data).digest()
def hmac_blake2s(key, data):
return hmac.new(key, data, hashlib.blake2s).digest()
def hkdf(chaining_key, input_material, n):
"""RFC 5869 HKDF — derive n 32-byte outputs from the chain."""
tmp = hmac_blake2s(chaining_key, input_material) # extract
outs, prev = [], b""
for i in range(1, n + 1): # expand
prev = hmac_blake2s(tmp, prev + bytes([i]))
outs.append(prev)
return outs
def mix_key(chaining_key, dh_output):
"""Fold one DH result into the chain; return (new_chain, ...)."""
return hkdf(chaining_key, dh_output, 2) # [new_chain, key]
def handshake_initiator(my_static: X25519PrivateKey,
their_static: X25519PublicKey,
their_ephemeral: X25519PublicKey,
my_ephemeral: X25519PrivateKey,
preshared_key: bytes = b"\x00" * 32):
c = blake2s(CONSTRUCTION) # initial chaining key
# The four DH operations of the IK pattern, mixed in order:
c, _ = mix_key(c, my_ephemeral.exchange(their_static)) # e_i · s_r
c, _ = mix_key(c, my_static.exchange(their_static)) # s_i · s_r
c, _ = mix_key(c, my_ephemeral.exchange(their_ephemeral)) # e_i · e_r
c, _ = mix_key(c, my_static.exchange(their_ephemeral)) # s_i · e_r
# psk2: mix the optional pre-shared key for post-quantum hedging
out = hkdf(c, preshared_key, 3)
c, tau, key = out[0], out[1], out[2]
# Final split: two directional transport keys (send / receive)
send_key, recv_key = hkdf(c, b"", 2)
return send_key, recv_key
# Each side derives the SAME secret because Curve25519 DH is symmetric:
# DH(a, B) == DH(b, A). No secret ever crosses the wire.
The structure is the takeaway: a single chaining key absorbs DH result after DH result, and the transport keys fall out of one final split. Because the ephemeral keys are thrown away after the session, an attacker who later steals my_static cannot reconstruct send_key — they would also need the discarded ephemerals, which is what forward secrecy buys.
Variants and the wider ecosystem
wireguard-go. The reference userspace implementation in Go. Slower than the kernel module (it copies packets across the user/kernel boundary), but it runs anywhere, including inside other programs as a library. Tailscale embeds a fork of it.
BoringTun. Cloudflare's userspace WireGuard in Rust, written for their WARP product. Memory-safe and cross-platform, it powers WireGuard on iOS and macOS where loading a kernel module is impossible.
Tailscale and Netmaker. These are control planes on top of WireGuard, not new protocols. They keep WireGuard's data path but replace the manual "exchange public keys and edit AllowedIPs" step with a coordination server, SSO login, ACLs, and NAT-traversal/DERP relays — solving exactly the centralized-identity gap WireGuard leaves open.
AmneziaWG. A fork that adds junk-packet padding and header obfuscation so the otherwise fingerprintable WireGuard handshake survives deep-packet-inspection censorship. It trades WireGuard's minimalism for unblockability.
The psk2 post-quantum hedge. WireGuard's optional pre-shared key isn't a replacement for the Curve25519 handshake — it's mixed in addition, so even if a future quantum computer breaks the elliptic-curve DH, an attacker still needs the symmetric PSK to decrypt. It's a pragmatic stopgap until a full post-quantum handshake (e.g., Rosenpass layered in front) is standardized.
Common bugs and edge cases
- AllowedIPs is overloaded and bites everyone. It is simultaneously the outbound route and the inbound source-IP allowlist. Setting it to
0.0.0.0/0on a peer says "send all my traffic here" and "accept any source IP from this peer" — fine for a single full-tunnel client, dangerous on a hub with many peers, where it lets one peer spoof another's addresses. - No keepalive behind NAT. If neither side sets
PersistentKeepalive, an idle tunnel behind NAT goes silent, the NAT mapping expires, and the connection appears "dead" until the peer with a known endpoint sends first. Set keepalive (commonly 25s) on the side behind NAT. - MTU and fragmentation. Forgetting the 32-byte WireGuard overhead leaves the inner MTU too high; large TCP flows then black-hole when PMTU discovery is blocked. Lower the interface MTU to ~1420 (IPv4) and the symptom disappears.
- Reusing a private key across devices. Two devices sharing one static key fight over the same session and corrupt each other's replay counters. Every peer must have its own key pair.
- Clock skew breaks the handshake. The initiation timestamp (TAI64N) is anti-replay state; a peer whose clock jumps backward can have its later initiations rejected as "older" than one already seen. Keep clocks roughly in sync.
- Expecting WireGuard to hide that you use a VPN. WireGuard hides whether a server exists, but its handshake packets have a recognizable shape. On censored networks that fingerprint VPN traffic, vanilla WireGuard is detectable — that's the niche AmneziaWG and obfuscation wrappers fill.
Frequently asked questions
Why does WireGuard have no cipher negotiation?
WireGuard hard-codes one cipher suite — Curve25519, ChaCha20-Poly1305, BLAKE2s — so there is nothing to negotiate and no weak ciphers to downgrade to. If the primitives ever break, you bump the protocol version rather than negotiating per-connection. This kills the entire class of negotiation and downgrade attacks (BEAST, FREAK, Logjam) that plague TLS and IPsec.
What is cryptokey routing in WireGuard?
Each peer is identified by its public key, and each public key is associated with a list of allowed IP ranges. Outbound packets are routed to the peer whose AllowedIPs cover the destination; inbound packets are dropped unless their source IP falls inside the sending peer's AllowedIPs. Routing and access control collapse into one table keyed by public key.
Is WireGuard really only about 4,000 lines of code?
The Linux kernel implementation is roughly 4,000 lines, versus around 100,000 for OpenVPN plus OpenSSL and hundreds of thousands for an IPsec/StrongSwan stack. The small surface is the security argument: the whole thing can be audited in an afternoon, which is why Linus Torvalds called it "a work of art" when it merged into Linux 5.6 in March 2020.
How does WireGuard hide whether a server is even running?
WireGuard is silent by default: an endpoint never replies to a packet it can't authenticate. With no valid handshake initiation, the server sends nothing back, so a port scanner can't distinguish a WireGuard endpoint from a closed UDP port. There is no banner, version string, or unauthenticated response surface.
Does WireGuard support perfect forward secrecy?
Yes. Every handshake mixes fresh ephemeral Curve25519 keys via the Noise IKpsk2 pattern, and peers re-handshake roughly every 2 minutes. Compromising a static private key today does not decrypt yesterday's traffic, because the session keys derived from the ephemerals are discarded after each rekey.
Why does WireGuard run over UDP instead of TCP?
UDP avoids TCP-over-TCP meltdown, where a reliable tunnel carrying a reliable connection stacks two retransmission timers and collapses under loss. UDP also keeps the data path stateless and lets a roaming client change IP and port mid-session — the server simply updates the endpoint when the next authenticated packet arrives from a new address.