Writing Custom Packet Filter (pf) Firewall Rules on macOS
Using the same BSD packet filter that powers macOS's Application Firewall directly, for network-layer filtering the GUI's simple allow/deny-per-app model can't express.
macOS’s built-in Application Firewall (the simple on/off, per-application toggle in System Settings) is actually a thin, simplified front-end over pf, the same BSD packet filter FreeBSD uses — and pf is directly accessible and configurable on macOS for anyone who needs filtering rules more expressive than “allow or block this specific application.”
Why reach for pf directly instead of the GUI firewall
The Application Firewall’s model — allow or block a named application from accepting incoming connections — can’t express rules based on source IP address, specific ports independent of which application is listening, or protocol-level distinctions. pf operates at the network and transport layer directly, giving you the same kind of fine-grained, address-and-port-based filtering available on any BSD or Linux firewall, entirely independent of which application happens to be involved.
Where macOS’s pf configuration lives
/etc/pf.conf
This is Apple’s own base configuration, referenced by the system’s built-in firewall management. Rather than editing this file directly (which risks being overwritten by a system update), the correct approach is adding a separate anchor file and referencing it from the main configuration.
Setting up a custom anchor
# /etc/pf.anchors/com.example.custom
block in on en0 from 192.168.1.50 to any
pass in on en0 proto tcp from 192.168.1.0/24 to any port 22
This anchor blocks all incoming traffic from a specific address while explicitly allowing SSH from the rest of the local subnet — the kind of address-scoped, port-specific rule the GUI Application Firewall has no way to express at all.
Loading the anchor into the running configuration
sudo pfctl -a com.example.custom -f /etc/pf.anchors/com.example.custom
This loads the anchor’s rules into the specified anchor point without needing to modify or reload the entire main pf.conf, keeping your custom rules cleanly isolated from Apple’s own base configuration.
Enabling pf itself
Confirm pf is actually active, since having rules loaded into an anchor doesn’t matter if packet filtering itself isn’t enabled:
sudo pfctl -e
Verifying active rules
sudo pfctl -a com.example.custom -s rules
This shows exactly which rules are currently active in your specific anchor, which is the reliable way to confirm your configuration actually loaded and took effect as written, rather than assuming based on the load command completing without an error.
Watching pf’s live packet-filtering activity
sudo tcpdump -i pflog0
macOS creates a pflog0 pseudo-interface specifically for logging packets matched by rules with a log keyword — adding log to a specific rule (block log in on en0 from 192.168.1.50 to any) and watching this interface confirms in real time exactly which packets are actually matching that rule, rather than only inferring it indirectly from connection behavior.
Why custom pf rules don’t survive a macOS update untouched
Apple’s own pf.conf and the broader Application Firewall mechanism can be modified by system updates, which is exactly why keeping custom rules in a separate anchor file rather than editing the main configuration directly matters — an anchor file under /etc/pf.anchors/ is your own file, not something a macOS update is likely to overwrite, whereas directly-edited changes to /etc/pf.conf itself risk being reverted the next time Apple updates that file as part of a system update.
Making custom rules persist across reboots
pfctl rules loaded manually via the command line don’t persist across a restart by default. A launch daemon that re-applies your anchor at boot makes the configuration durable:
<!-- /Library/LaunchDaemons/com.example.pfconfig.plist -->
<key>ProgramArguments</key>
<array>
<string>/sbin/pfctl</string>
<string>-a</string>
<string>com.example.custom</string>
<string>-f</string>
<string>/etc/pf.anchors/com.example.custom</string>
</array>
<key>RunAtLoad</key>
<true/>
This ensures your custom filtering rules are reapplied automatically on every boot, rather than only being active until the next restart if applied manually via pfctl alone.
When this level of control is actually worth the added complexity
For most users, the GUI Application Firewall’s simpler per-application model is genuinely sufficient. Direct pf configuration is worth the added complexity specifically when you need address-scoped or port-specific rules the GUI simply can’t express — restricting a specific service to only a trusted subnet, for instance — rather than as a general replacement for the simpler default firewall for everyday use.