Most Linux security advice starts with “install a firewall” and stops there, as if flipping a switch were the hard part. It isn’t. The hard part is understanding what’s actually happening when a packet hits your network interface, because that’s the difference between a firewall that protects you and one that just makes you feel protected while quietly leaking traffic out the back.
iptables is where most people learn this. It’s blunt, it’s old, and it will absolutely let you shoot yourself in the foot if you don’t understand rule order. It’s also still the tool you’ll see in most OSCP labs, most HTB boxes, and most legacy production servers you’ll ever touch professionally. So even in 2026, with nftables sitting in the kernel as the officially preferred successor, iptables knowledge isn’t optional. It’s the thing every other Linux firewall tool gets built on top of or compared against, which makes it worth understanding properly even if you never type an iptables command in production again.
What a Firewall Does
A firewall is a subsystem that decides whether a packet gets to pass, based on rules you define. That’s it. No AI, no magic, just pattern matching against source addresses, destination addresses, ports, protocols, and connection states, followed by a verdict.
Picture a packet arriving at your network interface carrying a source IP, a destination port, and a protocol. The firewall checks that packet against its rule list, in order, and the first rule it matches decides the packet’s fate. Everything else in this guide is really just variations on that one idea.
There are two broad flavors. Hardware firewalls sit at the network edge and protect everything behind them, think of the appliance at your office’s internet handoff. Software firewalls run on the host itself and protect that one machine. iptables is the second kind. It filters traffic for the box it’s installed on, which gives it more granularity than a perimeter device can offer, but it also means every host needs its own correctly configured ruleset instead of inheriting protection from somewhere upstream. A firewall covering your gateway does nothing for a server that gets compromised through your VPN and then talks laterally to everything else on the subnet.
Where iptables Sits in the Kernel
iptables is the command-line front end to Netfilter, the packet filtering framework that’s been part of the Linux kernel since kernel 2.4. Netfilter exposes five hook points as a packet moves through the stack: PREROUTING, INPUT, FORWARD, OUTPUT, and POSTROUTING. For day-to-day firewall work you mostly interact with three of them:
- INPUT handles packets destined for the local system
- OUTPUT handles packets leaving the local system
- FORWARD handles packets being routed through the system without stopping there, relevant if the box is acting as a router or gateway

