FreeBSD.software
Home/Guides/PF Firewall on FreeBSD: The Complete Review
review·2026-04-09·12 min read

PF Firewall on FreeBSD: The Complete Review

Complete review of PF firewall on FreeBSD: architecture, rule syntax, tables, anchors, NAT, rate limiting, pflog analysis, and comparison with IPFW and Linux nftables.

PF Firewall on FreeBSD: The Complete Review

PF (Packet Filter) is a stateful packet filtering engine originally developed for OpenBSD and ported to FreeBSD. It is one of three firewall options available on FreeBSD -- alongside IPFW (the native FreeBSD firewall) and IPFilter -- but PF has become the most popular choice for FreeBSD administrators due to its clean syntax, powerful feature set, and excellent documentation. PF handles packet filtering, NAT, traffic shaping, and logging in a single, coherent framework.

This review covers PF's architecture and design philosophy, rule syntax, tables for dynamic IP sets, anchors for modular rulesets, NAT configuration, rate limiting, pflog for traffic analysis, and a comparison with IPFW and Linux's nftables.

PF Architecture

PF operates as a kernel-level packet filter. When enabled, every packet entering or leaving a network interface passes through PF's ruleset. The design principles are worth understanding because they shape how you write rules.

Last-match wins: PF evaluates rules top to bottom. The last matching rule determines the action (pass or block). This is different from first-match firewalls. The quick keyword overrides this behavior, causing immediate action on the first match.

Stateful by default: PF automatically creates state entries for passed traffic. Return traffic matching an existing state is allowed without requiring explicit rules. This means you typically write rules only for connection initiation.

Atomic ruleset loading: When you reload PF rules with pfctl -f /etc/pf.conf, the entire ruleset is loaded atomically. There is no moment where a partial ruleset is active.

Tables for performance: PF tables are kernel-level data structures optimized for fast lookups of IP addresses. A table with 100,000 entries performs nearly as fast as one with 10 entries.

Enabling PF on FreeBSD

Load the PF kernel module and enable the service:

sh
kldload pf kldload pflog sysrc pf_enable="YES" sysrc pflog_enable="YES" sysrc pf_rules="/etc/pf.conf"

Start PF:

sh
service pf start service pflog start

Verify PF is running:

sh
pfctl -s info

If you are configuring PF remotely over SSH, protect yourself from lockout. Before loading rules, set a cron job to disable PF in case of misconfiguration:

sh
echo "pfctl -d" | at now + 5 minutes

If your rules work, cancel the at job. If they lock you out, PF will disable itself in five minutes.

Rule Syntax

Basic Rule Structure

PF rules follow this general syntax:

shell
action [direction] [log] [quick] [on interface] [af] [proto protocol] \ [from source [port port]] [to destination [port port]] \ [flags flags] [state]

Essential Rule Examples

A minimal but functional /etc/pf.conf:

sh
# /etc/pf.conf # Macros ext_if = "em0" int_if = "em1" tcp_services = "{ ssh, http, https }" trusted_nets = "{ 10.0.0.0/8, 192.168.0.0/16 }" # Tables table <bruteforce> persist # Options set skip on lo0 set block-policy drop set loginterface $ext_if set state-policy if-bound # Scrub match in all scrub (no-df max-mss 1440) # Default deny block in all block out all # Loopback -- already skipped but belt-and-suspenders pass quick on lo0 all # Outbound -- allow all from this host pass out on $ext_if proto { tcp udp icmp } from ($ext_if) to any keep state # Inbound services pass in on $ext_if proto tcp from any to ($ext_if) port $tcp_services keep state # SSH with bruteforce protection pass in on $ext_if proto tcp from any to ($ext_if) port ssh \ flags S/SA keep state \ (max-src-conn 10, max-src-conn-rate 5/30, \ overload <bruteforce> flush global) # Block bruteforce offenders block in quick on $ext_if from <bruteforce> # Internal network pass in on $int_if from $trusted_nets to any keep state pass out on $int_if from any to $trusted_nets keep state # ICMP pass in on $ext_if inet proto icmp icmp-type { echoreq, unreach }

Loading and Testing Rules

sh
# Check syntax without loading pfctl -n -f /etc/pf.conf # Load the ruleset pfctl -f /etc/pf.conf # View active rules pfctl -s rules # View rules with rule numbers and evaluations pfctl -vvs rules

Tables

Tables are PF's mechanism for handling large sets of IP addresses efficiently. They are stored in kernel memory using a radix tree, providing O(log n) lookup performance.

Creating and Managing Tables

Define persistent tables in pf.conf:

sh
# Empty table populated at runtime table <bruteforce> persist # Table loaded from file table <whitelist> persist file "/etc/pf.whitelist" # Table with inline addresses table <management> persist { 10.0.1.0/24, 10.0.2.5 }

Manage tables at runtime:

