CoursesLinux hardeningA default-deny firewall (nftables)

A default-deny firewall (nftables)

A host firewall you can actually read.

Intermediate14 min · lesson 6 of 16

A hardened host treats its network connection like the front door of a building with one guard and a very short guest list. The guard's default answer is no. If your name is on the list, you are waved through. If it is not, you do not get an argument or an explanation, you get nothing, and from the sidewalk you cannot even tell whether anyone is inside. That is a default-deny firewall: block every incoming packet, then allow back only the specific traffic this machine actually needs.

On a modern Linux box that guard is nftables (netfilter tables, the kernel's built-in packet filter and the replacement for the older iptables command). The kernel (the core part of the operating system that talks directly to the hardware and the network card) checks every arriving packet against a list of rules you write. The reason a defender likes nftables is plain: the whole firewall lives in one file you can read top to bottom in under a minute. No rules scattered across a dozen chains, no guessing what is actually enforced.

The Whole Firewall In One File

Three words carry the structure. A table is the container for a set of rules. A chain is an ordered list of rules inside that table. A hook is the checkpoint where the kernel runs a packet past a chain. There are three hooks you care about: input sees packets addressed to this machine, forward sees packets merely passing through it on the way somewhere else (a plain server never routes, so you drop those), and output sees packets this machine sends out. The word inet in the table name means one ruleset covers both IPv4 and IPv6 (the two versions of the Internet Protocol that carry every packet), so you do not keep two copies that drift apart.

/etc/nftables.conf
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
# the machine talking to itself (127.0.0.1)
iif "lo" accept
# replies to connections we opened stay open
ct state established,related accept
# stray or malformed packets: throw them out
ct state invalid drop
# SSH, only from the management network
tcp dport 22 ip saddr 10.0.0.0/24 accept
# the public web service
tcp dport { 80, 443 } accept
# ping and path-MTU discovery, v4 and v6
ip protocol icmp accept
ip6 nexthdr ipv6-icmp accept
# log a rate-limited sample of what we drop
limit rate 5/minute burst 5 packets log prefix "nft-drop: "
}
chain forward { type filter hook forward priority 0; policy drop; }
chain output { type filter hook output priority 0; policy accept; }
}

Read that chain the way the kernel does: from the top, one rule at a time, and the first rule that reaches a verdict (accept or drop) wins. Nothing after it runs for that packet. So order the cheap, common allows first. The line that does the real work is policy drop. It is the guard's standing instruction: if we reach the bottom and no rule said yes, the answer is no. priority 0 sets where this chain sits relative to other netfilter machinery; the default filtering slot is named filter, which is why the live output prints priority filter later.

The two ct lines are connection tracking (the kernel remembering the state of each conversation). Think of the guard writing down everyone he lets out so he recognizes their reply when it comes back. ct state established,related accept means: if this packet belongs to a connection we already opened, let it in without a second thought. That single rule is why you do not open thousands of high-numbered ports for your own outbound traffic. ct state invalid drop tosses packets connection tracking can't place in any conversation, like a lone ACK answering a handshake that never happened. Some port scans fire off exactly these to slip past filters that only watch for brand-new connections. iif "lo" accept keeps the loopback interface open, because databases, caches, and metrics agents often bind to 127.0.0.1 and talk to each other over it; block loopback and those break in confusing ways.