Worth knowing going in: what you type as iptables on a modern distro almost certainly isn’t running the original iptables engine anymore. Ubuntu 22.04+, Debian 11+, RHEL 9+, AlmaLinux 9+, and Fedora 35+ all ship nftables as the actual packet-filtering backend, with an iptables-nft compatibility layer quietly translating your familiar syntax into nftables rules behind the scenes. Your commands still work exactly as documented here. You’re just running them through a translator now, and you can confirm it yourself by running iptables --version and looking for nf_tables in the output.
The Three Building Blocks: Tables, Chains, Target
Tables
Tables group rules by what kind of job they do. There are five:
| Table | Purpose |
|---|---|
| filter | The default table. Straightforward allow/block decisions. |
| nat | Rewrites source or destination addresses (used for port forwarding, masquerading outbound traffic). |
| mangle | Alters packet headers, like TTL or TOS fields. |
| raw | Configures exemptions from connection tracking, mostly used for performance tuning on high-traffic boxes. |
| security | Applies SELinux security context marking, rarely touched outside hardened environments. |
If you don’t specify a table with -t, iptables assumes filter. That’s where nearly everything in this guide lives, and it’s also the table you’ll spend 90 percent of your time in unless you’re specifically doing NAT or traffic shaping work.
Chains
A chain is an ordered list of rules within a table. The built-in ones are INPUT, OUTPUT, and FORWARD (plus PREROUTING and POSTROUTING for NAT work). You can also define your own custom chains and jump into them from a built-in chain, which is how you keep a large ruleset organized instead of dumping 80 rules into INPUT and hoping for the best. Custom chains are also how you build reusable logic. A chain called LOG_AND_DROP that logs a packet and then drops it can be jumped to from a dozen different rules instead of duplicating both actions everywhere.
Targets
A target is the verdict a packet gets when it matches a rule. There are five you’ll use constantly:
ACCEPT lets the packet through. DROP discards it silently, with no response sent back. REJECT discards it too, but sends an error response, so the sender knows something actively refused them rather than wondering if the packet vanished into the void. LOG records the packet and keeps evaluating the next rule, since logging by itself isn’t a verdict. RETURN stops processing the current chain and falls back to whatever chain called it.
The DROP versus REJECT choice matters more than people give it credit for. DROP makes a port scan take longer, because the scanner has to wait for a timeout instead of getting an instant “closed” response, which is exactly why internet-facing servers almost always default to DROP. REJECT is more polite and easier to debug, which makes it a reasonable choice on an internal network where you’re troubleshooting connectivity between two hosts you control and don’t want to sit through timeouts. But on anything exposed to the wider internet, REJECT hands out a small piece of intelligence for free: it confirms something is listening and actively choosing to refuse the connection, rather than leaving an attacker to guess whether the host even exists.
Installing iptables
It ships by default on almost every Linux distribution. Confirm what you’ve got before you do anything else:
bash
iptables --version
If that comes back empty, install it:
bash
# Debian / Ubuntusudo apt update && sudo apt install iptables# RHEL / CentOS / AlmaLinux (may require disabling firewalld first)sudo systemctl stop firewalldsudo systemctl disable firewalldsudo yum install iptables-services
On RHEL-family systems, firewalld usually owns the firewall by default, and it’s worth understanding what that actually means rather than just disabling it on autopilot. firewalld is a policy layer that sits on top of nftables (or iptables, on older systems) and lets you manage traffic through zones, trust levels like “public,” “internal,” or “dmz”, and predefined services instead of writing raw match-and-target rules by hand. It’s genuinely useful for admins who don’t want to hand-write firewall syntax, and it applies changes dynamically without dropping existing connections. But if you want direct iptables control, the two tools fight over the same rules, so you generally have to step firewalld aside first rather than try to layer one on top of the other.
Reading the Default Policy
Before writing a single rule, check what your chains currently do to unmatched traffic:
bash
sudo iptables -L
A fresh install typically shows all three chains set to ACCEPT, meaning nothing is blocked yet. This is your decision point, and it’s a bigger one than it looks.
Default ACCEPT means you allow everything except what you explicitly block, a blocklist model. It’s low maintenance but leaves you exposed to anything you forgot to block. Default DROP means you block everything except what you explicitly allow, an allowlist model. It’s the more secure posture, and it’s what most hardening guides recommend, but it demands discipline. Forget to allow SSH before you flip INPUT to DROP on a remote box, and you’ve just locked yourself out with no way back in except console access or a support ticket. This single mistake is one of the most common causes of “why can’t I reach my server anymore” incidents on freshly hardened VPS instances. Always add your allow rules first, test, and only then set the restrictive default.
Rule Syntax, Decoded
iptables syntax looks intimidating until you separate it into two categories.
Structural flags, written uppercase, tell iptables what to do with the rule itself. -A appends a rule to the end of a chain. -I inserts a rule at a specific position, or the top by default. -D deletes a rule. -L lists the current rules. -F flushes, deleting all rules in a chain or table. -P sets the default policy for a chain.
Match and target flags, written lowercase, describe the traffic and the verdict. -s is the source address, -d the destination address, -p the protocol (tcp, udp, icmp), --dport and --sport the destination and source port, -i and -o the input and output network interface, and -j jumps to a target, whether that’s ACCEPT, DROP, REJECT, LOG, or a custom chain.
Once that split clicks, every rule you’ll ever write reads like a sentence: append this rule (-A), to this chain, matching this source (-s) on this protocol (-p) at this port (–dport), and jump to this verdict (-j).

