The FireAI Security Blog

By FireAI Security & Research Team · Published

The macOS pf Firewall: What It Can Do, and Why It Can’t Be Your App Firewall

The macOS pf Firewall: What It Can Do, and Why It Can’t Be Your App Firewall

Every Mac ships with two things people call "the firewall". One is the Application Firewall in System Settings, a per-app toggle for inbound connections. The other, quieter one is pf, the BSD packet filter that macOS inherited from the FreeBSD/OpenBSD lineage and that Apple itself uses under the hood for internet sharing, VPN and NAT. Power users and administrators can talk to it directly with pfctl, and plenty of guides show a pf.conf snippet and call it a day. What those guides rarely explain is where pf stops being useful for the thing most people actually want: watching and controlling what their own apps send out. This is a lab, not a lecture — we will turn pf on, write a rule, read the state it keeps, and then look at exactly why that state is the wrong shape for an app firewall.

What pf actually is

pf is a kernel-level packet filter: it inspects packets as they cross network interfaces and decides, rule by rule, whether to pass or block them. It has no concept of "apps" — it works purely on packet headers: source and destination address, port, protocol, interface, direction. That is not a limitation someone forgot to fix; it is the design. pf was built to filter traffic at the network layer, the same layer routers and gateways operate at, and it is very good at that job.

You talk to it with pfctl, the control utility. Its man page is explicit about the split between the two things it does: it loads rulesets from a configuration file, and it reports on the state the kernel is holding. Two flags matter most for enabling and disabling the filter, in the man page’s own words: -e (“Enable the packet filter.”) and -d (“Disable the packet filter.”). Nothing else about pf is on or off — the whole ruleset moves together.

Turning it on and reading its state

A quick lab, on a Mac where you are comfortable using sudo. First, check whether the filter is already enabled and see its counters:

Terminal — pf status and counters
sudo pfctl -s info
# example output, trimmed — the real thing includes per-rule and per-source-tracking stats with -v
Status: Enabled for 0 days 02:14:07		Debug: err
State Table                          Total             Rate
  current entries                       42
  searches                           88213             9.7/s
  inserts                              611             0.1/s
  removals                             569             0.1/s

Then list the rules the kernel currently holds. pfctl -s rules does exactly this; the man page notes that with -v it also prints per-rule evaluation counts, packets and bytes:

Terminal — currently loaded rules
sudo pfctl -s rules
# example output, trimmed
scrub-anchor "com.apple/*" all fragment reassemble
anchor "com.apple/*" all
block drop in log quick from <blocklist> to any

