3 WireGuard VPN Mistakes That Broke Our Remote Server Access

devops-networking

Context

WireGuard VPN mistakes illustration

WireGuard VPN mistakes rarely announce themselves. That’s the whole problem — the tunnel just quietly stops working, or quietly does the wrong thing, and you find out from a Slack message that says “hey, is the VPN down for anyone else?” We learned this the hard way over about six weeks last year, and I want to walk through exactly what broke and why, because I think we’re not the only team that got bitten by the same shortcuts.

We were migrating off an aging OpenVPN bastion. It worked, technically, but reconnects took 15-20 seconds, the logs were a mess to audit, and every new hire meant another round of certificate generation nobody enjoyed. WireGuard looked like the obvious upgrade: in-kernel, ChaCha20, dead simple config format. We stood up a single hub server running wireguard-tools 1.0.20210914 on Ubuntu 22.04, using the kernel module rather than the userspace implementation, with six remote peers — four engineers and two CI runners that needed access to internal build artifacts.

Day one, it worked. wg show showed green handshakes, SSH over the tunnel was fast, everyone was happy. That’s exactly the trap. WireGuard’s simplicity means a config mistake doesn’t throw an error — it just silently does something you didn’t intend, and you don’t notice until it costs you. This isn’t a “we nailed it” post. Three mistakes cost us an outage, a security exposure, and a weekend. Here’s what actually happened.

Mistake 1: Reusing the same key pair across multiple peers

To speed up onboarding, we cloned a “template” client config for new engineers — Address, DNS, AllowedIPs, all pre-filled, just swap the hostname and go. Except the template also included a pre-generated private key. Two engineers ended up with the exact same keypair on two different laptops.

WireGuard doesn’t error on this. It just lets the most recently connected peer win. wg show on the server showed only one peer online at a time — whichever laptop had connected last simply kicked the other off the tunnel, silently, with zero error message on either end. We burned a few hours chasing “why did my VPN drop for no reason” before someone noticed both configs had identical PrivateKey values.

The fix we applied at the time was wrong: we told people to “just generate your own keys.” No script, no enforcement, just a Slack message and good intentions. A month later a new hire cloned the same template from an old onboarding doc, and we had the exact same collision again. The lesson wasn’t “generate unique keys” — everyone already agreed with that in principle. The lesson was that without a script or a process gate, someone will always take the fast path, especially under onboarding pressure. Per-machine key generation needs to be the only path available, not the recommended one.

Mistake 2: AllowedIPs set to 0.0.0.0/0 without a real routing plan

We wanted full-tunnel security for engineers on public WiFi — route everything through the VPN, not just internal traffic. Reasonable goal. So we set AllowedIPs = 0.0.0.0/0 on the client side, intending WireGuard to route all outbound traffic through the tunnel and out via the server.

What we forgot: our server-side NAT rule was scoped only to the internal subnet.

# server-side NAT — only masquerades traffic from the VPN subnet
# this was fine for split-tunnel, but broke full-tunnel silently
iptables -t nat -A POSTROUTING -s 10.10.10.0/24 -o eth0 -j MASQUERADE

With 0.0.0.0/0 on the client, all traffic — including general internet browsing — got routed into the tunnel. But since the MASQUERADE rule only covered the VPN’s own subnet range for legitimate internal routing, anything outside that scope hit a dead end on the server. Clients lost general internet access entirely. Traffic went into the tunnel and never came back out.

The nasty part: SSH over the tunnel kept working fine, because that traffic stayed within the 10.10.10.0/24 range the whole time. So from an infra perspective, everything looked healthy. It took two days before someone reported “my internet is down, but only when I’m on the VPN, and only on WiFi” — because their 4G hotspot bypassed the VPN app on mobile and worked fine. We had never actually decided whether we wanted split-tunnel or full-tunnel; we just typed 0.0.0.0/0 because it felt like “more security.” Full-tunnel and split-tunnel are different architectures with different NAT requirements, and mixing them without a plan is how you get two days of confused bug reports instead of one clear failure.

Mistake 3: No PersistentKeepalive + firewall left wide open on 51820/udp

This one was actually two separate mistakes that happened to surface around the same time, both stemming from the same root cause: WireGuard fails silently, and we weren’t watching closely enough.

First, peers behind carrier-grade NAT — home routers, 4G modems — would drop after roughly two minutes of idle. No error, no disconnect event, the tunnel would just stop passing traffic. The cause was a stale NAT mapping on the client’s router. Without PersistentKeepalive, WireGuard never sends anything to keep that mapping alive, so once the router’s NAT table entry expired (usually somewhere between 30 and 120 seconds depending on the device), the server had no way to reach the client anymore. The only fix was manually restarting wg-quick on the client, which of course nobody understood the first few times it happened — they just thought the VPN was “flaky.”