Building Rules, Step by Step
Block a single IP address
bash
sudo iptables -A INPUT -s 203.0.113.55 -j DROP
This drops everything from that one address, no matter the protocol or port.
Block an entire subnet
Use CIDR notation to cover a range instead of one host:
bash
sudo iptables -A INPUT -s 203.0.113.0/24 -j DROP
This is the same technique used for geo-blocking, where a whole country’s aggregated IP ranges get dropped in bulk. For large lists like that, don’t write hundreds of individual iptables rules. It’ll cripple performance, since each packet gets checked line by line against every rule above it. Use ipset instead to bundle the range into a single set that iptables references with one rule, which keeps the ruleset both fast and readable.
Block traffic to a specific port
bash
sudo iptables -A INPUT -p tcp --dport 22 -j DROP
That example blocks inbound SSH. Reverse the logic to allow instead of block, and you have the single most common iptables rule in existence:
bash
sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT
Allow outbound traffic to one destination only
bash
sudo iptables -A OUTPUT -p tcp -d 93.184.216.34 --dport 443 -j ACCEPTsudo iptables -A OUTPUT -p tcp --dport 80 -j DROPsudo iptables -A OUTPUT -p tcp --dport 443 -j DROP
Two things to understand here, because both trip people up constantly.
First, if you write a rule with -d example.com instead of a raw IP, iptables resolves that hostname to an IP address once, at the moment you create the rule, and bakes that IP in. It does not re-resolve on every connection. If the site’s IP changes, which happens constantly behind a CDN, your rule silently stops matching what you intended, and you won’t get an error telling you so. For anything that needs to survive beyond a quick test, use the IP address directly, or better, use ipset with a script that refreshes entries on a schedule so the rule stays accurate as the underlying infrastructure changes.
Second, and this is the one that actually breaks people’s firewalls: rule order is everything. iptables evaluates rules top to bottom and stops at the first match. In the example above, the allow rule for the specific destination has to come before the two DROP rules for ports 80 and 443, or the DROP rules will catch and kill that traffic before the allow rule ever gets a chance to run. Flip the order and the “allowed” site becomes unreachable too, with no error message pointing you toward why. This is the number one reason people post “my iptables rules aren’t working” threads. They wrote a correct rule that never gets evaluated, because a broader rule sitting above it already claimed the packet first.
Viewing, Deleting, and Flushing Rules
List what’s active, with rule numbers so you can reference them individually:
bash
sudo iptables -L --line-numbers -v -n
The -v flag adds packet and byte counters per rule, genuinely useful for spotting which rules are actually being hit versus dead weight nobody triggers anymore. -n skips DNS resolution on the output so the listing doesn’t hang waiting on reverse lookups.
Delete a specific rule by its line number:
bash
sudo iptables -D INPUT 3
Wipe everything and start clean:
bash
sudo iptables -F # flush rules in the filter tablesudo iptables -X # delete any custom chainssudo iptables -t nat -F # flush the nat table separatelysudo iptables -t mangle -F
Flushing doesn’t reset the default policy. If INPUT was set to DROP before you flushed, it’s still DROP after, just with an empty rule list underneath it and nothing left to let traffic through. On a remote box, that’s another classic lockout scenario, so check your policy both before and after any flush, not just before.
Making Rules Survive a Reboot
This is the step a lot of quick-start guides skip entirely, and it’s the first question everyone asks the moment their carefully built ruleset vanishes after the next reboot. iptables rules live in memory, not on disk, unless you explicitly save them.
Debian/Ubuntu:
bash
sudo apt install iptables-persistentsudo netfilter-persistent save
RHEL/CentOS/AlmaLinux:
bash
sudo iptables-save > /etc/sysconfig/iptablessudo systemctl enable iptables
Take the habit of running iptables-save > ~/iptables.backup.$(date +%F).v4 before any significant change. If a new ruleset locks you out or breaks something, restoring from that backup with iptables-restore is a much better afternoon than rebuilding forty rules from memory over a console session, working from a half-remembered mental model of what you had before.
A Sane Baseline Ruleset
Most hardening guides converge on roughly the same starting shape. Loopback traffic gets allowed unconditionally, established connections get allowed so replies to your own outbound requests aren’t blocked, invalid packets get dropped, and only then do you apply restrictive policy:
bash
# Allow loopback (localhost) trafficsudo iptables -A INPUT -i lo -j ACCEPT# Allow established and related connections (replies to traffic you initiated)sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT# Drop packets that don't make sense in any known connection statesudo iptables -A INPUT -m conntrack --ctstate INVALID -j DROP# Allow SSH before locking anything down (adjust port if you don't use 22)sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT# Now, and only now, set the default policy to DROPsudo iptables -P INPUT DROPsudo iptables -P FORWARD DROPsudo iptables -P OUTPUT ACCEPT
That ordering is not arbitrary. Set the DROP policy before the SSH allow rule exists, and you’ll cut your own session mid-command on a remote box, with no way back in short of console access.

iptables vs nftables: What You Need to Know
nftables entered the Linux kernel in January 2014 and has been the default packet-filtering backend on every major distribution released since roughly 2019 to 2020, displacing iptables as the out-of-the-box choice on Debian, Ubuntu, RHEL, and Fedora alike. It fixes real architectural pain points. A single nft command replaces the separate iptables, ip6tables, arptables, and ebtables tools. IPv4 and IPv6 can share one ruleset instead of duplicated rule files. And unlike iptables, which pre-registers a fixed set of tables and chains whether you use them or not, nftables only registers the base chains you explicitly define, which means set-based matching scales far better once a ruleset gets large.

