Stateful firewall rules don’t track individual packets, but rather the established connections between two endpoints.
Let’s see it in action. Imagine a simple web server running on your machine. You want to allow incoming HTTP traffic on port 80, but only if it’s part of an established connection.
# First, create a table and a chain for incoming traffic
nft add table ip filter
nft add chain ip filter input { type filter hook input priority 0 \; }
# Now, add the stateful rule to allow established connections
nft add rule ip filter input ct state established,related accept
# And a rule to drop everything else that didn't match above
nft add rule ip filter input drop
What’s happening here? The ct state established,related accept rule is the magic. ct stands for connection tracking. When a packet arrives, nftables checks its state. If the packet is part of an established connection (meaning the connection has already been initiated and acknowledged) or related to an established connection (like an FTP data channel related to an FTP control connection), it’s allowed to pass. The accept action permits the packet. The subsequent drop rule acts as a default deny, ensuring that any traffic not explicitly allowed by the stateful rule is discarded. This is crucial because it means you don’t need to explicitly allow outgoing traffic for every service your server might initiate. The firewall remembers the outgoing connections and allows the replies.
The core of stateful inspection lies in the connection tracking module (nf_conntrack in the kernel). When a packet initiates a new connection (e.g., your server making an outbound request), nftables marks it as new. The kernel then allocates a tracking state for this connection. When subsequent packets arrive that belong to this already-tracked connection, they are marked as established. The related state is for protocols that might open secondary connections, like FTP.
The primary benefit is security through simplicity. Instead of defining rules for both incoming and outgoing traffic for every possible service, you define rules for initiating connections and then let the stateful engine handle the replies. For instance, to allow your server to initiate outbound SSH connections, you’d typically only need a rule to permit new or untracked outgoing traffic on the output chain, and the replies to those connections will be automatically allowed by the established,related rule on the input chain.
Here’s a more practical example for a typical server:
# Allow loopback traffic (essential for local services)
nft add rule ip filter input iif lo accept
nft add rule ip filter output oif lo accept
# Allow established and related incoming connections
nft add rule ip filter input ct state established,related accept
# Allow new incoming SSH connections (port 22)
nft add rule ip filter input tcp dport 22 ct state new accept
# Allow new incoming HTTP connections (port 80)
nft add rule ip filter input tcp dport 80 ct state new accept
# Allow new incoming HTTPS connections (port 443)
nft add rule ip filter input tcp dport 443 ct state new accept
# Drop everything else on the input chain
nft add rule ip filter input drop
# For outgoing traffic, allow established and related, and new connections
nft add rule ip filter output ct state established,related accept
nft add rule ip filter output ct state new accept # This allows outbound connections
# Drop any other outgoing traffic (less common for servers but good practice)
nft add rule ip filter output drop
In this scenario, the ct state new accept rule on the output chain is what allows your server to initiate connections to the outside world. Once that connection is established, the ct state established,related accept rule on the input chain will permit the replies.
The timeout values for connection tracking are also configurable and can significantly impact how long idle connections are remembered. For example, an idle TCP connection might time out after 30 seconds by default, while an idle UDP connection might last much longer. You can inspect and adjust these timeouts within the nftables configuration, or by looking at /proc/sys/net/netfilter/.
A common pitfall is forgetting to allow new connections on the output chain if your server needs to initiate outbound connections. Without this, the replies to those initiated connections will never be allowed back in, even though the established,related rule is present on the input chain.
The nftables connection tracking module can also identify and track UDP "connections" which are not true connections in the TCP sense. It infers a "connection" based on the source and destination IP and port, and if a reply packet comes back within a certain timeout period, it’s considered related. This is why UDP can sometimes appear stateful in nftables even though the protocol itself is connectionless.
The next step in understanding nftables is exploring more advanced filtering based on packet contents or network address translation (NAT).