How pf Processes Rules
pf evaluates rules top to bottom and applies the last matching rule by default. This is different from iptables, where the first match wins on most chains. In pf, unless you use the quick keyword, every rule is evaluated and the last match wins.
The practical consequence: put your block rules before your pass rules if you want pass to win, or use block as a default and pass quick for specific allowed traffic. The quick keyword short-circuits evaluation at that rule, which is what most production rulesets use.
Rule order in /etc/pf.conf also has a structural requirement. Tables and macros must be defined before they are used. Options (set) come first, then tables, then scrub rules, then NAT/binat/rdr, then filter rules. Violating this order produces parse errors that pfctl reports clearly.
# Check current ruleset load status
doas pfctl -s rules
# Test a config file without loading it
doas pfctl -nf /etc/pf.conf
# Load the ruleset
doas pfctl -f /etc/pf.conf
# Enable pf if it is not running
doas pfctl -e
Macros and Tables: Keeping Rulesets Maintainable
Macros are pf variables. Define them at the top of pf.conf and reference them with a dollar sign. They reduce repetition and make IP changes a one-line edit. Tables are dynamic sets of IP addresses that pf can match against at packet speed using radix trees - they handle thousands of entries without performance impact.
We use macros for interface names, trusted admin IPs, and service ports. Tables handle blocklists, allowed country ranges, and dynamic entries updated by scripts.
One naming consideration: when you expose a service or project on a new domain and need to reference it internally in scripts or macros, picking a clean short name matters. Services like nicename.me help you secure a memorable domain before you build out the infrastructure, which is worth doing before you have load balancers and firewall rules tied to a name you cannot change easily.
Table entries persist only as long as pf is loaded unless you define them in pf.conf. For dynamic blocklists, write a cron job that runs pfctl -t bruteforce -T add to add addresses, and reference the table in your block rule.
# /etc/pf.conf - macros and tables section
ext_if = "vio0"
int_if = "vio1"
lo_if = "lo0"
admin_ips = "{ 203.0.113.10, 203.0.113.11 }"
web_ports = "{ 80, 443 }"
ssh_port = "2222"
table persist
table const { 203.0.113.0/24 }
table const { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 }
The Default Block and Essential Pass Rules
Start with a default deny-all policy using block return, which sends a TCP RST or ICMP unreachable to the sender rather than silently dropping packets. Silent drops (block drop) make debugging harder and can stall connections for 30+ seconds on the client side. Use block drop only on untrusted external interfaces to prevent information leakage.
After the default block, pass loopback traffic unconditionally. Then pass your management access with quick so it is never accidentally blocked by later rules.
For state tracking, pf uses keep state by default on pass rules since OpenBSD 4.1. You can specify modulate state for TCP to randomize ISNs, or synproxy state to protect against SYN floods. On a public-facing server, use synproxy state on your web port rules.
ICMP requires explicit rules. We pass icmp type echoreq (ping) from trusted sources only, and pass icmp6 type echorep, neighbradv, neighbrsol unconditionally since IPv6 neighbor discovery requires it.
# Core filter rules
# Default policy
block return log all
# Loopback - always pass
pass quick on $lo_if all
# Block spoofed RFC1918 on external interface
block drop in quick on $ext_if from
block drop out quick on $ext_if to
# Block known bad actors
block drop in quick on $ext_if from
# Admin SSH - pass quick, no logging to reduce noise
pass in quick on $ext_if inet proto tcp \
from to ($ext_if) port $ssh_port \
flags S/SA modulate state
# Web services - synproxy for SYN flood protection
pass in quick on $ext_if inet proto tcp \
to ($ext_if) port $web_ports \
flags S/SA synproxy state
# Allow established outbound
pass out quick on $ext_if proto tcp modulate state
pass out quick on $ext_if proto { udp, icmp } keep state
# ICMP echo from trusted only
pass in quick on $ext_if inet proto icmp \
from icmp-type echoreq keep state
Stateful NAT for an Internal Network
pf handles NAT with a match rule using nat-to, not a separate nat keyword (which was removed in OpenBSD 4.7). The match rule rewrites the source address but does not pass the packet - your filter rules still apply afterward. Combine match nat-to with pass rules for the internal interface.
For a router or gateway with a dynamic external IP, use ($ext_if) with parentheses so pf re-evaluates the interface address dynamically. This is essential on DHCP uplinks.
Port forwarding uses match rdr-to. If you want a single rule that both translates and passes, use pass in on ... rdr-to which combines both actions. We tested this on OpenBSD 7.5 with a dual-homed VM and confirmed that the combined syntax eliminates a common pitfall where packets were translated but then hit the default block rule.
# NAT for internal network 192.168.1.0/24
# Outbound NAT - rewrite source to external IP
match out on $ext_if from nat-to ($ext_if)
# Allow internal hosts to reach internet
pass in on $int_if from 192.168.1.0/24 to any keep state
pass out on $ext_if from 192.168.1.0/24 to any keep state
# Port forward: external 443 to internal web server
pass in on $ext_if inet proto tcp \
to ($ext_if) port 443 \
rdr-to 192.168.1.100 port 443 \
flags S/SA synproxy state
# Port forward: external 2222 to internal SSH server
pass in on $ext_if inet proto tcp \
to ($ext_if) port $ssh_port \
rdr-to 192.168.1.50 port 22 \
flags S/SA modulate state
Rate Limiting and Brute Force Protection
pf implements connection rate limiting with the (max-src-conn, max-src-conn-rate, overload) syntax on pass rules. When a source exceeds the rate, pf adds it to the overload table you specify. A separate rule blocks that table. Add a pfctl cron to expire old entries so the table does not grow unbounded.
In our testing, max-src-conn-rate 10/5 (10 connections per 5 seconds) stops most SSH brute force tools while allowing legitimate users who reconnect frequently. Adjust for your use case - a deployment server might hit 20 connections in 5 seconds legitimately.
For web rate limiting, the numbers are higher. We use max-src-conn 100, max-src-conn-rate 50/5 for HTTP/HTTPS. This catches simple scrapers but does not interfere with CDN edge nodes that multiplex connections. If you run automated deployment pipelines that hit API endpoints, tools like taskbotshub.ai can help you orchestrate those workflows in ways that stay within rate limits by spreading requests or using connection pooling.
The overload table is shared between SSH and web rules in the example below. You can use separate tables if you want different expiry windows.
# Rate limiting with automatic blocklist population
# SSH with brute force protection
pass in quick on $ext_if inet proto tcp \
to ($ext_if) port $ssh_port \
flags S/SA modulate state \
(max-src-conn 5, max-src-conn-rate 5/30, \
overload flush global)
# Web with rate limiting
pass in quick on $ext_if inet proto tcp \
to ($ext_if) port $web_ports \
flags S/SA synproxy state \
(max-src-conn 100, max-src-conn-rate 50/5, \
overload flush global)
# Expire bruteforce table entries older than 24 hours
# Add to cron: 0 * * * * pfctl -t bruteforce -T expire 86400
Anchors for Modular Rulesets
Anchors let you load sub-rulesets into named namespaces inside the main ruleset. This is useful for managing rules for multiple services independently, for reloadable rule sets without touching the main pf.conf, and for software like relayd and ospfd that inject their own rules into pf anchors.
Define an anchor in pf.conf with the anchor keyword, then load rules into it with pfctl -a anchorname -f rulefile. Rules inside an anchor follow the same last-match semantics unless you use quick inside the anchor.
We use anchors on servers running relayd for layer 7 load balancing. relayd writes its redirect rules into the relayd anchor automatically. If you define that anchor in pf.conf and forget to start relayd, the anchor is empty and traffic falls through to the main ruleset - this is safer than having relayd rules inlined where a relayd restart could leave stale rules.
Anchors can be nested. pfctl -a 'parent/child' -f rulefile loads into a nested anchor. Inspect anchor contents with pfctl -a anchorname -s rules.
# In /etc/pf.conf - define anchors
anchor "relayd/*"
anchor "custom/blocklist"
anchor "custom/services"
# Load rules into custom anchors at boot
# Add to /etc/rc.local or a startup script:
# pfctl -a custom/blocklist -f /etc/pf.blocklist.conf
# pfctl -a custom/services -f /etc/pf.services.conf
# Inspect anchor contents
doas pfctl -a custom/services -s rules
# Flush and reload a single anchor without touching main ruleset
doas pfctl -a custom/blocklist -F rules
doas pfctl -a custom/blocklist -f /etc/pf.blocklist.conf
# List all anchors currently loaded
doas pfctl -s Anchors
Logging, Debugging, and Real-Time Monitoring
pf logging writes to /dev/pflog0, a dedicated pseudo-interface. tcpdump reads it directly. Add log to any rule to capture matching packets. The log (all) modifier logs both inbound and outbound directions of the matched flow.
For persistent logging, pflogd writes binary pcap files to /var/log/pflog. Parse them with tcpdump -n -e -ttt -r /var/log/pflog. The -e flag shows the pf action (pass/block), the rule number, and the interface.
Real-time packet viewing during debugging: run tcpdump -n -e -i pflog0 in one terminal while making test connections. This is faster than reading logs after the fact and shows you exactly which rule is matching.
pfctl -s state shows the state table. On a busy server this can be thousands of entries. Filter with pfctl -s state | grep 192.168.1.100 to find states for a specific host. State table size is controlled by set limit states in pf.conf - the default on OpenBSD 7.5 is 100,000 states, which is sufficient for most servers but should be raised to 500,000 or higher on NAT gateways handling many clients.
pfctl -s info shows global statistics including state table size, packets passed and blocked, and memory usage.
# Real-time log monitoring
doas tcpdump -n -e -i pflog0
# Parse saved log with timestamps and actions
doas tcpdump -n -e -ttt -r /var/log/pflog
# Filter log for specific host
doas tcpdump -n -e -r /var/log/pflog host 203.0.113.50
# Show state table - sorted by bytes transferred
doas pfctl -s state | sort -k8 -rn | head -20
# Global pf statistics
doas pfctl -s info
# Show table contents
doas pfctl -t bruteforce -T show
# Manually add and remove table entries
doas pfctl -t bruteforce -T add 203.0.113.99
doas pfctl -t bruteforce -T delete 203.0.113.99
# Increase state limit in pf.conf for NAT gateways
# set limit states 500000
# set limit frags 50000
Scrub Rules and Traffic Normalization
Scrub rules normalize incoming packets before filter rules see them. They reassemble fragments, enforce minimum TTLs, strip IP options, and randomize IP IDs. On OpenBSD 7.5, match in all scrub (no-df random-id reassemble tcp) is the recommended catch-all scrub rule.
no-df clears the do-not-fragment bit, preventing fragmentation-based evasion. random-id randomizes the IP ID field, which stops OS fingerprinting tools that rely on predictable ID sequences. reassemble tcp handles out-of-order TCP segments before your rules see them.
For VoIP traffic running RTP over UDP, fragmentation reassembly can cause problems because RTP is time-sensitive. Use match in on $ext_if proto udp scrub (no-df) to apply lighter normalization to UDP while keeping full scrubbing for TCP.
Scrub rules run before filter rules in the pf processing pipeline. A packet that fails scrub is silently dropped - you will not see it in filter rule logs. If you see traffic that should be passing but is not, add log to your scrub rule to check.
# /etc/pf.conf - scrub section (before filter rules)
# Full normalization for all traffic
match in all scrub (no-df random-id reassemble tcp)
# Lighter scrub for UDP (VoIP, DNS, gaming)
match in on $ext_if proto udp scrub (no-df)
# Log scrub drops for debugging
match in all log scrub (no-df random-id reassemble tcp)
# Verify scrub is active
doas pfctl -s rules | grep scrub
Complete Production pf.conf Template
The following is a complete pf.conf we use as a starting point for single-homed public servers - a VPS or dedicated server with one external interface and no NAT requirements. It includes all the patterns discussed above: macros, tables, scrub, default block, admin access, web services, rate limiting, and logging.
Load it with doas pfctl -nf /etc/pf.conf to test, then doas pfctl -f /etc/pf.conf to apply. On OpenBSD, pf is controlled by the pf_enable variable in /etc/rc.conf.local. Set pf=YES there to ensure it starts at boot with your ruleset.
After loading, verify with pfctl -s rules that the ruleset loaded as expected. Check pfctl -s info for the packet counters to confirm traffic is matching the right rules. If a service is unexpectedly blocked, check pfctl -s state for its connection state and run tcpdump on pflog0 to see which rule is blocking it.
# /etc/pf.conf - production single-homed server template
# Tested on OpenBSD 7.5
# --- Options ---
set block-policy return
set loginterface egress
set skip on lo0
set limit states 200000
# --- Macros ---
ext_if = "vio0"
ssh_port = "2222"
web_ports = "{ 80, 443 }"
# --- Tables ---
table persist
table const { 203.0.113.0/24 }
table const { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 }
# --- Scrub ---
match in all scrub (no-df random-id reassemble tcp)
# --- Anchors ---
anchor "relayd/*"
# --- Default block ---
block return log all
# --- Anti-spoofing ---
block drop in quick on $ext_if from
block drop in quick on $ext_if from
# --- Admin access ---
pass in quick on $ext_if inet proto tcp \
from to ($ext_if) port $ssh_port \
flags S/SA modulate state
# --- Web services ---
pass in quick on $ext_if inet proto tcp \
to ($ext_if) port $web_ports \
flags S/SA synproxy state \
(max-src-conn 100, max-src-conn-rate 50/5, \
overload flush global)
# --- ICMP ---
pass in quick on $ext_if inet proto icmp from icmp-type echoreq keep state
pass out quick on $ext_if inet proto icmp keep state
pass quick on $ext_if inet6 proto icmp6 keep state
# --- Outbound ---
pass out quick on $ext_if proto tcp modulate state
pass out quick on $ext_if proto udp keep state