How WireGuard works: cryptokey routing
AllowedIPs is the routing table and the access list at once. Learn cryptokey routing, the Noise handshake and rotation, and every wg0.conf reads clearly.
How WireGuard works, in one idea
WireGuard works by tying every packet to a public key. The mechanism has a name, cryptokey routing, and it is the whole design: the AllowedIPs line next to a peer is the routing table for packets leaving your machine and the access control list for packets arriving from that peer. One setting, two jobs. Read AllowedIPs that way and every WireGuard config file becomes readable.
There is no session table keyed by IP address and no user database. A peer is a public key plus the set of addresses that key may use. The handshake and the timers exist to keep that binding true while the network underneath changes. If you want a working tunnel before the theory, build one with a self-hosted WireGuard VPN on your own VPS, then come back here when a config line surprises you.
AllowedIPs is a routing table and an access list
Take the outbound direction first. Your kernel routes a packet to the wg0 device the usual way, through the main routing table. WireGuard then matches that packet's destination address against one table holding every peer's allowed prefixes, longest prefix first. A match names a peer, which names a public key, which names a session key and a UDP endpoint. The packet is encrypted for that peer and sent there.
If no peer's AllowedIPs covers the destination, nothing is sent, because there is no key to send it with.
ping: sendmsg: Required key not availableThat error means one thing: the address you tried to reach is not listed under any peer. A different error, ping: sendmsg: Destination address required, means a peer did match but WireGuard has no endpoint for it, because none was configured and none has been learned yet.
Now the inbound direction. A UDP packet arrives on the listen port. WireGuard finds the session from the receiver index in the header, checks the counter against a sliding replay window, then decrypts and authenticates the payload. Only after that does it read the inner packet, and the source address of that inner packet must fall inside the sending peer's AllowedIPs. If it does not, the packet is dropped. With dynamic debug enabled, the kernel prints the reason, in a line like this one:
wg0: Packet has unallowed src IP (10.8.0.9) from peer 2 (203.0.113.10:51820)This is why a peer on the server side gets a /32. A peer configured with AllowedIPs = 10.8.0.2/32 may send packets from 10.8.0.2 and from no other address. Write 0.0.0.0/0 there instead and that single client is allowed to inject packets claiming any source address inside your tunnel, including another client's.
Overlapping prefixes resolve by specificity, since the lookup is longest prefix match. Identical prefixes on two peers behave differently: the entry moves to whichever peer was configured last, and the first peer stops receiving that traffic with no error printed anywhere. wg show wg0 allowed-ips prints the table that is actually in the kernel, which is what counts when the file on disk and the running state have drifted apart.
Reading a config file with cryptokey routing in mind
The server side:
[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = <server private key>
[Peer]
PublicKey = <laptop public key>
AllowedIPs = 10.8.0.2/32The client side:
[Interface]
Address = 10.8.0.2/32
PrivateKey = <laptop private key>
[Peer]
PublicKey = <server public key>
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25The same keyword carries opposite weight on the two sides. On the client it says "send every destination to this peer". On the server it says "accept only this one address from this peer". The asymmetry lives in the values, not in any role.
Half of these keys are not protocol at all. Address, DNS, MTU, PostUp and SaveConfig belong to wg-quick, the shell script that brings the interface up. The kernel never sees them. wg-quick strip wg0 prints the reduced config that the wg tool actually loads, which is the fastest way to see that split.
What the handshake actually does
WireGuard's handshake is Noise_IKpsk2, from the Noise Protocol Framework. The IK part is the useful bit for a sysadmin: the responder's static public key is already known to the initiator, since it is the PublicKey in your [Peer] block, and the initiator sends its own static public key inside the first message, encrypted. So there is no certificate exchange and no identity round trip. A passive observer cannot tell which key is calling unless it holds the responder's private key.
The cost is one round trip. The initiation message is 148 bytes, the response is 92 bytes, and data flows immediately after. Each side generates a fresh ephemeral Curve25519 key pair per handshake, and the session keys come from a chain of Diffie-Hellman results that mixes the static and the ephemeral keys. The ephemeral private keys are then discarded, which gives forward secrecy: someone who records your traffic today and steals the server's private key next year still cannot read what they recorded.
A handshake initiation carries a TAI64N timestamp, and each peer remembers the greatest timestamp it has seen from the other, so a replayed initiation is rejected. Data packets carry a 64-bit counter used as the nonce, and the receiver keeps a sliding window of recently seen counters, so replays and heavy reordering are handled with no TCP-style connection state.
Session keys do not last long, and the timers are compiled in rather than configurable.
The data behind this chart
[
{
"label": "REKEY_TIMEOUT",
"seconds": 5,
"notes": "resend a handshake initiation that got no answer"
},
{
"label": "KEEPALIVE_TIMEOUT",
"seconds": 10,
"notes": "send a keepalive after receiving data and sending none back"
},
{
"label": "REKEY_ATTEMPT_TIME",
"seconds": 90,
"notes": "give up on the handshake and report the peer as down"
},
{
"label": "REKEY_AFTER_TIME",
"seconds": 120,
"notes": "sender begins a fresh handshake for a new session key"
},
{
"label": "REJECT_AFTER_TIME",
"seconds": 180,
"notes": "the old session key is refused and traffic stops"
}
]These are constants from the protocol specification, not measurements. The 5 of them drive the whole session lifecycle. After 120 seconds of use the sender starts a fresh handshake, and after 180 seconds the old key is refused outright, so traffic stops until a new handshake completes. An initiation that gets no answer is resent every 5 seconds and abandoned after 90 seconds. This is why wg show prints latest handshake as a relative age, and why a busy healthy tunnel keeps that age small. An age that grows while you are actively sending traffic means handshakes are failing, not that the tunnel is idle.
Why a peer has no client or server role
Both ends run identical code and the same config format. There is no server mode. The asymmetry you feel comes from Endpoint, and Endpoint is optional.
A peer with a configured endpoint can start a handshake. A peer without one waits, then learns the other side's address and port from the first packet that authenticates correctly. That learned endpoint is stored, and it is updated whenever a valid packet arrives from a new address. This is how roaming works: a laptop moving from wifi to a mobile network keeps the same tunnel, because a session is identified by key and index rather than by IP address. Nothing reconnects, because nothing was ever connected in the TCP sense.
The same mechanism creates a fact worth knowing: the peer with the public address always holds the other side's last known public IP, and wg show prints it.
Fixed primitives, and nothing to negotiate
There is no ciphersuite list in WireGuard. ChaCha20-Poly1305 for authenticated encryption, Curve25519 for key agreement, BLAKE2s for hashing, HKDF for key derivation. Every deployment uses those, so there is no negotiation phase to parse and no downgrade path to a weaker option. The trade is real: if one of those primitives is broken, the fix is a new version of the whole protocol and an update on both ends, not a config change. That single decision removes most of the code and most of the failure modes a TLS-based tunnel carries, which is what the comparison in WireGuard against OpenVPN mostly comes down to.
Why the port does not answer a scanner
Every handshake message carries a field called mac1. It is a MAC (message authentication code) computed over the message with a key derived from the responder's static public key. A sender who does not know that public key cannot produce a valid mac1, and the receiver drops such a packet with no reply at all. No error, no reset, no ICMP message.
The visible result is a UDP scan that gets nothing back.
sudo nmap -sU -p 51820 vpn.example.comnmap reports open|filtered, which is the same answer it gives for a port that a firewall silently drops. The port behaves the same whether WireGuard is listening or not, at least for anyone who does not already hold your public key.
A second field, mac2, handles denial of service pressure. When the receiver is under load it answers a valid initiation with a 64-byte cookie reply tied to the sender's source address, and it refuses to do expensive public key work until the sender echoes that cookie back. That proves the source address is real before any CPU is spent on it, and it only activates under load.
Why 0.0.0.0/0 turns a peer into your default route
Because AllowedIPs is the routing table, AllowedIPs = 0.0.0.0/0, ::/0 claims every destination for that peer. That is the entire full tunnel setting.
The routing that makes it work is more interesting than the line itself. A plain default route through wg0 would loop, because the encrypted UDP packet carrying your traffic also has to leave the machine, and it would match its own default route. wg-quick avoids that with policy routing. It marks WireGuard's own outgoing packets with an fwmark, puts the tunnel default route in a separate routing table, and adds rules so only unmarked traffic reaches it. Run ip rule show and you see the result:
32764: from all lookup main suppress_prefixlength 0
32765: not from all fwmark 0xca6c lookup 51820
32766: from all lookup main0xca6c is 51820 in hex, and 51820 is also the table number. The suppress_prefixlength 0 rule makes the main table skip its own default route, so specific routes such as your local subnet still win while everything else falls through to the tunnel table. A split tunnel needs none of this: a narrower list such as AllowedIPs = 10.8.0.0/24, 10.20.0.0/16 becomes ordinary routes in the main table.
One thing a full tunnel does not fix by itself is name resolution, because the resolver your client learned from the local network usually stays in place and its route is more specific. That is a separate job, covered in DNS that leaks outside a WireGuard tunnel.
What PersistentKeepalive is really for
WireGuard sends nothing when there is no traffic. No heartbeat, no session refresh, nothing on the wire. That silence helps battery life and it helps the scanner case above, and it breaks one specific setup.
A peer behind NAT (network address translation) or behind a stateful firewall is reachable from outside only while a mapping exists in that device, and the mapping was created by an outgoing packet. Common UDP mapping lifetimes start at about 30 seconds. Once the mapping expires, packets from the public side are dropped by the middlebox, and the tunnel looks dead until the peer behind NAT sends something. PersistentKeepalive = 25 sends an empty authenticated packet every 25 seconds, which sits under that shortest common lifetime, so the mapping stays open.
Set it on the peer behind NAT. A server with a public address and an open UDP port does not need it, and setting it there only adds traffic. Do not confuse it with the automatic keepalive, which fires 10 seconds after a peer receives data and has nothing of its own to send back. That one is always on and cannot be configured.
Routing your LAN over the tunnel is not a WireGuard feature
Say peer B sits on a home network 192.168.50.0/24, and peer A should reach it. Two separate systems have to agree, and only one of them is WireGuard.
WireGuard's part: add 192.168.50.0/24 to B's AllowedIPs on A. That makes A route the prefix to B, and it makes A accept packets carrying those source addresses from B. Without it, cryptokey routing has no key for the destination and no permission for the source.
The kernel's part: on B, net.ipv4.ip_forward must be 1, or the kernel drops every decrypted packet that is not addressed to B itself. The forward chain of B's firewall has to allow the traffic. The hosts on the LAN need a route back toward 10.8.0.0/24, or B has to apply source NAT so replies come back through B.
WireGuard's job ends when it hands the decrypted packet to the kernel. Everything after that is ordinary Linux routing and filtering, which is why this failure shows up in nft list ruleset counters or in ip -s link show wg0, and not in wg show. If you would rather manage peers through a web interface, running wg-easy in Docker generates the peer entries for you, though the forwarding rules still belong to the host.
Why WireGuard lives in the kernel
wg0 is a network device driver. Packets reach it through the normal routing stack, are encrypted in softirq context, and leave through a UDP socket without ever crossing into userspace. That is where the throughput comes from, and it is also why the module stays around four thousand lines of code, small enough to review and to merge into mainline Linux 5.6 in March 2020. Ubuntu 24.04 and Debian 13 ship it, so only the wireguard-tools package is missing.
Being a normal interface has practical consequences. tcpdump -ni wg0 shows the plaintext inner packets while tcpdump -ni eth0 udp port 51820 shows the encrypted outer ones, and comparing the two tells you at once which direction is broken. netfilter and traffic shaping treat wg0 like any other link. Where the kernel module is unavailable, such as on container virtualisation that shares the host kernel, wireguard-go implements the same protocol in userspace over a TUN device, at a real cost in throughput because every packet crosses the kernel boundary twice.
What WireGuard does not protect you from
The threat model is narrow on purpose, and a protocol this quiet invites wishful thinking. State it plainly.
- It does not hide that you are using WireGuard. Handshake messages have fixed sizes, the first byte gives the message type, and the transport is UDP. Deep packet inspection recognises it easily, and a network that dislikes VPNs can block it. Obfuscation was left out by design.
- It does not hide volume or timing. Payloads are padded only to a 16-byte boundary, so an observer still sees when you send and roughly how much.
- It keeps the last known endpoint. The peer with the public address stores the other side's current public IP, and
wg showdisplays it. Together with a tunnel address that is fixed in the config, that is a stable identifier following a user between networks. On your own VPS this is fine. It is also why commercial services add a layer above the protocol. - It authenticates a key, not a person. Whoever holds the private key file is the peer. Keep
/etc/wireguardat mode 700 and the key files at 600. - There is no revocation list and no expiry. Access ends when you delete the peer entry from every server that holds it, and static keys live until you remove them.
None of this makes WireGuard weak. It makes it small, and small is the point: it authenticates and encrypts, and it leaves identity management and address allocation to whatever you build above it. A coordination layer of the kind described in WireGuard compared with Tailscale exists to fill exactly that gap, using the same data plane you have just read about.
FAQ
What is cryptokey routing in WireGuard?
Cryptokey routing is the rule that binds every packet to a public key. Each peer entry carries a list of prefixes in AllowedIPs. Outbound, WireGuard picks the peer by matching the packet's destination against every peer's list, longest prefix first, so the list acts as a routing table. Inbound, once a packet is decrypted and authenticated, its inner source address must fall inside that same peer's list or it is dropped, so the list acts as an access control list. WireGuard has no separate routing config and no separate internal firewall, because that one list does both jobs.
Do I need PersistentKeepalive on both peers?
No. Set it on the side behind NAT (network address translation) or behind a stateful firewall, which is usually the client. WireGuard sends nothing while idle, so the mapping that lets the far side reach that peer expires, often within a minute, and the tunnel then looks dead in one direction. PersistentKeepalive = 25 sends an empty authenticated packet every 25 seconds and holds the mapping open. A peer with a public address and an open UDP port does not need it.
Why does ping over the tunnel say "Required key not available"?
Because the destination address is not inside any peer's AllowedIPs, so cryptokey routing found no key to encrypt the packet with and the kernel refused to send it. Run wg show wg0 allowed-ips and compare that output with the address you are pinging. The similar error Destination address required is a different problem: a peer matched, but WireGuard has no endpoint for it, because none was configured and no authenticated packet has arrived from that peer yet.
Can a firewall detect and block WireGuard?
Yes. WireGuard authenticates and encrypts your traffic, and it makes no attempt to disguise itself. Handshake messages are a fixed 148 and 92 bytes, the first byte of every message identifies its type, and the transport is UDP, so deep packet inspection identifies the protocol without difficulty. Networks that block UDP or that fingerprint protocols will stop it. Hiding the tunnel means wrapping it in something else, which is a separate tool rather than a WireGuard setting.