Introduction
A service that is reachable is a service that can be attacked. The usual answer is authentication: let anyone connect, then prove they are allowed. Port knocking turns that around. The service is firewalled shut and stays invisible until a client presents a secret out-of-band signal first; only then does the firewall open the port, and only for that client, and only briefly. An attacker scanning the device sees nothing to attack.
There is more than one way to send that signal, and they are not equivalent. This tutorial sets up three of them side by side on a single Linux host so they can be compared on the same machine, against the same service:
- a small custom daemon that watches for a sequence of TCP SYNs and opens an nftables set,
- the classic knockd, which watches for the same kind of sequence and drives iptables,
- fwknop, which uses Single Packet Authorization: one encrypted, authenticated UDP packet instead of a sequence.
Each one guards an identical trivial service, on its own port, so the difference you observe is the mechanism and nothing else. We will configure all three, then scan the host from another machine to show that the ports really are invisible until you knock.
The three lanes are independent and identical except for the part in the middle — the same kind of service, each behind its own knock mechanism and its own firewall backend:
A word on what port knocking is, and is not
Before any code, the caveat that has to be made. Port knocking is not authentication, and the sequence-based variants are not cryptography. A knock sequence of three TCP ports is a shared secret of maybe 48 bits sent in cleartext; anyone who can see your traffic can replay it. It is obscurity, and obscurity has a bad name for good reasons.
What it actually buys you is the removal of your service from the attack surface of an untargeted scanner. The overwhelming majority of hostile traffic an internet-facing device sees is broad, automated, and uninterested in you specifically: it scans, finds an open port, and tries known exploits against whatever is listening. A port that does not answer is a port that never makes it onto that list. Port knocking is a filter against the background radiation of the internet, layered in front of real authentication, never instead of it.
The reason an open port is so much more than “found” is fingerprinting. Search engines like Shodan and Censys scan the whole IPv4 space continuously, and they do not just record that tcp/22 is open. They connect, read whatever the service volunteers, and index it. Most protocols are talkative by design: SSH sends an identification string (SSH-2.0-OpenSSH_8.4p1) before you have authenticated anything, TLS hands back a certificate and a cipher list, an HTTP server stamps Server: nginx/1.18.0 into every response. From a single connection a scanner learns the implementation and often the exact version, and an attacker then searches the index the other way round: not “scan this host” but “show me every host running the version with this CVE.” Your device is found because it answered, and targeted because it said what it was.
A silent port breaks that chain at the first link. There is no connection, so there is no banner; no banner, so no version; no version, so nothing to fingerprint and nothing to match against a vulnerability database. The service simply does not exist as far as the index is concerned. That is the property port knocking restores: not secrecy of the data, but absence from the catalogue.
Single Packet Authorization is the response to the obvious weaknesses of the sequence variants. The “knock” is a single packet whose payload is encrypted and carries an HMAC and a timestamp, so it cannot be trivially replayed and does not leak the secret to a passive observer. It is meaningfully stronger. It is also more to configure, and that trade-off is exactly what this tutorial is here to let you feel.
The difference is best seen from the position of someone watching the wire:
The rule of thumb: knocking decides whether the door is visible; it does not decide who gets in. Keep your SSH keys and your TLS exactly as strict as they were.
Step 0: install the tools
Two of the three mechanisms are packaged on any Debian-based distribution (Debian, Ubuntu, Raspberry Pi OS). On the host being protected, install knockd, the fwknop daemon, both firewall front-ends and Python:
sudo apt install knockd fwknop-server nftables iptables python3
On the other machine, the one you will knock and scan from, install the scanner and the fwknop client:
sudo apt install nmap fwknop-client python3
The first approach has no distribution package — it is the small knockdemo Python package that ships with this tutorial (the daemon, the firewall helper, the demo service and a client). Drop it somewhere importable on the host, for example under /usr/lib/python3/dist-packages/knockdemo, so python3 -m knockdemo.<module> works.
Everything below assumes systemd, since that is how the three mechanisms and the demo services are kept running. Unit files go in /etc/systemd/system/; reload with systemctl daemon-reload after dropping each one in.
Step 1: the service worth hiding
The thing being protected should be boring on purpose: the interesting part is the firewall in front of it, not the service itself. A trivial TCP server that writes a banner and hangs up is enough, and using one server for all three approaches keeps the comparison honest. It takes the port as an argument so we can run three instances.
knockdemo/secret_service.py (excerpt):
import socketserver, sys
from . import config
BANNER = b"You knocked. Welcome to the secret service on port %d.\n"
class _Server(socketserver.ThreadingTCPServer):
allow_reuse_address = True
def _make_handler(port):
class _Handler(socketserver.BaseRequestHandler):
def handle(self):
self.request.sendall(BANNER % port)
return _Handler
def main(argv=None):
argv = sys.argv if argv is None else argv
port = int(argv[1]) if len(argv) > 1 else config.load()["service_port"]
with _Server(("0.0.0.0", port), _make_handler(port)) as server:
server.serve_forever()
It runs from a systemd template so the same unit backs every protected port. Drop this in as /etc/systemd/system/secret-service@.service:
[Unit]
Description=Protected demo service on tcp/%i (guarded by a knock mechanism)
After=network.target
[Service]
ExecStart=/usr/bin/python3 -m knockdemo.secret_service %i
Restart=on-failure
[Install]
WantedBy=multi-user.target
We will enable secret-service@6500, @6501 and @6502, one per approach. On its own this service has no protection at all. That is deliberate: the firewall is the only lock, and that separation is the entire lesson.
Step 2: approach A: a custom daemon over nftables
The first approach is the one you would write yourself if you wanted to understand the mechanism end to end. A daemon opens an AF_PACKET socket, watches for inbound TCP SYNs, tracks how far each source IP has gotten through a defined port sequence, and on a complete, in-order, in-time match it authorizes that source.
The firewall side is where nftables earns its place. Instead of adding and later removing an accept rule, we add the source IP to a named set whose elements carry a timeout. The kernel expires the element on its own; the daemon never has to schedule the close.
knockdemo/firewall.py renders this base ruleset and loads it with nft -f -:
add table inet knockdemo
delete table inet knockdemo
table inet knockdemo {
set knock_allow {
type ipv4_addr
flags timeout
}
chain input {
type filter hook input priority filter; policy accept;
tcp dport 6500 ip saddr @knock_allow accept
tcp dport 6500 drop
}
}
The add / delete pair at the top is the idempotent-reset idiom: the add guarantees the table exists so the delete cannot fail, then we install a fresh copy. Only tcp/6500 is dropped; everything else, including the knock ports, is left alone. A successful knock is then a single command:
def authorize(ip, cfg):
subprocess.run(["nft", "add", "element", "inet", cfg["nft_table"], cfg["nft_set"],
"{ %s timeout %ss }" % (ip, cfg["open_window_seconds"])], check=True)
The daemon itself parses packets straight from the socket. Two details matter. It uses SOCK_DGRAM rather than SOCK_RAW, so the kernel strips the link-layer header and the same parser works on Ethernet, a wired NIC and loopback alike. And it filters out PACKET_OUTGOING, because on loopback every packet is also seen on its way out and would otherwise be counted twice:
sock = socket.socket(socket.AF_PACKET, socket.SOCK_DGRAM, socket.htons(0x0800))
...
data, addr = sock.recvfrom(65535)
if addr[2] == socket.PACKET_OUTGOING:
continue
The daemon needs to open a packet socket and call nft, so it runs as root. A unit named knock-demo.service keeps it alive; it installs the base ruleset above on start and watches for the sequence 7000, 8000, 9000. With this approach the port is closed by the daemon’s own nftables table, so there is nothing else to set up for tcp/6500.
Step 3: approach B: knockd over iptables
knockd is the canonical port-knocking daemon. It watches a libpcap capture for the sequence and runs an arbitrary command on match, which is the textbook way to drive iptables. It came in with the knockd package in Step 0; all that is left is the configuration.
The demo configuration opens tcp/6501 for the knocking host for fifteen seconds, then closes it again with a timed stop command. This is knockd doing exactly what the manual shows. Put it in /etc/knockd.conf:
[options]
UseSyslog
[openSecret]
sequence = 7100,7200,7300
seq_timeout = 5
tcpflags = syn
start_command = /usr/sbin/iptables -I INPUT 1 -s %IP% -p tcp --dport 6501 -j ACCEPT
cmd_timeout = 15
stop_command = /usr/sbin/iptables -D INPUT -s %IP% -p tcp --dport 6501 -j ACCEPT
start_command inserts an ACCEPT for the source at the top of INPUT so it is evaluated before the default drop; cmd_timeout = 15 schedules stop_command to remove it again. Run knockd in the foreground under systemd with knockd -D -i eth0 (-D is verbose, and without -d it does not daemonize, which is what systemd wants). Swap in your own interface — ip link lists them.
Note what just happened to the firewall story. Approach A speaks nftables; knockd, by configuration, speaks iptables. On a modern distribution iptables is the nft-backed iptables, so both ultimately talk to the same kernel subsystem, but through different tables. They coexist without coordination because each one only ever touches its own port.
Step 4: approach C: fwknop and Single Packet Authorization
fwknop came in with fwknop-server in Step 0, so approach C is mostly configuration. The client sends one encrypted, HMAC-authenticated SPA packet; fwknopd sniffs for it, validates it, and opens the requested port for the source for a fixed window.
We point fwknopd at the wired interface and give it an access stanza with a shared key pair, generated once with fwknop --key-gen. This goes in /etc/fwknop/access.conf:
SOURCE ANY
OPEN_PORTS tcp/6502
KEY_BASE64 apAjDrWMbZdaGMpRjawbfw==
HMAC_KEY_BASE64 XWlmWPHJu+9VQiEWH/NJ3nxfGJgUro1xoWSfRVQsAdo=
FW_ACCESS_TIMEOUT 15
/etc/fwknop/fwknopd.conf is one line, PCAP_INTF eth0; — the defaults handle the rest, including the SPA listener on udp/62201 and the iptables integration. FW_ACCESS_TIMEOUT 15 is fwknop’s own auto-close, matching the other two approaches.
Unlike the sequence daemons, fwknopd only ever adds an accept; it expects the port to be closed by default. So a small one-shot unit installs the base drops for the two iptables-managed ports before knockd and fwknopd start:
demo-firewall-base.sh:
#!/bin/sh
set -e
for port in 6501 6502; do
if ! iptables -C INPUT -p tcp --dport "$port" -j DROP 2>/dev/null; then
iptables -A INPUT -p tcp --dport "$port" -j DROP
fi
done
Approach A drops tcp/6500 itself, from its own nftables table, so it is not in this list. Wrap that script in a one-shot unit (demo-firewall-base.service, ordered Before=knockd.service fwknopd.service) so the two iptables-managed ports start closed.
Step 5: bring it up and confirm
Enable and start the three mechanisms, the one-shot base firewall, and the three protected services:
sudo systemctl daemon-reload
sudo systemctl enable --now knock-demo demo-firewall-base knockd fwknopd
sudo systemctl enable --now secret-service@6500 secret-service@6501 secret-service@6502
Then check that everything came up:
$ systemctl is-active knock-demo secret-service@6500 knockd fwknopd demo-firewall-base secret-service@6501 secret-service@6502
active
active
active
active
active
active
active
All three mechanisms and all three protected services are running. Note the host’s IP address (ip -4 addr show eth0); the rest of the demo is driven from another machine on the same network, because knockd and fwknopd sniff the wire. A knock from the host to itself over loopback never reaches them, so the clients have to run from somewhere else on the network.
Step 6: the payoff — scan, then knock
This is the part worth showing an audience — the whole arc is a single loop: scan and see nothing, knock, scan and see the port, wait out the timeout, scan and see nothing again.
From another machine, with the host at $PI, scan the three service ports:
$ nmap -Pn -p 6500,6501,6502 $PI
PORT STATE SERVICE
6500/tcp filtered boks
6501/tcp filtered boks_servc
6502/tcp filtered netop-rc
filtered, not closed. The distinction is the whole point. A closed port answers with a TCP RST; nmap can see it is there and not listening, and the host has confirmed it exists. A filtered port answers with nothing at all, so from the scanner’s perspective the port — and any clue about what the device runs — simply does not exist.
The SERVICE column here is nmap’s guess from a port-number table (/etc/services), not anything the host said — boks and netop-rc are simply whatever happens to be registered against 6500 and 6502, and they are wrong. That is exactly the fingerprinting that does not happen. Add version detection (nmap -sV) and, against a normal service, nmap connects, grabs the banner, and prints the real software and version; against a filtered port it has nothing to connect to and so nothing to report. No banner reaches the scanner, and nothing reaches a service like Shodan to be indexed.
Now knock each one. Approaches A and B are just TCP SYNs to a sequence of ports, which the knockdemo client sends (it is pure standard library and runs anywhere with Python):
$ python3 knock-client.py $PI # A: 7000,8000,9000 -> opens 6500
$ python3 knock-client.py $PI 7100 7200 7300 # B: knockd -> opens 6501
Approach C sends the SPA packet with the fwknop client and the same keys as access.conf. The -a argument is the IP that should be allowed in — the source address the device will see your connection come from:
$ fwknop -A tcp/6502 -a <your-ip> -D $PI --use-hmac --no-rc-file --key-base64-rijndael apAjDrWMbZdaGMpRjawbfw== --key-base64-hmac XWlmWPHJu+9VQiEWH/NJ3nxfGJgUro1xoWSfRVQsAdo=
Two traps lurk here, and both cost me a confused minute. The first is the option names: the key flags are --key-base64-rijndael and --key-base64-hmac, not the --key-base64 you might guess; pass the wrong flag and fwknop silently prints its usage and sends nothing. The second is the clock. The SPA payload carries a timestamp and fwknopd rejects packets too far from its own clock to defeat replay. Many small boards and VMs have no battery-backed real-time clock — a Raspberry Pi is the classic example — so a freshly booted host that has not yet reached an NTP server can be far in the past, and every SPA is rejected as stale. The fix is just to let systemd-timesyncd settle, or set the clock; but if approach C is the only one failing, check date on the host first.
Scan again inside the fifteen-second window:
$ nmap -Pn -p 6500,6501,6502 $PI
PORT STATE SERVICE
6500/tcp open boks
6501/tcp open boks_servc
6502/tcp open netop-rc
All three open, and a plain nc -w2 $PI 6500 now returns You knocked. Welcome to the secret service on port 6500. Wait sixteen seconds and scan once more: all three are filtered again, each closed by its own timeout with no client involvement. The door was visible only to the host that knocked, and only for as long as the window.
Conclusion
That is it — three ports, three mechanisms, one host, and a scanner that sees nothing until the right signal arrives. With all three in front of you the trade-offs stop being abstract:
- The custom nftables daemon is the most transparent and the easiest to extend, and the timeout set means it never has to track its own open windows. It is also code you now own and must maintain.
- knockd is the least effort for a sequence knock: a well-trodden tool and a config file. It is still a cleartext sequence, replayable by anyone watching, and it drives iptables with shell commands you have to get right.
- fwknop is the only one of the three that resists a passive observer and a replay, because the knock is encrypted, authenticated and timestamped. You pay for that with key management and a daemon that is unforgiving about the clock.
None of them is a substitute for authentication on the service behind them. What they are is a cheap, structural way to keep that service off the radar of everything that is not already looking for you specifically. Pick the sequence daemons when the threat you care about is untargeted noise; reach for SPA when the knock itself has to survive someone watching the wire.
The accompanying knockdemo package contains the full daemon, the firewall helper, the demo service, the client and the unit files for anyone who wants to reproduce the setup.
More resources:
- The knock (jvinet) repository for knockd’s manual and config reference.
- The fwknop project and its documentation on SPA, key management and NAT traversal.
- The nftables wiki on sets with timeouts, which is what makes approach A so compact.


