Build the mental model
A firewall's job sounds simple, decide whether a packet may pass, but the design turns on how much it remembers.
A stateless filter examines each packet in isolation, using only the header fields in front of it: source and destination address, protocol, ports, flags. It has no memory of what came before. That is cheap and fast, and it creates an ugly problem. When your server makes an outbound request, the reply arrives from an arbitrary remote port back to a high-numbered ephemeral port on your machine. A stateless filter that wants to allow those replies cannot distinguish them from unsolicited inbound connections to those same ports, so it ends up opening the entire ephemeral range to the world.
A stateful firewall keeps a connection table. When it permits an outbound flow it records the five-tuple, and return packets matching an existing entry are accepted because they belong to a conversation you started, not because a port was left open. This is why the first rule in almost every real ruleset accepts ESTABLISHED traffic. The distinction is invisible in the packet itself: two packets can be byte-identical and get opposite verdicts purely because one belongs to a known flow.
What makes any of this work is default deny. The last rule drops everything, and each rule above it carves out one specific exception. The alternative, allowing everything and blocklisting the bad, fails the moment someone deploys a service you did not anticipate.
Rules are evaluated top to bottom and the first match wins. Ordering is the program. A broad allow placed above a narrow deny silently makes the deny unreachable, and nothing warns you.
Finally, a network firewall filters at a boundary; a host firewall runs on the machine itself and still applies to traffic that never crossed that boundary.
A PACKET THROUGH AN ORDERED RULE CHAIN
--------------------------------------
packet arrives
|
v
+-----------------------------------+
| 1. state == ESTABLISHED ? |-- match --> ACCEPT
+-----------------------------------+
| no match
v
+-----------------------------------+
| 2. dst 203.0.113.10:443, NEW ? |-- match --> ACCEPT
+-----------------------------------+
| no match
v
+-----------------------------------+
| 3. src 10.0.0.0/24 -> :22, NEW ? |-- match --> ACCEPT
+-----------------------------------+
| no match
v
+-----------------------------------+
| 4. default policy |------------> DROP
+-----------------------------------+
FIRST MATCH WINS
Nothing below a matching rule is ever read. Put a broad ACCEPT
above a narrow DROP and the DROP becomes dead code, silently.
WHY RULE 1 EXISTS
stateless: to let replies in you must open the whole
ephemeral range 32768-60999 to the internet
stateful: the reply matches a flow YOU started, so no
inbound port is opened at allConnect it to a real scenario
Here is a rule-ordering bug that ships to production constantly. Someone exposes a new admin panel, adding an allow rule for port 8080 from anywhere. Later, a security review adds a deny rule for that panel from outside the office, placed at the bottom near the other denies. It never fires: the allow above it already matched, and evaluation stopped there. The ruleset reads correctly to a human and does the wrong thing.
The habit that prevents this: order rules most specific to least specific, and treat the final default-deny as the only broad rule in the chain. Any allow saying from anywhere should prompt you to check whether something narrower belongs above it.
The second habit is to choose between reject and drop deliberately. A reject sends an ICMP message or TCP RST, so the client fails immediately. A drop sends nothing, so the client waits for a timeout. Drop is better facing the internet, because it gives a scanner no information at all. Reject is better internally: a thirty-second hang is a far worse debugging experience than an instant refusal, and that timeout-versus-refused distinction is exactly what tells you a firewall is involved.
The engine below evaluates a fixed set of packets against an ordered chain; its two port 54321 packets show statefulness deciding the outcome by itself.
Try the working example
import ipaddress
# An ordered rule chain. Order is the whole program: the first rule that
# matches decides, and nothing below it is ever consulted.
RULES = [
{"n": 1, "action": "ACCEPT", "state": "ESTABLISHED", "src": "any",
"dst": "any", "port": "any"},
{"n": 2, "action": "ACCEPT", "state": "NEW", "src": "any",
"dst": "203.0.113.10", "port": 443},
{"n": 3, "action": "ACCEPT", "state": "NEW", "src": "10.0.0.0/24",
"dst": "203.0.113.10", "port": 22},
{"n": 4, "action": "DROP", "state": "any", "src": "any",
"dst": "any", "port": "any"}, # default deny, last
]
def matches(rule, pkt):
if rule["state"] != "any" and rule["state"] != pkt["state"]:
return False
if rule["src"] != "any":
if ipaddress.ip_address(pkt["src"]) not in ipaddress.ip_network(rule["src"]):
return False
if rule["dst"] != "any" and rule["dst"] != pkt["dst"]:
return False
if rule["port"] != "any" and rule["port"] != pkt["port"]:
return False
return True
def evaluate(pkt):
for rule in RULES:
if matches(rule, pkt):
return rule["n"], rule["action"]
return None, "DROP" # unreachable here; rule 4 always matches
PACKETS = [
{"src": "198.51.100.7", "dst": "203.0.113.10", "port": 443, "state": "NEW"},
{"src": "198.51.100.7", "dst": "203.0.113.10", "port": 22, "state": "NEW"},
{"src": "10.0.0.5", "dst": "203.0.113.10", "port": 22, "state": "NEW"},
{"src": "93.184.216.34", "dst": "203.0.113.10", "port": 54321,
"state": "ESTABLISHED"},
{"src": "93.184.216.34", "dst": "203.0.113.10", "port": 54321,
"state": "NEW"},
{"src": "198.51.100.7", "dst": "203.0.113.10", "port": 3306, "state": "NEW"},
]
for pkt in PACKETS:
n, action = evaluate(pkt)
where = "rule " + str(n) if n else "policy"
print(action.ljust(7) + pkt["src"].ljust(15) + " -> " +
pkt["dst"] + ":" + str(pkt["port"]).ljust(6) +
pkt["state"].ljust(12) + "(" + where + ")")
print("")
# The two 54321 packets are the point of the whole lesson: identical
# 5-tuples, opposite outcomes, decided purely by connection state.
print("packets 4 and 5 are byte-identical except for state.")
print("stateless filtering cannot tell them apart; stateful can.")ACCEPT 198.51.100.7 -> 203.0.113.10:443 NEW (rule 2)
DROP 198.51.100.7 -> 203.0.113.10:22 NEW (rule 4)
ACCEPT 10.0.0.5 -> 203.0.113.10:22 NEW (rule 3)
ACCEPT 93.184.216.34 -> 203.0.113.10:54321 ESTABLISHED (rule 1)
DROP 93.184.216.34 -> 203.0.113.10:54321 NEW (rule 4)
DROP 198.51.100.7 -> 203.0.113.10:3306 NEW (rule 4)
packets 4 and 5 are byte-identical except for state.
stateless filtering cannot tell them apart; stateful can.5-minute try-it
Move rule 2 above rule 3 and change its port to any, then see which packets change verdict. Next delete rule 1 (the ESTABLISHED rule) and re-run, then write the rule a stateless firewall would need in order to let packet 4 through.
One important caution
Placing a broad allow rule above a narrower deny: first-match-wins makes the deny unreachable and nothing warns you.
Using DROP on internal networks: clients hang until timeout instead of failing fast, which turns a clear refusal into a slow, ambiguous outage.
Cloudflare Learning Center - What is a firewall? — Computer Networking