Skip to content
macOSHow-To Published Updated 5 min readViews unavailable

Writing Custom Packet Filter (pf) Firewall Rules on macOS

Using the same BSD packet filter that powers macOS's Application Firewall directly, for 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.

Where pf itself actually comes from

pf (Packet Filter) originated in OpenBSD, first shipping in OpenBSD 3.0 in December 2001, written specifically as a modern, cleaner replacement after a licensing dispute forced OpenBSD to drop the IPFilter firewall it had relied on previously. Its design emphasized a clear, readable rule syntax and tight integration with OpenBSD’s networking stack — qualities that led other BSD-family systems, including FreeBSD and macOS’s own Darwin-based networking stack, to adopt ported versions of it rather than building separate, independent packet-filtering implementations from scratch. This shared BSD lineage is exactly why pf rule syntax on macOS looks and behaves recognizably similar to pf on FreeBSD or OpenBSD, even though each platform’s specific integration and default configuration around it differs.

Testing a new ruleset without committing to it first

sudo pfctl -n -f /etc/pf.anchors/com.example.custom

The -n flag parses and validates a ruleset file without actually loading it into the running configuration — a dry-run check that catches syntax errors before they’d otherwise fail during a real load attempt, or worse, partially apply. Running this validation step before every real pfctl -f load, especially after hand-editing a rules file, catches typos and syntax mistakes in a zero-risk way rather than discovering them only after attempting to apply a broken ruleset to your actual running firewall.

Combining pf rules with macOS’s own Application Firewall safely

Since pf and the Application Firewall operate at different layers — pf at the network/transport level, the Application Firewall at the per-process level — the two can run simultaneously without directly conflicting, each independently capable of blocking traffic the other would otherwise allow. This layering is generally a net security benefit (an attacker needs to get past both independently), but it’s worth remembering when troubleshooting connectivity: a connection blocked by pf rules can look identical, from the affected application’s perspective, to one blocked by the Application Firewall, so checking both layers rather than assuming the more visible GUI toggle is the only thing in play saves real troubleshooting time.

Related:

Sources:

Comments