The SSH (Secure Shell, encrypted remote login) rule is deliberately narrow: port 22 is accepted only when the source address is inside 10.0.0.0/24, your management range. Everyone else hits policy drop. The web ports 80 and 443 are open to the world because that is the job. The ICMP (Internet Control Message Protocol, the network's status-and-error messages) rules keep ping working and, more importantly, keep Path MTU Discovery working (MTU is Maximum Transmission Unit, the largest packet a link will carry); block all ICMP and you get connections that hang instead of failing cleanly.

How A Packet Moves Through The Input Chain

Every inbound packet takes the same walk down the chain. Here is the exact path for the ruleset above, in order.

One inbound packet through the input chain
1Packet arrives at the input hook
this host is the destination
2From loopback? accept
iif lo, the machine talking to itself
3Reply to a connection we opened? accept
ct state established,related
4Stray or malformed? drop
ct state invalid
5Hits an allowed port? accept
tcp dport 22 (scoped) / 80 / 443
6Nothing matched: policy drop
silent, the sender gets no reply

Check It, Load It, And Arm An Escape Hatch

Never point a live ruleset at a production host and hope. The -c flag parses the file and reports errors without touching the running firewall, so you catch a typo before it costs you. Here it catches dprot, a misspelled dport.

~/secopslog — bash
$ sudo nft -c -f /etc/nftables.conf
/etc/nftables.conf:19:13-17: Error: syntax error, unexpected string, expecting sport or dport or sequence or ackseq or doff or reserved or flags or window or checksum or urgptr or option tcp dprot 22 ip saddr 10.0.0.0/24 accept ^^^^^

Fix the line, re-run the check until it prints nothing (silence means success), then enable the service so the ruleset loads now and again on every boot.

~/secopslog — bash
$ sudo nft -c -f /etc/nftables.conf sudo systemctl enable --now nftables
Created symlink /etc/systemd/system/sysinit.target.wants/nftables.service → /lib/systemd/system/nftables.service.

Now read back what is actually enforced. list ruleset prints the live firewall, normalized. Notice priority 0 came back as priority filter, the redundant burst 5 packets (the default) dropped off the log rule, and the whole thing still fits on one screen.

~/secopslog — bash
$ sudo nft list ruleset
table inet filter { chain input { type filter hook input priority filter; policy drop; iif "lo" accept ct state established,related accept ct state invalid drop tcp dport 22 ip saddr 10.0.0.0/24 accept tcp dport { 80, 443 } accept ip protocol icmp accept ip6 nexthdr ipv6-icmp accept limit rate 5/minute log prefix "nft-drop: " } chain forward { type filter hook forward priority filter; policy drop; } chain output { type filter hook output priority filter; policy accept; } }
Do not lock yourself out
Applying policy drop over a live SSH session cuts you off the instant it loads unless the rule that accepts your own connection is already in the file and your source address really is in that range. The established rule keeps your current shell alive, because connection tracking survives the reload, but a fresh session may be refused. Always test with nft -c first, and before any risky remote change arm a self-healing rollback so a mistake un-does itself instead of stranding you.

The escape hatch is a one-shot job scheduled with at (the run-this-later scheduler). It flushes the ruleset in five minutes, which removes every table and returns the host to its wide-open default. That is not the state you want to live in, but it hands you back your SSH so you can reload the corrected file. If everything still works, cancel the job before it fires.

~/secopslog — bash
$ echo 'flush ruleset' | sudo at now + 5 minutes sudo atrm 3 # run this once you confirm you are still connected
warning: commands will be executed using /bin/sh job 3 at Fri Jul 17 09:20:00 2026

Open Each Port To The Smallest Possible Audience

Opening a port and opening it to the right people are different jobs. The SSH rule already shows the pattern: match on ip saddr so port 22 answers the management network and nobody else. Do the same for any internal service. A metrics port that only your monitoring server scrapes should read tcp dport 9100 ip saddr 10.0.0.20 accept, not a blanket allow. The firewall is a second lock behind the first, because a service binding to the whole machine is a common accident. See it for yourself with ss (socket statistics), which lists every listening TCP (Transmission Control Protocol, the connection-based way most services talk) socket and the process holding it open.

~/secopslog — bash
$ sudo ss -tlnp
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=611,fd=14)) LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=812,fd=3)) LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=933,fd=6)) LISTEN 0 4096 0.0.0.0:9100 0.0.0.0:* users:(("node_exporter",pid=1287,fd=3))

Look at the last line. node_exporter (the Prometheus metrics agent, where Prometheus is a monitoring system) is listening on 0.0.0.0:9100, meaning every network interface, offering your host's memory, disk, and process stats to anyone who can reach the port. You never added a rule for 9100. So the socket is wide open at the application level and completely unreachable from outside, because the input chain sends anything bound for 9100 straight to policy drop. That gap between what is listening and what is reachable is exactly the safety margin a default-deny firewall buys you: a service you forgot, a debug port someone left on, a payload an attacker binds after a break-in, all inert until you deliberately open them.

Watch What The Firewall Turns Away