sh
# Add an address pfctl -t bruteforce -T add 198.51.100.5 # Remove an address pfctl -t bruteforce -T delete 198.51.100.5 # List table contents pfctl -t bruteforce -T show # Flush all entries pfctl -t bruteforce -T flush # Load addresses from file pfctl -t whitelist -T replace -f /etc/pf.whitelist # Show table statistics pfctl -t bruteforce -T show -vv

Automatic Table Expiry

PF does not natively expire table entries, but a cron job can handle this:

sh
# /etc/cron.d/pf-expire */5 * * * * root /sbin/pfctl -t bruteforce -T expire 86400

This removes entries older than 24 hours (86400 seconds) from the bruteforce table every 5 minutes.

Anchors

Anchors allow modular, hierarchical rulesets. An anchor is a named collection of rules that can be loaded, flushed, and managed independently of the main ruleset.

Defining Anchors

In /etc/pf.conf:

sh
# Load anchor from file at startup anchor "jails" load anchor "jails" from "/etc/pf.anchors/jails.conf" # Empty anchor populated at runtime anchor "blocklist" # Anchor for NAT rules nat-anchor "jails" rdr-anchor "jails"

Managing Anchors at Runtime

sh
# Add rules to an anchor echo "pass in on em0 proto tcp from any to 10.0.0.50 port 80" | pfctl -a jails -f - # View rules in an anchor pfctl -a jails -s rules # Flush an anchor pfctl -a jails -F rules # Load from file pfctl -a blocklist -f /etc/pf.anchors/blocklist.conf

Anchors are useful for jail management -- each jail's rules can live in a separate anchor, loaded and unloaded as jails start and stop.

NAT Configuration

PF handles NAT (Network Address Translation) using nat, rdr, and binat rules. In modern PF on FreeBSD, NAT rules use the nat-to, rdr-to, and binat-to syntax within match or pass rules.

Source NAT (Outbound)

For a FreeBSD router providing internet access to an internal network:

sh
ext_if = "em0" int_if = "em1" nat on $ext_if from $int_if:network to any -> ($ext_if)

The parentheses around ($ext_if) handle dynamic IP addresses -- PF automatically updates the NAT address when the interface IP changes.

Port Forwarding (Inbound)

Forward external port 80 to an internal web server:

sh
rdr on $ext_if proto tcp from any to ($ext_if) port 80 -> 10.0.1.50 port 80

Forward with port translation:

sh
rdr on $ext_if proto tcp from any to ($ext_if) port 8080 -> 10.0.1.50 port 80

NAT for Jails

A common pattern for FreeBSD jails with private IPs:

sh
ext_if = "em0" jail_net = "10.0.99.0/24" # Outbound NAT for jails nat on $ext_if from $jail_net to any -> ($ext_if) # Port forwarding to specific jails rdr on $ext_if proto tcp from any to ($ext_if) port 80 -> 10.0.99.10 port 80 rdr on $ext_if proto tcp from any to ($ext_if) port 443 -> 10.0.99.10 port 443 rdr on $ext_if proto tcp from any to ($ext_if) port 25 -> 10.0.99.20 port 25

Rate Limiting

PF provides several mechanisms for rate limiting connections.

Per-Source Connection Limits

sh
pass in on $ext_if proto tcp from any to ($ext_if) port smtp \ flags S/SA keep state \ (max-src-conn 5, max-src-conn-rate 3/60, \ overload <smtp_abuse> flush global)

This limits each source IP to 5 simultaneous connections and 3 new connections per 60 seconds. Offenders are added to the smtp_abuse table and all their existing states are flushed.

Global Connection Limits

sh
pass in on $ext_if proto tcp from any to ($ext_if) port http \ keep state (max 10000)

Limits total state entries for this rule to 10,000.

SYN Proxy

For protecting servers from SYN floods:

sh
pass in on $ext_if proto tcp from any to ($ext_if) port http \ flags S/SA synproxy state

PF completes the three-way handshake on behalf of the server before forwarding the connection. This absorbs SYN flood attacks at the firewall.

pflog: Traffic Analysis

PF logs packets to the pflog interface, which can be captured and analyzed with tcpdump.

Enabling Logging

Add log to rules you want to monitor:

sh
block in log on $ext_if all pass in log on $ext_if proto tcp from any to ($ext_if) port ssh

Viewing Live Logs

sh
tcpdump -n -e -ttt -i pflog0

The -e flag shows the PF rule number and action for each logged packet.

Saving Logs to File

The pflog service writes to /var/log/pflog in pcap format:

sh
# Read the log file tcpdump -n -e -ttt -r /var/log/pflog # Filter by action tcpdump -n -e -ttt -r /var/log/pflog 'action block' # Filter by source tcpdump -n -e -ttt -r /var/log/pflog 'src 198.51.100.0/24'

PF Monitoring and Debugging

Runtime Statistics

sh
# Interface statistics pfctl -s info # State table pfctl -s state # Memory usage pfctl -s memory # Table statistics pfctl -s Tables # Rule evaluation counters pfctl -vvs rules

