OPNsenseIII · Stateful Firewalling and PFStateful firewalling and PF
PF rule evaluation order
What you'll learn
- Explain PF evaluation order: top-down, first match wins, and where the default-deny actually comes from
- Read the generated PF ruleset with pfctl and map it back to the GUI rules
- Identify the rule-ordering traps that cause production incidents
- Construct a deliberate, reviewable ruleset ordering
Prerequisites
Verified against OPNsense 25.x · FreeBSD 14.x · PF (FreeBSD packet filter) FreeBSD 14.x · Unbound 1.20+ · Kea DHCP OPNsense 25.x plugin · WireGuard in-kernel + OPNsense plugin · strongSwan (IPsec plugin) OPNsense 25.x plugin · OpenVPN 2.6.x · Suricata 7.x · 2026-08-14
A firewall ruleset is a list of rules evaluated top to bottom. PF (like iptables, nftables, and Cisco ACLs) uses first match wins: the first rule that matches a packet decides whether it is permitted or blocked, and evaluation stops. Every rule after the matching one is irrelevant for that packet.
This rule-ordering model is simple to describe and brutally easy to get wrong. A misplaced rule that matches a broad pattern will shadow every rule below it. The lesson covers how evaluation works, how to read the ruleset PF is actually running, and the production patterns that prevent order-induced incidents.
First match wins
For each packet that has no matching state, PF walks the ruleset top to bottom. The first rule whose match criteria all match the packet is the one that decides. PF applies the rule’s action (pass, block, match, etc.), allocates state if appropriate, and stops.
Three consequences:
- A broad rule above a specific rule shadows it. A common trap: the operator writes a “permit LAN → any” rule at the top, then writes a more specific “block LAN → 10.0.0.0/8” rule below. The block never matches because the permit above it always wins.
- Action types are not the same as priorities. Pass, block, match, rdr, nat — they are all rules. PF does not prefer pass over block or vice versa. Order is everything.
- The default-deny is a rule, not a PF default. PF’s own
behaviour for a packet that matches no rule is to pass it.
What blocks unmatched traffic on OPNsense is a rule the filter
generator writes for you: a non-quick
blockthat matches everything and therefore wins by last-match whenever noquickrule has claimed the packet. It is visible inpfctl -sr. The distinction matters the moment an operator edits the generated ruleset or writes an anchor by hand — remove that rule and PF does not fall back to blocking.
$ pfctl -s rules@87 pass in quick on igb0 inet proto tcp from 192.0.2.0/24 to any port = https
@88 pass in quick on igb0 inet proto tcp from 192.0.2.0/24 to any port = http
@89 pass in quick on igb0 inet proto tcp from 192.0.2.0/24 to any port = 53
@92 pass in quick on igb0 inet proto tcp from 192.0.2.0/24 to any port = 22
@100 block drop in quick on igb0 inet from 10.0.0.0/8 to any
@110 block drop in on igb1 inet from any to any
@115 pass out allIllustrative output
The quick modifier
Notice quick on each rule above. The quick keyword tells PF
to stop evaluating as soon as the rule matches. Without quick,
PF continues evaluating even after a match, and the last
matching rule wins.
OPNsense generates every rule with quick by default. This is
the safest behaviour for most operators: first match wins, just
like the GUI suggests. But it has a subtle consequence: rules
below a quick rule never see packets that the quick rule
matches. So if a rule above silently captures packets you meant
to handle with a rule below, you will not see the packets in the
log.
If you need last-match-wins semantics (rare in OPNsense), omit
quick. Be aware that this also means logging the evaluating
rules is harder — every matching rule fires the log line, not
just the last one.
How OPNsense generates the ruleset
The GUI rules are not the rules PF runs. OPNsense compiles the
GUI rules into a filter.conf (under
/conf/backup/filter/) and feeds it to pfctl -f. The compiled
ruleset adds several elements the GUI does not show directly:
- Rule numbers (
@N) for ordering and reference. - The
quickmodifier on every GUI rule. - Anchor rules that load additional rulesets from subdirectories.
- Default block rules at the bottom that catch everything the GUI did not explicitly permit.
To read what PF is actually doing, always use pfctl -s rules.
Do not assume the GUI’s rule ordering matches the compiled
ordering in edge cases (floating rules, interface-group rules,
NAT hooks).
Production ordering patterns
Three ruleset orderings cover most production estates well.
Default-allow-then-specific-deny (least safe, most common)
1. Permit LAN -> any
2. Permit DMZ -> specific destinations
3. Permit specific block on a sub-pattern
4. Block all
This is what the OPNsense wizard generates. It is easy to write but it means the operator cannot easily add a deny rule for specific traffic — every deny rule must be added above the default-allow.
Specific-permit-then-default-deny (safest, recommended)
1. Permit LAN -> Internet (specific)
2. Permit DMZ -> specific internal services
3. Permit VPN -> internal
4. Permit management -> specific
5. Block all
Every permit rule is explicit. Every block above the default catches specific traffic that should not be permitted. This is the ruleset the OPNsense hardening guide recommends for production estates.
Tiered ruleset
1. Anti-spoofing blocks (RFC 1918 from WAN, bogons)
2. Permit established/related traffic (the "let return traffic
through" rule)
3. Permit specific inbound services (each on its own line)
4. Permit specific outbound services (each on its own line)
5. Block all
Each tier has a purpose; rules within a tier can be reordered without affecting the overall behaviour. This is the most reviewable ordering and the one the OPNsense production-first checklist promotes.
Order-induced production incidents
Three patterns recur:
- The permit-any above a specific deny. A “permit LAN to any” rule sits above a “deny LAN to 10.0.0.0/8” rule. The deny never matches. The fix is to swap their order or to rewrite the deny as a more specific permit elsewhere.
- The floating rule that matches everywhere. A floating rule that permits LAN-to-anything on every interface shadows every interface-specific rule. The fix is to bind the floating rule to a specific interface or to reorder.
- The logging rule above the real rule. A “log all” rule for debugging, left in place, captures every packet that should have matched a more-specific rule below. The fix is to remove the debug rule after diagnosis.
Verifying a ruleset
The verification sequence for any ruleset change:
- Edit the GUI rule.
- Click Apply.
- Run
pfctl -s rules | grep -A 1 -B 1 <unique text>to confirm the rule compiled at the expected position. - Send test traffic and confirm the rule matches as expected.
- If the rule does not behave as expected, check
pfctl -s statefor the flow; the state table may have cached the old decision.
The state-cache point is critical: PF caches decisions in the
state table. If a packet matches a state entry from before the
rule change, it does not re-evaluate against the new ruleset.
To verify the new ruleset for an existing flow, clear the
relevant state with pfctl -k state.
Summary
- PF evaluates top-down. First match wins. PF’s own default for an unmatched packet is pass; the default-deny is an explicit generated rule.
quickstops evaluation at the first match. OPNsense usesquickeverywhere.- The GUI rules are not the rules PF runs. Read
pfctl -s rulesto verify. - Order matters: a broad permit above a specific deny shadows the deny.
- State caches decisions. Verify new rules by clearing state.
Knowledge check · 4 questions
Q1. You add a rule that permits HTTPS from the LAN to a specific server, but the rule never matches. The GUI shows the rule in the right place. What is the most likely cause?
Q2. OPNsense generates every GUI rule with the quick modifier by default.
Q3. Which of the following statements about PF rule evaluation are correct? Select all that apply.
Q4. You change a firewall rule but a live TCP session continues to match the old rule behaviour. PF state shows an entry for the session. What is the most likely explanation?
Passing score: 75%. Answers are checked in this browser.