Netfilter architecture¶
Every Linux firewall you are likely to meet, whether you configure it with nft, iptables, firewall-cmd or ufw, ends up in the same place: netfilter, the packet-filtering framework inside the Linux kernel.
The tools differ in how you describe the rules, but the kernel evaluates them at the same points in the network stack and with the same idea of a "connection".
This page explains that common machinery: where packets meet the firewall, how the kernel remembers connections, why nftables replaced iptables, and how the higher-level front ends relate to it. Understanding it explains several things that otherwise look surprising, such as why a rule that accepts "established" traffic comes first, or why two firewall tools on one host can block each other's traffic.
What netfilter is¶
Netfilter is a set of hooks in the kernel's networking code. A hook is a fixed point on a packet's path (for example "just after the packet arrived, before routing") where other kernel code can register a function to look at the packet. That function returns a verdict: let the packet continue, drop it, queue it to userspace, and so on.
Netfilter itself doesn't decide anything. The decisions come from subsystems that register on the hooks:
nf_tables, the kernel side of nftables, which runs the rules you load withnft;- the older
x_tablesmodules behind classiciptables,ip6tables,arptablesandebtables; - connection tracking (conntrack), which records the state of each flow;
- NAT, which rewrites addresses and ports, and relies on conntrack;
- other users such as IPVS load balancing and SELinux packet labelling.
The userspace commands only load configuration into these subsystems. Once the rules are loaded, the command can exit: filtering happens entirely in the kernel, per packet.
Hooks and packet paths¶
For IPv4 and IPv6 there are five hooks. Which ones a packet passes depends on whether it is addressed to this host, passing through it, or created by it.
flowchart LR
nic_in(["Packet arrives<br/>on an interface"]) --> pre["prerouting"]
pre --> route1{"Routing<br/>decision"}
route1 -- "for this host" --> input["input"]
input --> proc(["Local process"])
route1 -- "for another host" --> fwd["forward"]
fwd --> post["postrouting"]
proc -- "sends a packet" --> output["output"]
output --> route2{"Routing<br/>decision"}
route2 --> post
post --> nic_out(["Packet leaves<br/>on an interface"])
preroutingsees every incoming packet before the kernel has decided where it goes. Destination NAT happens here, because changing the destination address changes the routing decision that follows.inputsees packets the routing decision delivers to a local socket. This is where a host protects its own services, and where most server firewalls do their work.forwardsees packets that are only passing through, which matters on routers, VPN gateways and container or VM hosts. On a host that doesn't forward (thenet.ipv4.ip_forwardsysctl is 0), nothing reaches it.outputsees packets created by local processes, before they are routed.postroutingsees every outgoing packet, forwarded or local, just before it leaves. Source NAT and masquerading happen here, after the outgoing interface is known.
So the three typical paths are:
| Traffic | Hooks passed, in order |
|---|---|
| To a local service (for example an SSH login to this host) | prerouting → input |
| Through the host (routing, NAT gateway) | prerouting → forward → postrouting |
From a local process (for example apt downloading packages) |
output → postrouting |
Traffic between two processes on the same host also passes through these hooks, on the loopback interface lo: output and postrouting on the way out, prerouting and input on the way back in.
That is also true when a process connects to one of the host's own non-loopback addresses: the kernel delivers the packet internally through lo, so it never touches a physical interface.
This is why a firewall that accepts everything on lo can't be tested by connecting to the host from itself.
There are further hooks outside this picture.
The netdev family offers ingress and egress hooks attached to a single interface, which see packets even before prerouting (or after postrouting), which is useful for very early filtering of floods.
The bridge and arp families have their own hooks for layer-2 traffic and ARP.
Several chains on one hook¶
Many pieces of kernel code can register on the same hook, so each registration has a priority, a number where lower runs first. Some well-known values for IPv4 and IPv6:
| Priority | Name in nftables | Used for |
|---|---|---|
| -400 | Reassembling fragmented IP packets | |
| -300 | raw |
Rules that must run before connection tracking (for example notrack) |
| -200 | Connection tracking | |
| -150 | mangle |
Changing packet fields |
| -100 | dstnat |
Destination NAT (in prerouting) |
| 0 | filter |
Ordinary filtering |
| 50 | security |
SELinux and similar labelling |
| 100 | srcnat |
Source NAT (in postrouting) |
In nftables, every base chain you create (a chain with a hook) is registered as one more function on that hook, at the priority you give it.
Chains from different tables on the same hook are simply run one after another in priority order.
This has a consequence that surprises many people: a packet must survive every base chain on a hook.
An accept verdict ends evaluation of that chain only; the packet then continues to the next registered chain, which may still drop it.
A drop, on the other hand, is final.
If firewalld's chain accepts a packet but a hand-written nftables table on the same hook drops it, the packet is dropped.
Connection tracking¶
Early packet filters judged each packet on its own. To allow replies to outgoing connections, administrators had to open broad ranges of high ports or match TCP flags, which was both fragile and easy to abuse.
Netfilter's connection tracking subsystem removes that problem by remembering flows. When it sees the first packet of a new flow, it creates an entry in the conntrack table recording the addresses, ports and protocol in both directions (the "original" and the expected "reply" tuple). Every later packet is matched against the table, and the firewall can ask about the packet's relationship to what came before, through its state:
| State | Meaning |
|---|---|
new |
The first packet of a flow the table doesn't know yet |
established |
A packet of a flow that has already seen traffic in both directions, or a reply to a known flow |
related |
A new flow that belongs to an existing one, such as an ICMP "destination unreachable" error about a TCP connection, or an FTP data connection recognised by a helper |
invalid |
A packet that doesn't fit any valid state, such as a TCP segment for a connection that doesn't exist |
untracked |
A packet that a notrack rule excluded from tracking |
Connection tracking works for connectionless protocols too.
A UDP query and its reply form a flow, and so do an ICMP echo request and its echo reply, each with a timeout after which the entry is forgotten.
You can inspect the table with the conntrack tool or in /proc/net/nf_conntrack when that file is available.
A few details of the design matter in practice:
- Tracking happens before filtering. Conntrack runs at priority -200 in
preroutingandoutput, so by the time afilterchain at priority 0 sees a packet, its state is already known. - An entry is confirmed only if the packet survives. The new entry is committed to the table at the very end of the packet's path (in
inputorpostrouting). If a rule drops the first packet of a flow, nothing is remembered. - NAT is decided once per flow. The NAT chains see only the first packet of a connection; conntrack applies the same translation to every later packet in both directions. This is why NAT needs connection tracking and why NAT rules can't be per-packet.
- Helpers understand protocols that negotiate extra connections inside their payload (FTP is the classic example) and mark those extra connections as
related. Modern kernels no longer assign helpers automatically; a ruleset has to enable them explicitly, because automatic assignment proved to be a security risk. - The table has a size limit (
net.netfilter.nf_conntrack_max). On a very busy host, a full table makes the kernel drop new flows, which looks like a network fault rather than a firewall decision.
Stateful filtering as a design¶
Connection tracking makes possible the design almost every host firewall uses, and which the tutorial and how-to guide in this cluster follow:
- Accept
establishedandrelatedtraffic first. The vast majority of packets belong to flows that have already been allowed. Matching them with the first rule keeps the ruleset fast, and it means you only need to write rules for the first packet of each kind of connection. - Drop
invalidpackets. They can't be part of any legitimate conversation the host knows about. - Accept loopback traffic. Many local services talk to each other over
lo, and nothing outside the host can send packets that arrive on it. - Accept the ICMP and ICMPv6 messages the network needs. IPv6 in particular doesn't work without ICMPv6: neighbour discovery replaces ARP, and path MTU discovery depends on "packet too big" messages. Dropping all ICMP breaks things in ways that are hard to diagnose.
- Accept new connections only to the services you mean to offer, such as TCP port 22 for SSH.
- Drop everything else, usually through the chain's policy (the verdict applied when no rule matched) rather than a final rule.
The order matters for safety as well as speed. When you build such a ruleset over SSH, the rules that keep your own session working (established traffic, and port 22) must be in place before the default becomes drop. Your existing SSH session is already established, so the first rule keeps it alive even while you work on the rest.
The asymmetry between directions is also deliberate.
Host firewalls usually filter input strictly and leave output open, because the host's own processes are trusted more than the network, and replies to outgoing connections come back in as established.
Filtering output, too, is possible and sometimes required, but every outgoing service (DNS, NTP, package mirrors) then needs its own rule.
Whether to drop or reject is a related choice.
drop discards a packet silently, so the sender waits for a timeout; reject answers with an ICMP error or a TCP reset, so the sender fails immediately.
Drop reveals slightly less to a scanner and costs nothing for flooded traffic; reject is friendlier to legitimate clients and to whoever is debugging the network.
Both are common, and neither changes what is allowed.
From iptables to nftables¶
Linux has had four generations of packet filter, each replacing the last:
| Tool | Kernel | Notes |
|---|---|---|
ipfwadm |
2.0 | Ported from BSD's ipfw |
ipchains |
2.2 | Chains of rules, still stateless |
iptables (x_tables) |
2.4 (2001) | The netfilter framework, connection tracking and NAT |
nftables (nf_tables) |
3.13 (2014) | A new rule engine on the same netfilter hooks |
iptables served for well over a decade, and its design shows the time it was built for. nftables was written to fix limitations that had become structural:
- One tool per protocol. IPv4, IPv6, ARP and bridge filtering used four separate commands (
iptables,ip6tables,arptables,ebtables) with separate kernel code. A dual-stack host needed two parallel rulesets kept in step by hand. nftables has one command and theinetfamily, where a single table filters both IPv4 and IPv6. - Matches compiled into the kernel. In iptables, every kind of match (
-m state,-m multiport,-m recent, …) was its own kernel module with its own code for parsing and checking. Innf_tables, the kernel runs a small virtual machine with generic instructions (load a field, compare, look up in a set), and thenftcommand compiles your rules into those instructions. New features usually need only userspace changes. - Fixed tables and chains. iptables had built-in tables (
filter,nat,mangle,raw,security) with built-in chains, registered whether you used them or not. nftables has no predefined tables or chains: you create the ones you need, name them as you like, and choose their hooks and priorities. Unused hooks cost nothing. - Slow, whole-table updates. iptables replaced an entire table as one binary blob for every change, which became slow with tens of thousands of rules and racy when several programs changed rules at once. nftables talks to the kernel over netlink, changes rules incrementally, and applies a whole file as one atomic transaction: either all of it loads, or none of it does.
- No native sets. Matching many addresses or ports in iptables meant many rules, or the separate
ipsettool. nftables has sets, maps and verdict maps built in, so a single rule can match thousands of addresses with a hash lookup, or jump to a different chain depending on the port.
Moving every script and tool at once was never realistic, so the migration happened in layers.
The iptables-nft variant (part of the iptables package since 1.8) accepts the familiar iptables syntax but loads the rules into nf_tables instead of x_tables.
Debian 10, RHEL 8 and their successors made it the default, so on the distributions this knowledge base covers, an iptables command usually creates nf_tables rules that nft list ruleset also shows.
The older backend remains available as iptables-legacy; mixing the two on one host works, but makes the full ruleset hard to see from either tool.
Where firewalld and ufw fit¶
nftables is a rule language: it gives you full control, and expects you to write and save a complete ruleset yourself. Many administrators prefer a tool that manages the ruleset for them and offers higher-level concepts. The two most widespread are firewalld and ufw, and both are front ends to netfilter rather than firewalls of their own.
flowchart TB
subgraph user["Userspace"]
direction LR
fwcmd["firewall-cmd /<br/>firewall-config"] --> fwd["firewalld daemon<br/>(D-Bus)"]
ufwcmd["ufw<br/>(Python)"] --> ipt["iptables-restore<br/>(iptables-nft)"]
nftcmd["nft"]
fwd --> nl["netlink"]
ipt --> nl
nftcmd --> nl
end
subgraph kernel["Kernel"]
direction LR
nl --> nft["nf_tables"]
nft --> hooks["netfilter hooks<br/>+ connection tracking"]
end
firewalld is the default on RHEL and its rebuilds, and on Fedora and SUSE.
It runs as a daemon with a D-Bus interface, which firewall-cmd, graphical tools, NetworkManager and libvirt use to change rules while the system runs.
Its main idea is the zone: a trust level (such as public, internal or trusted) assigned to interfaces or source addresses, each with its own list of allowed services (named port and protocol bundles such as ssh or http).
firewalld also separates the runtime configuration (what is loaded now) from the permanent configuration (XML files under /etc/firewalld/, loaded at start and on reload).
That split is a safety feature: you can try a change at runtime and lose it with a reload, or prepare a permanent change and apply it deliberately.
Since version 0.6 firewalld uses nftables as its backend by default. Current versions, such as firewalld 2.4 on RHEL 10, keep all of their rules in a single table of their own, inet firewalld; some earlier releases also created separate ip and ip6 tables.
ufw ("uncomplicated firewall") is Ubuntu's front end, installed by default but inactive until you enable it.
It aims at the common single-host case: a default policy plus short commands such as ufw allow 22/tcp.
ufw is written in Python and generates rules in iptables syntax, which it loads with iptables-restore; on current Ubuntu that is the nft-based variant, so ufw's rules also end up in nf_tables, as tables named ip filter and ip6 filter with many ufw- chains.
Its configuration lives in /etc/ufw/, and its own rule list is kept in /etc/ufw/user.rules and user6.rules.
Both front ends add the same stateful base rules described above, so a fresh ufw enable or a default firewalld zone already accepts established traffic and refuses unsolicited new connections (ufw drops them by default; firewalld's public zone rejects them with an ICMP "administratively prohibited" error), apart from what the zone or rule list allows.
Because all these tools load rules into the same kernel, they can coexist, but they don't know about each other.
A hand-written nftables table can't see firewalld's zones, ufw can't see firewalld's services, and as explained above, a drop in any of their base chains wins.
Running two managers on one host is the most common cause of "the port is open in the firewall, but connections still fail".
The usual advice is to choose one tool per host and to inspect the result with nft list ruleset, which shows what the kernel will actually do whichever tool wrote it.
Alternatives and neighbours¶
- eBPF and XDP let programs attach to the network stack even earlier than netfilter, at the network driver. They are used for very high packet rates (DDoS mitigation, load balancers) and by container networking projects such as Cilium. They are programming interfaces rather than firewall configuration languages, and most hosts still use netfilter for their policy.
- Cloud and hypervisor firewalls (security groups, virtual switch ACLs) filter traffic before it reaches the host at all. They complement a host firewall rather than replacing it.
- The BSD packet filters (pf, ipfw) are separate implementations on other operating systems; their concepts are similar, but none of this machinery is shared.
Further reading¶
- The netfilter project and the nftables wiki, including its pages on netfilter hooks and on moving from iptables to nftables
- The
nft(8)andconntrack(8)man pages - firewalld documentation, including zones and the nftables backend
- The
ufw(8)man page and Ubuntu's ufw documentation - Conference talks on nftables by its developers, at the Netdev conference and the Netfilter Workshop
Related¶
- Tutorial: Build your first nftables firewall
- How-to: How to open a port in the firewall
- Reference: nftables syntax