Second, separately, we’d applied ufw allow 51820/udp with zero source restriction — wide open to the entire internet, undocumented and unapproved by anyone reviewing the change. WireGuard doesn’t respond to unauthenticated packets, which is genuinely one of its best security properties compared to OpenVPN’s TLS handshake surface. But the port being open at all, with no rate limiting, still got flagged during a security review after a masscan-style sweep hit it repeatedly in our logs. The exposure wasn’t exploitable, but it was invisible and unapproved — which is its own problem in a compliance-sensitive environment.

Both issues sat unnoticed for weeks. There’s no auth log for WireGuard to grep, no failed-connection event to alert on — just connections that quietly stop reconnecting, and a port that’s open but never logs an unauthorized attempt.

What we do differently now

Key generation is scripted, not templated. Every peer gets a unique keypair generated at onboarding time and stored in Vault — never copy-pasted, never cloned from a template file.

#!/bin/bash
# generate-peer.sh — per-peer key generation, run once per new client
# lesson learned: never copy an existing privatekey file between machines

set -euo pipefail

PEER_NAME="${1:?Usage: generate-peer.sh }"
KEY_DIR="/etc/wireguard/peers/${PEER_NAME}"
SERVER_PUBKEY_FILE="/etc/wireguard/server_public.key"
SERVER_ENDPOINT="vpn.kuryzhev.cloud:51820"
CLIENT_SUBNET_BASE="10.10.10"

mkdir -p "${KEY_DIR}"
umask 077  # keys must never be world-readable

# generate unique keypair for this peer only
wg genkey | tee "${KEY_DIR}/private.key" | wg pubkey > "${KEY_DIR}/public.key"
PRIVATE_KEY=$(cat "${KEY_DIR}/private.key")
PUBLIC_KEY=$(cat "${KEY_DIR}/public.key")
SERVER_PUBKEY=$(cat "${SERVER_PUBKEY_FILE}")

# assign next available IP in the /24 — naive counter, replace with real IPAM in prod
NEXT_OCTET=$(( $(ls /etc/wireguard/peers | wc -l) + 1 ))
CLIENT_IP="${CLIENT_SUBNET_BASE}.${NEXT_OCTET}/32"

cat > "${KEY_DIR}/client.conf" <

Split-tunnel is now the default — AllowedIPs scoped to 10.10.10.0/24 only. Full-tunnel is an explicit opt-in flag, documented per use case, and we double-check the routing table with ip route after every peer add. PersistentKeepalive = 25 is mandatory for anything not sitting on a static IP, per the official WireGuard quickstart docs. The firewall rule for 51820/udp now goes through rate-limiting via iptables, since WireGuard has no auth log for fail2ban to hook into — restricting source ranges in the cloud security group does more real work than a generic allow rule ever did.

We also added a standing on-call check using wg show and journalctl -u wg-quick@wg0 -f, with a simple rule of thumb: alert if a peer's latest handshake exceeds 180 seconds when PersistentKeepalive is configured. That single threshold would have caught every one of these WireGuard VPN mistakes days earlier instead of weeks.

# wg show output — how we now spot dead peers before users complain
$ wg show wg0

interface: wg0
  public key: AbCdEf...server_pub...==
  private key: (hidden)
  listening port: 51820

peer: xY12ab...client1_pub...=
  endpoint: 203.0.113.44:51820
  allowed ips: 10.10.10.2/32
  latest handshake: 42 seconds ago       # healthy
  transfer: 1.24 MiB received, 3.87 MiB sent

peer: qR98zt...client2_pub...=
  endpoint: 198.51.100.9:51820
  allowed ips: 10.10.10.3/32
  latest handshake: 18 minutes ago       # stale — likely NAT timeout, no keepalive
  transfer: 890 KiB received, 1.1 MiB sent

# rule of thumb we now enforce: alert if latest handshake > 180s
# for a peer with PersistentKeepalive set — indicates dropped tunnel, not just idle

Worth noting: none of this made WireGuard slower or more expensive. The hub still runs on a $5/mo VPS — the low overhead of in-kernel ChaCha20 handles our peer count fine, though we know a single-core box gets CPU-bound somewhere past 15 concurrent peers doing sustained throughput, so that's on our radar for the next capacity review. If you're weighing a similar migration, I've written more on VPN and network hardening decisions over on kuryzhev.cloud, and the official WireGuard project docs are worth reading end to end before you touch a production config — they're short, and every gotcha above is technically documented there, we just didn't read closely enough the first time.

Related

Leave a Reply

Your email address will not be published. Required fields are marked *

Support us · 💳 Monobank