Why I Built Port Knocking for WireGuard
I run WireGuard on my home server to access internal services remotely. The problem is that WireGuard listens on a UDP port—51820 by default—and that port is visible to anyone scanning my public IP. While WireGuard itself is secure, I didn't like the idea of advertising my VPN's existence to every bot and script kiddie on the internet.
I wanted the port completely invisible unless I explicitly knocked first. Not hidden behind a different port, not protected by fail2ban—actually closed at the firewall level until I sent the right sequence. That's what port knocking does: it keeps a service invisible until you prove you know the secret knock.
My Real Setup
I use nftables on a Debian 12 host running Proxmox. This host handles my main firewall rules and sits between my internal network and the internet. WireGuard runs in a container on the same Proxmox node.
I chose nftables over iptables because it's the modern replacement, handles sets more efficiently, and doesn't require separate tools for IPv4 and IPv6. The syntax is cleaner once you get past the initial learning curve.
My port knocking setup uses three UDP ports in sequence: 7001, 8002, and 9003. These aren't special—just random high ports I picked. The sequence must be completed within one second, and once successful, my source IP gets added to a whitelist that allows access to the WireGuard port for 60 seconds.
The nftables Configuration
Here's the actual ruleset I use, stripped of comments:
table inet portknock {
set stage1 {
type ipv4_addr
flags timeout
timeout 1s
}
set stage2 {
type ipv4_addr
flags timeout
timeout 1s
}
set allowed {
type ipv4_addr
flags timeout
timeout 60s
}
chain input {
type filter hook input priority -10; policy accept;
iifname "lo" return
udp dport 7001 add @stage1 { ip saddr timeout 1s }
ip saddr != @stage1 return
udp dport 8002 add @stage2 { ip saddr timeout 1s }
ip saddr != @stage2 return
udp dport 9003 add @allowed { ip saddr timeout 60s } log prefix "portknock success: "
udp dport 51820 ip saddr @allowed accept
udp dport 51820 drop
}
}
This table runs at priority -10, which means it processes packets before the main filter rules. Each knock adds the source IP to the next set with a one-second timeout. If you knock port 7001, you have one second to knock 8002, then another second to knock 9003.
Once you complete the sequence, your IP goes into the "allowed" set for 60 seconds. During that window, you can connect to WireGuard. After 60 seconds, your IP drops out and the port closes again.
The Knock Script
I wrote a simple bash script to send the knock sequence from my laptop:
#!/bin/bash
HOST="$1"
if [ -z "$HOST" ]; then
echo "Usage: $0 "
exit 1
fi
echo "Knocking $HOST..."
echo -n "" > /dev/udp/$HOST/7001
sleep 0.2
echo -n "" > /dev/udp/$HOST/8002
sleep 0.2
echo -n "" > /dev/udp/$HOST/9003
echo "Done. Connect within 60 seconds."
The 0.2 second delays between knocks prevent packet reordering in transit. Without them, the packets sometimes arrive out of sequence and the knock fails.
What Worked
The setup works exactly as intended. The WireGuard port is completely invisible to port scans. I verified this with nmap from an external host—port 51820 shows as filtered, not open or closed.
Once I run the knock script, I can immediately connect through WireGuard. The 60-second timeout is long enough to establish the connection but short enough that I'm not leaving the port open unnecessarily.
The nftables sets handle timeouts automatically. I don't need a separate daemon or cron job to clean up old entries. When the timeout expires, the IP drops out of the set on its own.
Logging the successful knocks lets me see when and from where I've opened the port. This has been useful for debugging connection issues and verifying that the knock actually worked.
What Didn't Work
My first attempt used TCP SYN packets instead of UDP. This seemed more "proper" since it mimics a real connection attempt. The problem is that sending TCP SYN packets without root access requires raw sockets, and the bash /dev/tcp trick doesn't work for this—it establishes a full connection, which isn't what you want for knocking.
I tried using netcat for TCP knocks, but it was clunky and still required the -z flag to avoid hanging. UDP packets are simpler: you send them and move on. They don't require acknowledgment or state tracking.
I initially set the "allowed" timeout to 10 seconds, thinking that was enough to connect. It wasn't. WireGuard's handshake sometimes takes 15-20 seconds depending on network conditions. I bumped it to 60 seconds and haven't had issues since.
The one-second timeout between knock stages is tight. If there's any network latency or packet loss, the knock fails and you have to start over. I considered increasing it to two seconds, but that would make the sequence easier to stumble upon by accident or brute force. One second is the right balance for my setup.
IPv6 Limitation
My current ruleset only handles IPv4. I haven't added IPv6 support yet because my ISP doesn't provide native IPv6, and I don't use it internally. Adding it would require duplicating the sets and rules for ipv6_addr types, which is straightforward but something I haven't needed.
Key Takeaways
Port knocking isn't security through obscurity if you use it correctly. The WireGuard connection itself is still cryptographically secure. The knock just removes the port from public view, which reduces noise and eliminates the attack surface for potential WireGuard vulnerabilities.
nftables sets with timeouts are perfect for this. They're efficient, automatic, and don't require external tools. The syntax is weird at first, but once you understand how sets work, it's more flexible than iptables.
UDP is the right protocol for knocking. It's simple, doesn't require special privileges to send, and works well with bash's /dev/udp device.
The timeout values matter. Too short and legitimate connections fail. Too long and you're leaving the port open unnecessarily. 60 seconds works for WireGuard; your mileage may vary for other services.
This approach scales to any service you want to hide. I've considered using it for SSH as well, but I already use key-based auth and fail2ban there. For WireGuard, where the port is inherently exposed, it made more sense.