Packet filtering is the foundation everything else in Linux network security sits on top of. Before I ever get to fail2ban, VPNs, or intrusion detection, I want the kernel itself deciding — packet by packet, based on explicit rules — what’s allowed in, out, and through a box. This article walks through what packet filtering actually is at the kernel level, how to verify it’s available and active on your system, and how to configure it properly using the tools built into modern Linux distributions.
What Packet Filtering Actually Is
Packet filtering is the process of inspecting network packets against a set of rules and deciding to accept, drop, or reject them, based on criteria like source/destination IP, port, protocol, connection state, or interface. In Linux, this happens inside the kernel’s netfilter framework, which hooks into specific points of the networking stack: as packets arrive (PREROUTING), as they’re destined for the local machine (INPUT), as they’re routed through the machine (FORWARD), as they’re generated locally (OUTPUT), and as they leave (POSTROUTING).
This is fundamentally different from application-level filtering (like a web application firewall) — packet filtering happens before any application ever sees the traffic, at the kernel/network layer, which makes it both extremely efficient and a first line of defense against a huge range of attacks.
Confirming Netfilter Support Is Built Into Your Kernel
Nearly every modern distro kernel ships with netfilter support compiled in, either built-in or as loadable modules. Verify it:
zcat /proc/config.gz 2>/dev/null | grep -i netfilter | head -20
# or, if config.gz isn't exposed:
cat /boot/config-$(uname -r) | grep -i netfilter | head -20
Look for lines like:
CONFIG_NETFILTER=y
CONFIG_NF_CONNTRACK=y
CONFIG_NETFILTER_XTABLES=y
CONFIG_IP_NF_FILTER=y
CONFIG_IP_NF_TARGET_REJECT=y
CONFIG_IP_NF_NAT=y
Check that the relevant modules are loaded:
lsmod | grep -E 'nf_tables|ip_tables|nf_conntrack|nf_nat'
If any needed module isn’t loaded (rare on a normal distro kernel), load it manually:
sudo modprobe ip_tables
sudo modprobe nf_conntrack
sudo modprobe iptable_filter
Method 1: Enabling Packet Filtering with iptables Directly
The most direct route is configuring the filter table’s chains yourself.
# See current state
sudo iptables -L -n -v
# Set default-deny policy on INPUT and FORWARD
sudo iptables -P INPUT DROP
sudo iptables -P FORWARD DROP
sudo iptables -P OUTPUT ACCEPT
# Allow loopback (always required — many services rely on it internally)
sudo iptables -A INPUT -i lo -j ACCEPT
# Allow established/related connections so return traffic isn't blocked
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# Explicitly allow the services you actually need
sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT
I ran this exact sequence on a live Ubuntu 24.04 system and confirmed the policy, loopback, conntrack, and per-port rules all applied correctly and appeared in the right order under iptables -L INPUT -n -v --line-numbers.
Method 2: Enabling Packet Filtering via firewalld (RHEL, Fedora, CentOS Stream)
RHEL-family distros default to firewalld, a dynamic, zone-based front end that manages nftables (and historically iptables) rules underneath.
sudo systemctl enable --now firewalld
sudo firewall-cmd --state
Firewalld organizes rules around zones — trust levels applied to network interfaces. Common built-in zones: public, internal, dmz, trusted, drop, block.
firewall-cmd --get-zones
firewall-cmd --get-default-zone
firewall-cmd --get-active-zones
Enable a service in a zone:
sudo firewall-cmd --zone=public --add-service=ssh --permanent
sudo firewall-cmd --zone=public --add-service=http --permanent
sudo firewall-cmd --zone=public --add-port=8443/tcp --permanent
sudo firewall-cmd --reload
--permanent writes the rule to firewalld’s persistent config; without it, the change is runtime-only and disappears on reload/reboot. I always run one test rule non-permanent first to confirm it works, then repeat it with --permanent once I’ve validated behavior.
List what’s active:
sudo firewall-cmd --zone=public --list-all
Method 3: Enabling Packet Filtering via UFW (Ubuntu and Debian-derived Distros)
sudo apt install ufw
sudo ufw enable
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status verbose
Under the hood, UFW is generating iptables (or nftables, depending on version) rules and managing them for you — it’s the simplicity layer, not a separate filtering engine.
Enabling IP Forwarding (Required for Routing/Filtering Between Networks)
If this machine is meant to filter traffic passing through it (acting as a router or gateway), you also need to enable IP forwarding at the kernel level — packet filtering rules on FORWARD do nothing if forwarding itself is disabled.
Check current state:
sysctl net.ipv4.ip_forward
# net.ipv4.ip_forward = 0
Enable temporarily:
sudo sysctl -w net.ipv4.ip_forward=1
Enable persistently:
echo "net.ipv4.ip_forward = 1" | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
For IPv6 forwarding:
echo "net.ipv6.conf.all.forwarding = 1" | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
A Closer Look at Netfilter Hook Ordering
It helps to actually see how a packet’s path through the kernel maps onto the hooks, since this explains why some rules belong in PREROUTING and others in INPUT, and why getting that placement wrong is such a common source of confusion.
For a packet destined for the local machine:
wire -> PREROUTING -> routing decision -> INPUT -> local process
For a packet being forwarded through the machine to another host:
wire -> PREROUTING -> routing decision -> FORWARD -> POSTROUTING -> wire
For a packet generated locally:
local process -> OUTPUT -> routing decision -> POSTROUTING -> wire
Notice PREROUTING runs before the kernel has even decided whether a packet is for the local machine or needs forwarding — this is why DNAT rules (which can change the destination and therefore change that very routing decision) live in PREROUTING, not INPUT. By the time a packet reaches INPUT, the routing decision has already been made, so rewriting its destination there wouldn’t achieve the same effect.
Building a Realistic Layered Filtering Policy
A single flat list of ACCEPT/DROP rules works for a simple server, but for anything with distinct traffic classes (public web traffic, internal admin access, monitoring, database replication) I find it’s worth organizing rules by purpose using custom chains, which keeps the ruleset readable as it grows.
# Create purpose-specific custom chains
sudo iptables -N PUBLIC_SERVICES
sudo iptables -N INTERNAL_SERVICES
sudo iptables -N RATE_LIMITED
# Route relevant traffic into each chain from INPUT
sudo iptables -A INPUT -i lo -j ACCEPT
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 22 -j RATE_LIMITED
sudo iptables -A INPUT -p tcp -m multiport --dports 80,443 -j PUBLIC_SERVICES
sudo iptables -A INPUT -s 10.0.0.0/8 -j INTERNAL_SERVICES
# Define the custom chains
sudo iptables -A PUBLIC_SERVICES -j ACCEPT
sudo iptables -A INTERNAL_SERVICES -p tcp --dport 9100 -j ACCEPT
sudo iptables -A INTERNAL_SERVICES -j DROP
sudo iptables -A RATE_LIMITED -m limit --limit 5/minute --limit-burst 10 -j ACCEPT
sudo iptables -A RATE_LIMITED -j DROP
# Catch-all
sudo iptables -A INPUT -j LOG --log-prefix "FILTER-DROP: "
sudo iptables -A INPUT -j DROP
This structure means when you’re troubleshooting six months later, you can look at INPUT and immediately see the traffic classes involved, then drill into the relevant custom chain rather than scanning a single fifty-line list top to bottom.
Egress Filtering: The Often-Skipped Half
Nearly everything in most firewall guides focuses on inbound (INPUT) filtering, and outbound (OUTPUT) is left wide open by default. That’s a reasonable default for most workloads, but on anything handling sensitive data — a server that shouldn’t be exfiltrating data to arbitrary destinations if compromised — egress filtering is worth the extra setup effort.
sudo iptables -P OUTPUT DROP
sudo iptables -A OUTPUT -o lo -j ACCEPT
sudo iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# Allow DNS
sudo iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
sudo iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
# Allow NTP
sudo iptables -A OUTPUT -p udp --dport 123 -j ACCEPT
# Allow outbound HTTPS (package updates, external APIs)
sudo iptables -A OUTPUT -p tcp --dport 443 -j ACCEPT
# Allow outbound to a specific known API endpoint only
sudo iptables -A OUTPUT -p tcp -d 203.0.113.50 --dport 443 -j ACCEPT
sudo iptables -A OUTPUT -j LOG --log-prefix "EGRESS-DROP: "
sudo iptables -A OUTPUT -j DROP
This is meaningfully more effort to maintain than a default-allow OUTPUT policy — every legitimate outbound need has to be explicitly enumerated, and forgetting one breaks something in a way that’s often only discovered when that specific code path runs. I’d reserve strict egress filtering for genuinely sensitive workloads rather than applying it everywhere by default.
Packet Filtering and Container/Virtualization Environments
Filtering gets a layer more complex on hosts running Docker or other container runtimes, because Docker manipulates iptables rules directly to implement its own networking model — inserting rules into the DOCKER chain and often into FORWARD in ways that can interact unexpectedly with a hand-built firewall policy.
iptables -L DOCKER -n -v
iptables -L DOCKER-USER -n -v
Docker specifically provides the DOCKER-USER chain as a stable place for administrators to insert their own filtering rules without Docker overwriting them on restart — rules placed directly in FORWARD or the DOCKER chain itself can be reset when the Docker daemon restarts or containers are recreated.
sudo iptables -I DOCKER-USER -s 203.0.113.0/24 -j ACCEPT
sudo iptables -I DOCKER-USER -j DROP
If you’re running containers and your carefully built filtering rules seem to be getting silently bypassed or reset, checking whether Docker (or Podman, which has its own analogous behavior) is managing conflicting rules is usually the first thing to check.
Verifying Packet Filtering Is Actually Working
Don’t just trust that rules are loaded — test them.
From another host, attempt connections to ports you’ve blocked and ports you’ve allowed:
nc -zv target_host 22 # should connect if allowed
nc -zv target_host 23 # should time out or refuse if blocked
Watch the counters increase on the filtering host as traffic hits your rules:
watch -n1 'iptables -L -n -v'
Check kernel logs if you’ve added LOG rules:
sudo dmesg | grep IPT-DROPPED
sudo journalctl -k | grep IPT-DROPPED
Persisting Rules Across Reboots
This trips people up constantly — rules configured directly with iptables commands live only in kernel memory and vanish on reboot.
Debian/Ubuntu:
sudo apt install iptables-persistent
sudo netfilter-persistent save
RHEL/Fedora (if using firewalld, this is automatic — --permanent rules persist by design):
sudo firewall-cmd --runtime-to-permanent
UFW persists automatically once enabled via ufw enable, since it manages its own rule files under /etc/ufw/.
Internals: Connection Tracking and Stateful Filtering
Modern packet filtering isn’t just stateless rule matching — it’s stateful, powered by the nf_conntrack kernel module, which tracks the state of every connection (NEW, ESTABLISHED, RELATED, INVALID).
sudo cat /proc/net/nf_conntrack | head -5
sudo conntrack -L | head -10
This is what lets a rule like -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT correctly allow return traffic for outbound connections without needing a separate explicit rule for every possible response port.
Troubleshooting
Filtering rules exist but nothing is being blocked — check that the rules are actually in the chain being hit; a common mistake is adding rules to OUTPUT on a server when the traffic in question is inbound and should be on INPUT.
Locked out of SSH after enabling filtering — always allow your management access (SSH, or whatever you’re connected over) before setting a default-deny policy, and test changes with a scheduled rollback:
(sleep 90 && iptables -P INPUT ACCEPT && iptables -F) &
firewalld and iptables both active, conflicting behavior — pick one:
sudo systemctl disable --now firewalld # if you want raw iptables/nftables management
# or
sudo systemctl stop iptables 2>/dev/null # if you want firewalld to be authoritative
Forwarding rules not working — confirm net.ipv4.ip_forward is actually 1; filtering rules on FORWARD are irrelevant if the kernel isn’t forwarding packets in the first place.
Security Best Practices
- Default-deny on
INPUTandFORWARD, default-allow onOUTPUT(unless you’re doing strict egress filtering too, which is worth considering on sensitive systems). - Always allow loopback traffic — many local services depend on it.
- Use conntrack state matching rather than writing separate rules per return path.
- Log dropped packets, at least temporarily, so you can catch legitimate traffic you forgot to allow.
- Don’t run two firewall management layers (firewalld + raw iptables, or ufw + firewalld) on the same box — the resulting rule interactions are hard to reason about and easy to get wrong.
Summary
Packet filtering in Linux is built on the kernel’s netfilter framework, configured either directly through iptables/nftables or through a friendlier front end like firewalld or ufw. The core pattern is consistent regardless of tool: default-deny, allow loopback, allow established/related connections via conntrack, then explicitly permit only what you actually need — and always persist your rules so a reboot doesn’t quietly disable your firewall.