Skip to main content
RunBook Academy

OPNsenseIII · Stateful Firewalling and PFStateful firewalling and PF

PF rule evaluation order

Intermediate⏱ ~15 minpfctltcpdump

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

Not yet marked complete on this device.

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 block that matches everything and therefore wins by last-match whenever no quick rule has claimed the packet. It is visible in pfctl -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.
Read-only / Safepfctl -s rules
$ 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 all

Illustrative 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 quick modifier 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.

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:

  1. 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.
  2. 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.
  3. 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:

  1. Edit the GUI rule.
  2. Click Apply.
  3. Run pfctl -s rules | grep -A 1 -B 1 <unique text> to confirm the rule compiled at the expected position.
  4. Send test traffic and confirm the rule matches as expected.
  5. If the rule does not behave as expected, check pfctl -s state for 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.
  • quick stops evaluation at the first match. OPNsense uses quick everywhere.
  • The GUI rules are not the rules PF runs. Read pfctl -s rules to 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

  1. 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?

  2. Q2. OPNsense generates every GUI rule with the quick modifier by default.

  3. Q3. Which of the following statements about PF rule evaluation are correct? Select all that apply.

  4. 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.