OpenBSD PF Firewall: Stateful Security Without Complexity
OpenBSD PF (Packet Filter) is a modern firewall for admins who want to understand what their system does. This guide explains stateful filtering, threat detection, and centralized observability—without unnecessary complexity.
Why OpenBSD?
If you're a Linux admin, you know iptables, firewalld, nftables.
They work, but OpenBSD PF (Packet Filter) takes a different approach:
- Design philosophy: "Deny by default, allow by exception"—the entire OS is security-focused, not just one subsystem
- Simple syntax: PF rules are readable and maintainable, even with complex policies
- Integrated monitoring: Observability is built-in, not bolted on later
- Minimal attack surface: OpenBSD strips down standard services to what's necessary
- Firmware + Kernel: Regular security patches (OpenBSD is famous for security)
You want a firewall that's simple to understand, requires minimal maintenance, and where security isn't optional. Not: "Let me stack iptables rules to the sky."
PF Firewall Basics
What is PF?
PF is a kernel-based firewall system. Unlike Linux iptables, which run through netfilter,
PF is implemented directly in the OpenBSD kernel and optimized for it.
Core features:
- Stateful inspection: PF remembers connections. Return traffic is automatically allowed
- Network Address Translation (NAT): Masquerade internal IPs to the outside
- Source tracking: Track IPs that open many connections (port scanners, brute force)
- Rate limiting: Throttle or block certain traffic patterns
- Antispoof: Automatically filter spoofed packets (reverse path check)
PF vs. Linux iptables/nftables
| Aspect | PF (OpenBSD) | Linux iptables |
|---|---|---|
| Location in stack | Kernel-integrated | Kernel subsystem (netfilter) |
| Syntax | Declarative, top-to-bottom | Imperative, rule-by-rule |
| Default policy | Explicit deny | Depends on config |
| State table | Central, auto-optimized | Via conntrack module |
| Reload | Atomic (rules + state table) | Sometimes needs flush/reload |
Architecture: Stateful Filtering
How stateful filtering works
Unlike stateless firewalls that inspect each packet individually, a stateful firewall remembers context:
Practical benefit: You only write "allow internal hosts to reach the outside"—return traffic is automatically clear.
Example ruleset (conceptual)
# Default: Block everything
set block-policy drop
# Define internal interface
int_if = "re0"
ext_if = "pppoe0"
# Allow: loopback, internal traffic
pass on lo0
pass on $int_if proto tcp from any to any port 22
# Allow: Outbound, return automatically
pass out on $ext_if proto tcp from any to any
# Rate limiting: Too many connections from one source
pass in on $ext_if proto tcp to any port 22 \
(max-src-conn 10, max-src-conn-rate 5/60)
# Source tracking: Block IPs doing port scans
pass in on $ext_if proto tcp flags SYN/SYN,ACK
table <bruteforce> persist
block in quick from <bruteforce>
Note: Real production rules are longer and more granular.
Threat Detection & Monitoring
OpenBSD firewall alone isn't enough
PF filters layers 3/4 (IP/TCP/UDP). But:
- Malicious payloads in layer 7 (application) are invisible
- Zero-day HTTP exploits come through legitimate port 80
- Brute force attacks are only visible through rate limiting
Solution: Add threat detection (IDS) + centralized logging.
The three layers
| Layer | Tool | Detects |
|---|---|---|
| 3/4 (IP/TCP) | OpenBSD PF | Port scans, SYN floods, rate anomalies |
| 7 (Application) | IDS (Suricata/Snort) | HTTP payloads, SQL injection, exploits |
| Everywhere | Centralized logging (Syslog) | Correlation, time series, anomalies |
Monitoring setup (simplified)
What to monitor:
- New connections per minute (detect anomalies)
- Top blocked IPs (botnet C2?)
- Slow brute force (SSH attempts over hours)
- IDS alert trends (exploit attempts)
- Rule reload errors (config problems)
Verification: Logs arrive centrally, no manual SSH to router needed. Everything is queryable.
Practical Overview
Hardware
- Processor: 4 cores, 2+ GHz (suitable for stateful inspection)
- RAM: 4–8 GB (state table + logs in memory)
- Storage: SSD preferred (fast rule access)
- Network: 2+ Ethernet interfaces (WAN, LAN)
Compact hardware (e.g., mini-PCs, old workstations) work perfectly. You don't need an enterprise appliance.
Routing and filtering flow
Simplified:
Typical problems
- Rule order: PF evaluates top-to-bottom. Blocking rules first, then exceptions.
- State table limits: With many connections (e.g., DDoS), state table can fill. Set limits and monitor.
- NAT hairpinning: Internal hosts can't reach router's external IPs internally. Fix with RDR rule.
- Asymmetric routing: If return traffic comes via different path, PF may block it. Policy-based routing needed.
Best Practices
1. Least privilege
- Default: Block everything
- Explicit allow: Only what's needed
- Regular review: Delete old allow rules
2. Monitoring in the design
- Set up syslog from the start (don't think about it later)
- Export SNMP metrics
- Grafana dashboard for daily operations
3. Testing before deployment
- Practice rules in a test VM (VirtualBox with OpenBSD)
pfctl -n -f rules.confchecks syntax without loadingpfctl -srshows currently loaded rules
4. Documentation
- Every rule needs a comment: Why is it there?
- Example:
# Allow SSH from admin subnet - Maintenance in 6 months will thank you
5. Incremental complexity
- Phase 1: Basic filtering + NAT
- Phase 2: Rate limiting + source tracking
- Phase 3: IDS integration
- Phase 4: Centralized monitoring + alerting
Not everything at once. Each phase should run stably before the next begins.
Summary
- OpenBSD PF: Kernel-based, simple to understand, security-first
- Stateful filtering: Remembers connections, reduces rule complexity
- Threat detection: PF (L3/4) + IDS (L7) + centralized logging
- Monitoring: Not optional—central observability is part of the design
- Hardware: Compact, cheap, but monitoring requires attention