How pf Rule Evaluation Works
pf reads rules top to bottom but applies the last matching rule, not the first. This is the single biggest conceptual shift from iptables, where the first match wins. The practical consequence: put your broad block rules early and your specific pass rules later. The final matching rule is what executes.
There are three rule types that matter most: filter rules (pass, block), translation rules (nat, rdr, binat), and options (set). Translation rules are evaluated before filter rules in the packet path, which is why NAT configuration does not interfere with your filter logic. The quick keyword breaks this last-match evaluation and acts like iptables ACCEPT-and-stop: when a rule with quick matches, processing stops immediately.
Rule syntax follows a consistent pattern: action direction [log] [quick] on interface [af] [proto protocol] from src [port] to dst [port] [flags] [state]. Knowing this template lets you read any pf rule without looking up documentation every time.
# Verify your ruleset parses before loading it
pfctl -nf /etc/pf.conf
# Load the ruleset
pfctl -f /etc/pf.conf
# Enable pf if not already running
pfctl -e
# Show current rules
pfctl -sr
# Show NAT rules
pfctl -sn
# Show state table
pfctl -ss
Macros and Tables: The Foundation of Maintainable Rulesets
Hardcoding IP addresses and interface names directly in rules creates configuration that breaks the moment you change hardware or reassign addresses. Macros solve this for single values; tables solve it for sets of addresses.
A macro is defined with name = value and referenced with $name. Tables are declared with the table keyword and can hold thousands of IP prefixes with negligible performance cost because pf uses a radix tree for table lookups internally.
Tables can be loaded from files, which is useful when you maintain blocklists or allowlists externally. The persist flag keeps an empty table loaded even if no rules reference it. The const flag makes a table immutable at runtime. Without either flag, tables can be manipulated live with pfctl -t.
For a practical blocklist setup, you can populate a table from a file and then reference it in a single block rule. This keeps your filter ruleset clean while allowing the blocklist to grow independently.
# /etc/pf.conf - macros and tables section
ext_if = "em0"
int_if = "em1"
int_net = "192.168.10.0/24"
ssh_port = "22"
dns_servers = "{ 9.9.9.9, 149.112.112.112 }"
# Table of known bad actors (loaded from file)
table persist
table persist file "/etc/pf.blocklist.txt"
# Table of trusted admin IPs
table { 203.0.113.10, 203.0.113.20 }
# Manipulate tables at runtime without reloading ruleset
# pfctl -t bruteforce -T add 198.51.100.55
# pfctl -t bruteforce -T show
# pfctl -t blocklist -T replace -f /etc/pf.blocklist.txt
Building the Base Ruleset
Start every ruleset with a default deny. Block all traffic in both directions on all interfaces as the first policy statement. Then add specific pass rules for what you want to allow. This approach means a typo results in traffic being dropped rather than accidentally permitted.
The set skip on lo0 directive tells pf to ignore the loopback interface entirely. Without it, you would need explicit pass rules for loopback traffic, and some daemons behave oddly when pf inspects loopback packets.
For inbound SSH, restrict it to the admins table defined earlier. Use the synproxy state option on public services to absorb SYN floods without exposing backend state: pf completes the TCP handshake itself before passing the connection through. For high-volume web services, max-src-conn and max-src-conn-rate provide connection limiting per source IP without external tooling.
The flags S/SA on TCP rules restricts matching to packets with only the SYN flag set (not ACK), which means the rule only triggers on new connection attempts, not established traffic. This reduces unnecessary state table lookups for existing connections.
# /etc/pf.conf - complete base ruleset
ext_if = "em0"
int_if = "em1"
int_net = "192.168.10.0/24"
table persist
table persist file "/etc/pf.blocklist.txt"
table { 203.0.113.10, 203.0.113.20 }
# Options
set skip on lo0
set block-policy drop
set loginterface em0
set optimization aggressive
set state-policy if-bound
# Scrub incoming packets
match in all scrub (no-df max-mss 1440)
# Default deny
block all
# Block known bad actors immediately with quick
block quick from
block quick from
# Allow loopback (belt and suspenders)
pass quick on lo0 all
# Allow established state from inside out
pass out on $ext_if from ($ext_if) modulate state
# Pass LAN traffic out with NAT
pass out on $ext_if from $int_net to any nat-to ($ext_if) modulate state
# ICMP - allow ping and path MTU discovery
pass in on $ext_if inet proto icmp icmp-type { echoreq, unreach, timex } keep state
# SSH - admins only, rate limit, add bruteforce table on excess
pass in on $ext_if proto tcp from to ($ext_if) port 22 flags S/SA keep state
# HTTP and HTTPS
pass in on $ext_if proto tcp to ($ext_if) port { 80, 443 } flags S/SA \n synproxy state (max-src-conn 100, max-src-conn-rate 15/5, \n overload flush global)
# Internal LAN to internet
pass in on $int_if from $int_net to any keep state
NAT and Port Forwarding
On OpenBSD 7.x, NAT is configured with match rules rather than the older nat-on syntax from pre-4.7 pf. The nat-to keyword in a match rule handles outbound masquerading. For port forwarding, rdr-to in a match rule redirects inbound traffic to an internal host.
One detail that catches people: the match rule for NAT must appear before the filter pass rule that allows the same traffic, and both must match the same traffic criteria. If your pass rule is more specific than your match rule, some packets will pass without being translated.
For a server acting as a gateway for a LAN segment, you also need IP forwarding enabled at the kernel level. On OpenBSD this is a sysctl value, and it must be set in /etc/sysctl.conf to survive reboots.
Bidirectional NAT (binat-to) maps a specific internal host to a specific external IP one-to-one, which is useful when you need an internal server to appear as a fixed external IP for outbound connections while also being reachable inbound on that IP.
# Enable IP forwarding (add to /etc/sysctl.conf)
net.inet.ip.forwarding=1
# Apply immediately
sysctl net.inet.ip.forwarding=1
# /etc/pf.conf - NAT and port forwarding
# Outbound NAT for LAN
match out on $ext_if from $int_net to any nat-to ($ext_if)
# Port forward: external port 2222 -> internal SSH at 192.168.10.5:22
match in on $ext_if proto tcp to ($ext_if) port 2222 rdr-to 192.168.10.5 port 22
# Port forward: web server behind NAT
match in on $ext_if proto tcp to ($ext_if) port { 80, 443 } rdr-to 192.168.10.10
# Binat: 203.0.113.50 always maps to 192.168.10.20
match out on $ext_if from 192.168.10.20 binat-to 203.0.113.50
match in on $ext_if to 203.0.113.50 binat-to 192.168.10.20
# Verify NAT rules loaded correctly
# pfctl -sn
Logging, pflog, and tcpdump
pf logs to the pflog0 interface, not to a file directly. You capture that interface with tcpdump or tcpdump-compatible tools. OpenBSD includes pflogd, which writes binary tcpdump format captures to /var/log/pflog. Enable it in /etc/rc.conf.local and it starts automatically.
The log keyword on a rule sends matching packets to pflog0. The log (all) variant logs both the initial packet and state-established packets. For debugging, log (all, to pflog1) lets you route specific rule logs to a secondary pflog interface without mixing them into the main pflogd capture.
Reading pflog captures requires tcpdump with the -e flag to show pf-specific fields like the rule number, direction, and interface. The -r flag reads from a file rather than live capture. To correlate rule numbers in tcpdump output with your ruleset, use pfctl -sr -vv to see rules with their assigned numbers.
For structured log shipping to a SIEM or log aggregation system, pflog2syslog or a custom script reading from pflogd output is the standard approach on OpenBSD. Teams using automation pipelines sometimes integrate this into their alerting stack, and platforms like taskbotshub.ai can ingest structured firewall events as triggers for automated remediation workflows.
# Enable pflogd in /etc/rc.conf.local
pflogd_flags="-s 256"
# Start pflogd manually
pflogd
# Live capture on pflog0 - show all logged packets
tcpdump -n -e -i pflog0
# Read from pflogd file
tcpdump -n -e -r /var/log/pflog
# Filter pflog capture to blocked packets only
tcpdump -n -e -r /var/log/pflog action block
# Show rules with numbers for correlation
pfctl -sr -vv
# Add log keyword to specific rules in pf.conf
# block log quick from
# pass log (all) in on $ext_if proto tcp to ($ext_if) port 443 flags S/SA keep state
# Rotate pflog manually
kill -ALRM $(cat /var/run/pflogd.pid)
Anchors for Modular Rulesets
An anchor is a named sub-ruleset that pf evaluates when it hits an anchor rule in the main ruleset. Anchors let you load, replace, and flush subsets of your firewall rules without touching the main ruleset. This is how relayd, authpf, and pf-based VPN solutions inject rules dynamically.
authpf is a shell replacement that installs per-user anchor rules when an SSH session authenticates and removes them when the session ends. It is the standard OpenBSD mechanism for authenticated gateway access: a user SSHes to the firewall, authpf loads their rules, they get LAN access, they disconnect, rules vanish.
For your own modular rulesets, define an anchor in pf.conf and load rules into it with pfctl -a. This is useful in provisioning scripts and configuration management: the main ruleset stays static while individual service or tenant rulesets are managed independently.
# /etc/pf.conf - define anchors
# authpf anchor for authenticated users
anchor "authpf/*"
# Custom application anchor
anchor "services"
# Load rules into the services anchor from a separate file
pfctl -a services -f /etc/pf.services.conf
# Show rules in a specific anchor
pfctl -a services -sr
# Flush rules from an anchor without touching main ruleset
pfctl -a services -F rules
# /etc/pf.services.conf - example content
# pass in on $ext_if proto tcp to ($ext_if) port 8080 flags S/SA keep state
# pass in on $ext_if proto tcp to ($ext_if) port 9090 flags S/SA keep state
# authpf setup: user's shell in /etc/passwd must be /usr/sbin/authpf
# Per-user rules go in /etc/authpf/users//pf.conf
# System-wide authpf rules go in /etc/authpf/authpf.conf
Traffic Shaping with HFSC and Queues
pf on OpenBSD 7.6 uses the HFSC scheduler for traffic shaping. HFSC provides hierarchical bandwidth allocation with both real-time guarantees and link-sharing. The configuration defines a queue hierarchy attached to an interface, then filter rules assign traffic to queues.
The practical use case for most sysadmins: prevent bulk transfers from starving interactive traffic. SSH and DNS get a small queue with a real-time guarantee, HTTP gets the bulk of bandwidth, and a default queue catches everything else. Even if the link is saturated with a large file transfer, SSH sessions remain responsive because HFSC services the ssh queue on its real-time schedule.
Queue statistics are visible with pfctl -sq, which shows bytes, packets, and drop counts per queue. Drops in a queue indicate you have sized it too small or are exceeding the parent's allocation.
# /etc/pf.conf - HFSC queue configuration
# Assumes em0 is a 1Gbps uplink
queue root on $ext_if bandwidth 1G
queue main parent root bandwidth 950M default
queue interactive parent main bandwidth 100M min 10M
queue bulk parent main bandwidth 800M
queue dns parent main bandwidth 10M min 5M
queue catchall parent main bandwidth 40M
# Assign traffic to queues in filter rules
# SSH gets interactive queue
pass in on $ext_if proto tcp to ($ext_if) port 22 flags S/SA keep state queue interactive
# DNS gets its own guaranteed queue
pass in on $ext_if proto udp to ($ext_if) port 53 keep state queue dns
# Web traffic goes to bulk
pass in on $ext_if proto tcp to ($ext_if) port { 80, 443 } flags S/SA keep state queue bulk
# Everything else
pass in on $ext_if keep state queue catchall
# Monitor queue statistics
# pfctl -sq
# pfctl -sq -v (verbose, shows drop counts)
State Table Tuning and Performance
The default state table limit on OpenBSD 7.6 is 100,000 entries. For high-traffic gateways handling tens of thousands of concurrent connections, this runs out quickly and causes new connections to fail silently. Check current state count with pfctl -si and look at the States line.
State table memory is allocated from the kernel pool at boot time based on the limit setting. Increasing the limit costs memory: each state entry uses approximately 1.5 KB, so 1 million states requires roughly 1.5 GB of kernel memory. Set it proportionally to your expected concurrent connection count with headroom.
The set optimization directive accepts three values: normal (default), high-latency (longer timeouts, useful for satellite links), and aggressive (shorter timeouts, reclaims state faster, better for DDoS scenarios). On busy internet-facing systems, aggressive shortens TCP FIN and RST timeouts aggressively, which helps under SYN flood conditions.
For systems running pf at scale, the set state-policy if-bound directive restricts state matching to the interface where the state was created. This prevents state table spoofing attacks and is recommended over the default any-interface matching on multi-homed systems.
# Check current state table usage
pfctl -si | grep -E 'States|searches|inserts|removals'
# Check memory usage
pfctl -sm
# /etc/pf.conf - state table and performance options
set limit states 500000
set limit frags 25000
set limit src-nodes 100000
set optimization aggressive
set state-policy if-bound
# Source tracking - limit connections per source IP
# Add to individual pass rules:
# keep state (max-src-states 50, max-src-conn 100, max-src-conn-rate 20/10)
# Expire states faster for specific protocols
# Use state options on pass rules:
# keep state (tcp.established 3600, tcp.finwait 10)
# Monitor state table live
watch -n 2 'pfctl -si | grep States'
# Flush all states (use with caution - drops all connections)
# pfctl -F states
Automating pf Rule Management
Reloading pf.conf on a remote server carries a specific risk: a syntax error in your new ruleset will fail to load, leaving the old rules in place. That is the safe case. The unsafe case is accidentally loading a ruleset that blocks your SSH session before you can roll back. The standard mitigation is a timed revert: schedule a cron job to restore the known-good ruleset in 5 minutes, then cancel the job if the new rules work correctly.
For teams managing multiple OpenBSD firewalls, pf rule deployment benefits from the same idempotent approach used for other infrastructure. A simple deployment script can diff the running ruleset against the desired state before applying changes. Groups using CI/CD pipelines for infrastructure changes sometimes integrate pf.conf validation into their pre-deployment checks.
If you are running OpenBSD gateways as part of a larger automated infrastructure, naming conventions for anchors and interface groups matter for readability and tooling compatibility. The same discipline applies to any infrastructure project: clear, consistent names reduce operational errors. If you are also registering domain names or project identities for a new infrastructure tool or internal platform, services like nicename.me can help identify available, meaningful names before committing to one.
For automated remediation, the overload mechanism built into pf handles IP-level responses to abuse. The pfctl -t table -T add command is scriptable, so your monitoring system can feed addresses directly into pf tables without a full ruleset reload.
#!/bin/sh
# /usr/local/sbin/pf-deploy.sh
# Safe pf ruleset deployment with timed revert
NEW_CONF="/etc/pf.conf.new"
GOOD_CONF="/etc/pf.conf"
REVERT_MINS=5
# Validate new ruleset first
if ! pfctl -nf "$NEW_CONF"; then
echo "Ruleset validation failed. Aborting." >&2
exit 1
fi
# Schedule revert in case we lose connectivity
echo "pfctl -f $GOOD_CONF" | at now + ${REVERT_MINS} minutes
JOB=$(atq | tail -1 | awk '{print $1}')
echo "Revert job scheduled: $JOB. You have ${REVERT_MINS} minutes."
# Apply new ruleset
cp "$NEW_CONF" "$GOOD_CONF"
pfctl -f "$GOOD_CONF"
echo "New ruleset loaded. Cancel revert job if connectivity is confirmed:"
echo " atrm $JOB"