A silent drop is invisible to the attacker, but it does not have to be invisible to you. The log line at the bottom of the chain records a rate-limited sample of dropped packets to the kernel log, tagged with the nft-drop prefix so you can find it. Rate limiting matters: without it, a fast scan would flood your disk and turn your own logging into a denial of service. Read the drops with journalctl, filtering by that prefix.

~/secopslog — bash
$ sudo journalctl -k -g 'nft-drop' -n 2 --no-pager
Jul 17 09:14:22 web01 kernel: nft-drop: IN=eth0 OUT= MAC=fa:16:3e:1a:2b:3c:fa:16:3e:99:88:77:08:00 SRC=203.0.113.7 DST=10.0.0.5 LEN=60 TOS=0x00 PREC=0x00 TTL=52 ID=54321 DF PROTO=TCP SPT=51544 DPT=9100 WINDOW=64240 RES=0x00 SYN URGP=0 Jul 17 09:14:22 web01 kernel: nft-drop: IN=eth0 OUT= MAC=fa:16:3e:1a:2b:3c:fa:16:3e:99:88:77:08:00 SRC=203.0.113.7 DST=10.0.0.5 LEN=60 TOS=0x00 PREC=0x00 TTL=52 ID=54322 DF PROTO=TCP SPT=51545 DPT=6379 WINDOW=64240 RES=0x00 SYN URGP=0

Read those two lines like a defender. One source address, 203.0.113.7, is sending lone SYN packets (SYN is the flag that opens a TCP handshake, so a bare SYN with no follow-up is a knock on the door) to port 9100 and then 6379 within the same second. That is a port scan probing for a metrics endpoint and then for Redis (an in-memory database that is notorious for shipping with no password). Your firewall answered both with silence, and the log tells you who tried and what they wanted. On a busy host you feed these into your log pipeline and alert on a single source hitting many closed ports, which is one of the cleanest early signals that someone is mapping you.

Quick check
01Your input policy is drop. The host runs 'apt update', which opens an outbound HTTPS (encrypted web traffic) connection to a package mirror. The mirror's reply packets come back to a random high-numbered local port. With a default-deny input chain, why do those replies still get in?
Incorrect — Output accept only lets the request leave. It says nothing about letting the reply back in through the input hook.
Correct — Connection tracking remembers the outbound flow, so the return traffic matches this rule and is accepted without opening any high port.
Incorrect — The dport 443 rule allows inbound connections to your server on 443. The mirror's reply arrives at a random high ephemeral port, not 443.
Incorrect — ICMP is control and error messaging. The HTTPS reply is TCP data and has nothing to do with the ICMP rule.
02The ruleset explicitly accepts ICMP (and ICMPv6) instead of letting policy drop swallow it. Beyond keeping ping alive, why does the lesson insist on this?
Incorrect — SSH runs over TCP; ICMP does not carry the login handshake.
Incorrect — ct state tracks TCP and UDP flows on their own; it does not need ICMP to be accepted.
Incorrect — logging is done by the log rule writing to the kernel log, not by ICMP.
Correct — PMTUD relies on ICMP 'fragmentation needed' messages; drop them and you get mysterious hangs rather than clean errors.
03'ss -tlnp' shows node_exporter listening on 0.0.0.0:9100 (all interfaces), and your nftables ruleset has no rule for port 9100. Is that metrics endpoint reachable from the internet?
Incorrect — the socket listens broadly, but the firewall decides reachability, and nothing opened 9100.
Correct — the gap between what is listening and what is reachable is exactly the margin a default-deny firewall gives you.
Incorrect — the source-scoped rule applies only to port 22; it grants nothing to 9100.
Incorrect — 0.0.0.0 is all interfaces, not loopback; it is policy drop, not the bind address, that protects it.

When you next touch this file, keep the audit trail honest: edit /etc/nftables.conf, run sudo nft -c -f /etc/nftables.conf, reload with sudo systemctl reload nftables, and diff the output of nft list ruleset against what you expected. The file is the firewall, so a reviewer reading those dozen lines in a pull request can see exactly what your host will and will not answer, which is the whole point of choosing a firewall you can actually read.

Try this

Work through “Watch What The Firewall Turns Away” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: do not lock yourself out. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related