That com.apple/* line is not decoration. Apple loads its own rules into named anchors — pf’s term for a self-contained sub-ruleset that can be swapped in and out without reloading everything else. pfctl’s -a flag targets a specific anchor, and, per the man page, using it with a wildcard enables recursive printing of nested anchors, which is how you see what Apple itself has loaded alongside anything you add:

Terminal — listing every loaded anchor recursively
sudo pfctl -a '*' -s rules
# example output, trimmed to the anchors that exist on a stock Mac

Writing and loading a test rule

A minimal anchor file that blocks one IP address outbound, saved as /etc/pf.anchors/test-block:

/etc/pf.anchors/test-block
block drop out quick on en0 proto tcp to 203.0.113.10 port 443

To load a single anchor file, pfctl -f reads rules from a file, per its man page, which describes the file as containing macros, tables, options and filtering rules:

Terminal — loading and confirming the rule
sudo pfctl -f /etc/pf.anchors/test-block
sudo pfctl -s rules
block drop out quick on en0 proto tcp from any to 203.0.113.10 port = 443

Why pf cannot be your app firewall

None of what follows is a bug. It is what happens when you point a network-layer packet filter at a job that requires knowing which process sent the packet.

It has no idea which app sent the packet

pf rules match on IP, port, protocol, interface and direction. There is no field for "process name", "bundle identifier" or "code signature", because pf sits at the layer where packets exist but processes do not. Two completely different apps opening TCP connections to the same IP and port are indistinguishable to pf. If you want to allow Slack to reach a host while blocking every other app from reaching that same host, pf alone cannot express that rule.

A rule on a hostname is a rule on whatever IP that hostname had at load time

pf.conf files often reference a hostname for readability — block from evil.example.com. What actually gets loaded is not that name; it is whatever address it resolved to. The OpenBSD pf.conf man page says this plainly: "Host name resolution and interface to address translation are done at ruleset load-time." There is no runtime DNS lookup as traffic flows — the substitution happens once, when you run pfctl -f, and the rule keeps matching that one address until you reload it. That is fine for a server with a static IP. It falls apart the moment the name behind it is a CDN, a cloud load balancer, or any service that rotates or load-balances across many addresses — which describes most of the internet in 2026. A rule meant to block "this service" quietly narrows to "whichever one of that service’s IPs happened to answer when I loaded the rule," and traffic to every other address the same hostname resolves to sails straight through.

No prompts, no conversation — just a static ruleset

pf has no interaction model. It cannot pause a connection and ask "Mail wants to reach 51.x.x.x on port 993 for the first time — allow it?" It either matches a rule you already wrote, or it falls through to the default. Every decision has to be anticipated and written down in advance, in IP-and-port terms, before the traffic happens. There is no equivalent of a first-connection prompt, because prompting requires knowing which app is asking, and pf does not have that information to begin with.

Your handwritten configuration does not survive an update

Apple treats /etc/pf.conf and the anchors it loads as system-managed configuration tied to macOS internals — internet sharing, VPN, the Application Firewall’s own anchors all depend on it. macOS updates are free to rewrite or replace that file. If you have hand-edited it to add your own rules, there is no guarantee they survive the next update; you find out the hard way, after the fact, that your rule silently stopped applying. A configuration file that a person maintains by hand and the operating system periodically overwrites is a bad place to keep the one thing you actually cared about — "did my Mac talk to that address again."

No log viewer, no history, no map

pf can log matched packets to a pseudo-interface, pflog0, if a rule includes the log keyword — visible above in the blocklist rule from the earlier -s rules output. But that log is a packet capture stream, readable with tcpdump -i pflog0, not a searchable history. There is no built-in viewer, no per-app list of what was blocked and when, no country or organization attached to an address, nothing you would show someone to answer "what did this Mac try to reach last week." You get raw packets, and you get to build the rest yourself.

The layer Apple actually built for this job

Apple’s own answer to "I want to filter my Mac’s traffic per app" is not pf — it is the Network Extension framework, specifically its content filter providers. Apple’s developer documentation describes the model directly: "An on-device network content filter examines user network content as it passes through the network stack and determines if it should block that content or allow it to pass on to its final destination," and a filter data provider — an NEFilterDataProvider — "receives user network content and examines that content to determine whether to block or allow it." Flows are represented as NEFilterFlow objects (with NEFilterBrowserFlow and NEFilterSocketFlow as concrete cases), which is the missing piece pf never had: a flow object that a filtering app can inspect and tie back to the process that opened it, before deciding pass or block.

This is also why the built-in Application Firewall (the toggle in System Settings) is a different animal from pf, not a front-end for it. Apple’s own guide describes it in inbound terms only: it "can protect your Mac from unwanted contact initiated by other computers," and it works by letting you "select apps and services, and specify whether they can have access through the firewall." Per-app, yes — but only for connections coming in, and only through the specific mechanism Apple built for that one job. It answers a different question than "what is my app sending out."

Three ways to filter traffic on a Mac, and what each one actually knows
ApproachSees IP/portKnows which appHandles renamed/rotating IPsCan prompt the userDirection
pf (pfctl)YesNoNo — resolved once at load timeNoEither, by rule
Application Firewall (System Settings)No (app-level toggle)YesN/ANoInbound only
Network Extension content filterYesYes, via the flow objectYes — evaluated per live flowYes, by the app built on itOutbound and inbound

None of this makes pf useless. If you run a Mac as a lightweight router, need to reject a known-bad range at the kernel level regardless of which process is asking, or want to understand what Apple’s own internet-sharing and VPN features are doing under the hood, pf is the right and only tool for that job, and pfctl -s rules / -s info are the right way to look at it. What it was never going to do is answer the question most people actually have: which of my apps is talking to whom, right now, and can I be asked before a new one gets to.

How FireAI and HisnLabs fit in

pf and the built-in Application Firewall are both worth using — FireAI does not replace either; it fills the specific gap neither one can, by tying outbound decisions to the app’s code signature and asking before an unknown one gets a first connection.

FireAI is HisnLabs’ own product: an on-device AI firewall for Mac. It shows every connection your apps make, in plain language, and lets you decide what leaves your Mac — its AI runs locally, so your traffic is never sent to us or anyone else. HisnLabs’ security research team is the group that keeps that decision-making accurate: cataloguing which domains are ordinary telemetry versus a real product, tracking the country and network behind a connection, and training the on-device model (its Autopilot feature) on real traffic patterns, all without any of it leaving your Mac.

You can read the technical decisions behind it, or try FireAI for 17 days, at FireAI, by HisnLabs.

Sources