The performance gap isn’t theoretical. Benchmark testing from SKUDONET found nftables handling roughly double the NAT throughput per CPU core compared to iptables under load, with DNAT performance around 256,000 requests per second per core on iptables versus roughly 561,000 on nftables, and SNAT around 262,000 versus 609,000. nftables also ships with a built-in packet tracing feature for debugging exactly which rule is blocking a given connection, something iptables has never offered natively. You tag a specific address for tracing, then watch the live trace output to pinpoint the exact rule causing the block, instead of commenting out rules one at a time until traffic starts flowing again.
So should you skip iptables and just learn nftables? Depends what you’re doing.
If you’re standing up new production infrastructure, learn nftables directly rather than relying on the iptables-nft translation layer forever. It’s the actively developed tool, and it’s the one your distribution is already running underneath your iptables commands anyway, whether you’ve noticed or not.
If you’re preparing for OSCP, working through HTB or THM boxes, or maintaining legacy infrastructure that predates 2020, iptables syntax is still what you’ll encounter constantly. Certification labs and a huge share of still-running production Linux boxes were built before nftables became the default, and tools like Fail2Ban and Docker still generate iptables-style rules in many configurations by default. Realistically, you need both: nftables for anything you’re building today, iptables fluency for anything you’re auditing, exploiting, or defending that was built before today, which in practice is most of what you’ll actually touch for years to come.
Reading a Firewall Like an Attacker
This is the part most iptables tutorials skip entirely, and it’s the part that actually matters if you’re doing offensive work rather than just standing up a home server.
When you run an nmap scan against a target and see a port marked filtered rather than closed, that’s usually a firewall silently dropping your probe, a DROP target, rather than actively refusing it, a REJECT, or genuinely no service listening at all. That distinction alone tells you something about the box’s security posture before you’ve touched anything else.
A few misconfiguration patterns show up constantly, on real assessments and in lab environments alike.

Overly broad ACCEPT rules placed before restrictive ones sit at the top of the list, and it’s exactly the rule-order trap covered above, except now it’s working against the defender instead of just being an annoyance. A rule like -A INPUT -s 10.0.0.0/8 -j ACCEPT sitting near the top of a ruleset, intended to trust “internal” traffic, becomes a wide-open door the moment an attacker pivots from any compromised host inside that range.
Default-ACCEPT OUTPUT policy is the second one, and it’s everywhere. Look at the baseline ruleset above again: OUTPUT is left at ACCEPT while INPUT gets locked down. That’s extremely common, and it’s also exactly why command-and-control callbacks and data exfiltration over standard ports like 443 usually sail straight through a “hardened” host without triggering anything. Inbound-only hardening protects against a machine being reached. It does nothing against a machine that’s already been compromised reaching out on its own. If you’re on the defensive side, egress filtering, restricting OUTPUT to known-necessary destinations instead of leaving it wide open, closes a gap that inbound rules alone never touch.
Rules that exist but were never tested under load or reboot round out the pattern. A ruleset that isn’t persisted, isn’t backed up, or was built once and never audited tends to drift from what the admin thinks is actually enforced. Running iptables -L -v -n and finding packet counters sitting at zero on a supposedly critical rule is worth investigating. It may mean the rule is unreachable because of something above it, exactly like the ordering issue already covered, and the admin has been operating for months under a false sense of coverage.
None of this is exotic. It’s the same three or four mistakes, over and over, on real infrastructure and in lab environments built to teach this exact material. Recognizing them is most of the battle, on either side of the fence.
Safety Checklist Before You Touch a Remote Firewall
- Keep a second session open, SSH plus console or VNC access, before applying anything restrictive
- Back up the current ruleset:
iptables-save > ~/backup.$(date +%F-%H%M).v4 - Allow SSH, or whatever you’re connected on, before setting any DROP policy
- Apply changes incrementally, not as one giant script you can’t easily unwind
- Verify persistence is configured, or your careful ruleset disappears on the next reboot
- Double-check rule order after any edit, since inserting a rule in the wrong position can silently override something you already had working
Practice Exercises
- Build a ruleset that allows outbound HTTPS to one specific destination IP and blocks all other outbound traffic on ports 80 and 443. Get the rule order right on the first try, then deliberately break it by reversing the order and observe the difference with
curl. - Add a rule blocking inbound traffic on port 445 (SMB), then use
iptables -L -v -nto confirm the rule is actually being matched, not sitting dead beneath a broader rule. - Set up a full default-DROP INPUT policy on a test VM, not production, including the loopback and established-connection allow rules, and confirm SSH still works before you consider it done.
- Flush your ruleset entirely, restore it from an
iptables-savebackup, and verify the restored ruleset matches what you started with. - Deliberately create a broad
ACCEPTrule ahead of a restrictive one, the way a misconfigured “trust the internal network” rule might look, and useiptables -L -v -nto identify why the restrictive rule below it never fires.
Key Takeaways
Rule order decides everything, and it’s the single most common source of “this should be working and isn’t.” Default DROP is the safer posture, but only if your allow rules exist before you flip the switch. Rules vanish on reboot unless you explicitly persist them. And the same misconfigurations that inconvenience a sysadmin, an overly broad ACCEPT rule, a wide-open OUTPUT policy, an untested ruleset, are exactly what an attacker is hoping to find sitting on the other side of a scan.