PF vs IPFW vs nftables

IPFW (FreeBSD Native)

IPFW is FreeBSD's native firewall, available in the base system since the early days of FreeBSD.

Architecture: IPFW uses a first-match rule evaluation model with numbered rules (0-65535). Rules are processed in order; the first matching rule determines the action.

Strengths over PF: Part of the base system with no kernel module needed. Supports dummynet for traffic shaping. Numbered rules allow precise insertion.

Weaknesses: More verbose syntax, lacks PF's table performance, no anchor system for modular rulesets.

When to use IPFW: Advanced traffic shaping with dummynet, or when your team has IPFW expertise.

nftables (Linux)

nftables is the modern packet filtering framework for Linux, replacing iptables.

Architecture: nftables uses a virtual machine approach where rules are compiled into bytecode and executed in kernel space. It uses tables, chains, and rules with a syntax distinct from both iptables and PF.

Strengths over PF: nftables has a more flexible rule structure with sets, maps, and concatenations. Its incremental rule updates are more granular than PF's atomic reload. The Linux ecosystem has more nftables tooling and integration.

Weaknesses: nftables is Linux-only. Its syntax is arguably more complex than PF for common use cases. The transition from iptables has created documentation confusion. PF's syntax is cleaner for standard firewall configurations.

Comparison notes: Both PF and nftables are modern, stateful packet filters. PF's strength is its simplicity and clean design. nftables' strength is flexibility and Linux ecosystem integration. Choosing between them usually comes down to choosing between FreeBSD and Linux.

Summary

| Feature | PF | IPFW | nftables |

|---|---|---|---|

| Platform | FreeBSD, OpenBSD | FreeBSD | Linux |

| Rule evaluation | Last-match (or quick) | First-match | Chain-based |

| Syntax clarity | Excellent | Good | Good |

| NAT integration | Built-in | Separate (natd/in-kernel) | Built-in |

| Tables/Sets | Tables (radix tree) | Lookup tables | Sets and maps |

| Traffic shaping | ALTQ (deprecated) | dummynet | tc integration |

| Modular rulesets | Anchors | N/A | Chains |

| Logging | pflog interface | ipfw log | nflog |

FAQ

Is PF included in the FreeBSD base system?

PF is included as a kernel module in FreeBSD's base system. You do not need to install any packages. Load it with kldload pf or add pf_load="YES" to /boot/loader.conf for persistent loading. The pfctl management utility is also part of the base system.

How do I prevent locking myself out when configuring PF remotely?

Three strategies: (1) Use echo "pfctl -d" | at now + 5 minutes before loading new rules. (2) Test rules with pfctl -n -f /etc/pf.conf to check syntax before loading. (3) Keep a console session open (serial, IPMI, or cloud console) as a backup path. Always ensure your SSH allow rule is correct before loading a restrictive ruleset.

Can PF handle 10 Gbps throughput on FreeBSD?

Yes, with proper hardware and configuration. PF on FreeBSD can handle multi-gigabit throughput on modern hardware. Key factors: use a NIC with good FreeBSD driver support (Intel, Chelsio), enable multiple RSS queues, and keep the ruleset simple. Complex rulesets with many anchors and tables add per-packet overhead. For 10 Gbps NAT, ensure the state table is sized appropriately with set limit states 500000 or higher.

How do I block a country's IP range with PF?

Use a table loaded from a file containing the country's IP blocks:

sh
table <blocked_countries> persist file "/etc/pf.blocklist.country" block in quick on $ext_if from <blocked_countries>

Populate the file from a source like ipdeny.com or db-ip.com. Automate updates with a cron job that fetches the latest IP blocks and reloads the table with pfctl -t blocked_countries -T replace -f /etc/pf.blocklist.country.

How do I use PF with FreeBSD jails?

For jails using shared IP (IP alias on the host interface), PF rules on the host filter all jail traffic. For VNET jails with their own network stack, you can run PF inside the jail or filter on the bridge interface on the host. The anchor system is useful here -- create an anchor per jail and load/unload rules as jails start and stop.

What replaced ALTQ for traffic shaping with PF?

ALTQ (Alternate Queueing) was PF's integrated traffic shaping framework, but it has been deprecated in FreeBSD. For traffic shaping on FreeBSD, use IPFW with dummynet, which provides pipes and queues for bandwidth management. Some administrators run PF for filtering and IPFW for traffic shaping simultaneously, though this requires careful configuration to avoid conflicts.

How do I log all blocked packets for analysis?

Add log to your default block rule:

sh
block in log all block out log all

Then analyze with tcpdump:

sh
tcpdump -n -e -ttt -r /var/log/pflog 'action block'

For long-term analysis, consider shipping pflog data to a centralized logging system. The pfflowd tool can export PF state data as NetFlow records for analysis in tools like ntopng or Elasticsearch.

Get more FreeBSD guides

Weekly tutorials, security advisories, and package updates. No spam.