You installed a beta of some app. It’s slow, the UI’s weird, and Activity Monitor shows it making outbound network calls every two seconds — telemetry probably, or analytics, or who-knows-what. You’d uninstall it but you actually need the app for one specific job. You’d block it with Little Snitch but the family-pack license is $79. You’d block it via the system firewall but macOS’s GUI firewall doesn’t really do per-app outbound rules.
The thing macOS does have, hidden under the hood, is pf — the same packet filter that powers OpenBSD’s firewall. It’s been in macOS since Lion (2011), it’s powerful enough for any per-app rule you’d want, and it’s already installed and ready. The only catch is that Apple deprecated the user-facing /etc/pf.conf editing flow, so you have to know the right places to put your rules. Here’s the smallest possible setup to block one specific app’s outbound traffic and leave the rest of the system alone.
Step 1 — write the rules
Save this as /etc/pf.anchors/me.block-app (the directory exists by default; the filename is up to you):
# /etc/pf.anchors/me.block-app
# Block outbound traffic from a specific UNIX user that we'll create for the app.
block out proto { tcp udp } from any to any user "appsandbox"The trick is in the user match. pf can filter by the UID that owns the socket. So we don’t try to identify “this app” by binary name (which apps can hide) — we identify it by the UNIX user the app runs as.
Step 2 — make a sandbox user for the app
macOS’s user creation is annoying because useradd doesn’t exist; you use dscl:
# find a free UID — convention: 600+ for sandbox users
sudo dscl . -create /Users/appsandbox
sudo dscl . -create /Users/appsandbox UserShell /usr/bin/false
sudo dscl . -create /Users/appsandbox RealName "App Sandbox"
sudo dscl . -create /Users/appsandbox UniqueID 601
sudo dscl . -create /Users/appsandbox PrimaryGroupID 20
sudo dscl . -create /Users/appsandbox NFSHomeDirectory /var/emptyNow you have a real UNIX user that pf can match against, but the user has no shell, no home directory, and no password — it can only be used by something running as it via sudo -u.
Step 3 — wire the anchor into the main pf config
Apple ships a stub /etc/pf.conf that you should not edit directly. Instead, add a new file under /etc/pf.anchors/ (you already did, in step 1), and tell pf to load it via the system loader.
Create /Library/LaunchDaemons/me.pf.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>me.pf</string>
<key>ProgramArguments</key>
<array>
<string>/sbin/pfctl</string>
<string>-e</string>
<string>-f</string>
<string>/etc/pf.anchors/me.block-app</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardErrorPath</key>
<string>/var/log/me.pf.log</string>
</dict>
</plist>sudo chown root:wheel /Library/LaunchDaemons/me.pf.plist
sudo chmod 644 /Library/LaunchDaemons/me.pf.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/me.pf.plistNow on every boot, the daemon runs pfctl -e -f /etc/pf.anchors/me.block-app, which enables pf and loads the rule.
Step 4 — run the app as that user
Most GUI apps can be launched under a different user with sudo -u — but you’ll need to also pass the open arguments correctly:
# launch the app as the sandboxed user
sudo -u appsandbox /Applications/SuspectApp.app/Contents/MacOS/SuspectApp &Test it: open a terminal as appsandbox and try a network request:
sudo -u appsandbox curl -sS https://example.com
# expected: curl: (7) Failed to connect to example.com — blocked!
# from your normal user account, the same request works fine:
curl -sS https://example.com
# <HTML response>Step 5 — verify the rule loaded
sudo pfctl -s rules
# expected: block drop out proto tcp from any to any user = 601
# block drop out proto udp from any to any user = 601
sudo pfctl -s info
# expected: Status: Enabled
# packet counter — increments every time the rule blocks a packet
sudo pfctl -s rules -vIf pfctl -s info says Disabled, the LaunchDaemon didn’t run; check /var/log/me.pf.log for syntax errors.
Variations on the theme
- Block by destination instead. Replace the rule with
block out proto tcp to telemetry.suspectvendor.com. pf supports DNS names; they’re resolved when the rule is loaded (not on every packet, so DNS-rotation tactics still work — but for known-static endpoints this is fine). - Allow only specific destinations. Default-deny then explicit allow:
block out from any to any user "appsandbox"pass out proto tcp from any to api.legitvendor.com user "appsandbox" - Log instead of block. Replace
blockwithpass log. Thentcpdump -nei pflog0shows the packets going by — useful for figuring out what an app is actually contacting before deciding what to allow. - Per-port blocks for the whole system. If you want to block, say, all outbound SMTP from your laptop because no app should ever send mail directly:
block out proto tcp to any port 25. No user clause; the rule applies system-wide.
Why this is better than Little Snitch for some cases
- It’s not a per-connection prompt nag. The rule is set, the rule applies. No popups while you’re working.
- It’s free, native, and doesn’t require a kernel extension. pf has been in the kernel since Lion. It’s not going anywhere.
- It survives upgrades cleanly. Files under
/etc/pf.anchors/and/Library/LaunchDaemons/persist through macOS upgrades. Your rules don’t disappear when you update the OS. - It works at the network layer, not the user-prompt layer. Apps cannot present a “please grant me network access” dialog to bypass pf — the kernel just drops the packets.
What pf doesn’t replace: per-domain rules with on-the-fly prompts (Little Snitch’s strength), VPN-aware policy, or family-friendly app categorization. For surgical “this one app, no outbound” rules, pf is the right tool, and the entire setup fits in three small files.
Photo: Devices wrapped in chain — symbol of restricted access by Pixabay on Pexels.
