On macOS, a kill switch is only as good as the packet filter policy behind it, and pf is the piece that enforces that policy. The policy has to do two opposite things at once: let the client reach its server so a tunnel can exist, and stop everything else from leaving directly. Most published rulesets manage the second and forget the first, so the machine loses connectivity instead of gaining protection. The version that works names the endpoint, the resolver and the tunnel, and nothing more.

What a packet filter can decide, and what it cannot

pf looks at interfaces, addresses, protocol numbers and ports, and it stops there. It never learns which process opened a socket, so a permission written for one program becomes a permission for every program that can reach the same port. That is how a generous UDP exception turns a kill switch back into an ordinary network. Application-aware blocking belongs to the client or to a system extension, not to this file.

The filter does not create routes either. When the client drops, the default route may still point at a dead tunnel, and pf converts that into blocked packets rather than a silent fallback to Wi-Fi. The block is the intended result, not a malfunction to route around.

Ordering decides the outcome more often than the rule text does. Without quick, pf keeps evaluating after a match and the last matching rule wins, so a broad pass near the bottom overrides the careful exceptions above it. Marking the passes quick makes the first match final, which is what a policy author usually means.

Read the interface and endpoint facts first

Wireless interfaces are not numbered the same way on every Mac, and the tunnel device gets a number that can change between boots. A rule that names a device that does not exist fails at parse time, which is loud and easy to catch, while a rule naming the wrong existing device parses cleanly and permits traffic you meant to stop.

Collect the facts while the client is connected, because the tunnel device only exists then. You need the interface that holds the default route, the device the client created, and the address and port of the server it dials. That last value is the one most rulesets omit, and omitting it is why a copied ruleset blocks its own tunnel.

route -n get default
ifconfig -l
scutil --nc list

The first command names the interface that currently wins, the second lists every device the kernel knows about, and the third shows the VPN configurations the system can start. Write those answers down, then read the server address and port from the client's own configuration.

Write the ruleset around the tunnel path

The file below denies by default and re-admits five things: loopback, the tunnel device, the client's path to its server, the resolver used before the tunnel exists, and the two exchanges that keep a wireless link usable. Interface names and the server address come from your machine, so substitute the values you confirmed. The tunnel number will not match yours either.

set skip on lo0
block all
pass out quick on utun0
# maintain this line when the server address changes
pass out quick on en1 inet proto udp to 203.0.113.10 port 1194
# only the resolver the client uses before the tunnel exists
pass out quick on en1 inet proto udp to 192.0.2.53 port 53
pass out quick on en1 inet proto udp from any port 68 to any port 67
pass out quick on en1 inet proto udp to 224.0.0.251 port 5353

The DHCP line exists because a Mac asks for an address before it has one, so the request leaves port 68 toward port 67 with no usable source address. The multicast line lets the system judge whether the wireless link is worth using, and removing it produces a machine that reports no connection on a working network. The endpoint line is what lets OpenVPN reach its server at all; without it, block all drops the outer handshake and the tunnel never comes up. The resolver line is narrower than a general DNS permission, because it names one address and one port rather than every query the machine makes.

Those endpoint and resolver exceptions are the two that must exist; everything else stays blocked. If the client dials a literal address, drop the resolver line and keep the endpoint line. If the server address changes, update that line before the next connection, or fill a pf table from the client's own hook and match the table instead.

Take the tunnel down, then reboot

Parse before loading, because a syntax error is cheap in a dry run and expensive in a remote session. Then load the file, enable the filter if it was off, and read back what is actually in force. The readback is not ceremony: another anchor can add rules you did not write, so compare the printed set with your file.

sudo pfctl -n -v -f /etc/pf.conf
sudo pfctl -f /etc/pf.conf
sudo pfctl -e
sudo pfctl -s rules
sudo pfctl -s Anchors
sudo pfctl -a <anchor> -s rules
sudo pfctl -s info

With the rules loaded, drop the tunnel and try to load a page. Ordinary traffic should stop, and it should still be stopped after the machine sleeps and wakes, since that transition is where stale state creeps back. Reconnect and confirm traffic resumes without touching the ruleset. If a browser still works with the tunnel down, a rule is broader than you thought, and the readback usually names it.

The reboot is the part people skip. macOS reads /etc/pf.conf at boot, but reading the file is not the same as enabling the filter, so a ruleset that works now can be inactive after a restart. Run sudo pfctl -s info afterwards and confirm the status line reports the filter as enabled.

Keep the exit and the restart path honest

A ruleset that blocks everything can also lock you out of a machine you administer remotely. Edit from a local session when you can, keep the previous file beside the new one, and know the revert before you need it. The commands below disable the filter and reload the copy you kept, which is the sequence to run from a console rather than over the tunnel you just cut. A five-second revert beats a clever rule every time.

sudo pfctl -d
sudo pfctl -f /etc/pf.conf.previous
sudo pfctl -s rules

Persistence takes a launch daemon that runs pfctl -e and loads your file at boot. Treat it like any other privileged job: absolute paths, a log you can read afterwards, and a test on a machine you can physically reach. A daemon that fails silently leaves the machine unprotected and looking normal.

Document the gaps, starting with a second VPN client that creates its own tunnel device. Your rules name one tunnel interface, so a second one stays blocked or gets matched by a broad rule added later. Tethering and hotspot interfaces appear only sometimes and deserve the same note. None of those gaps is a reason to skip the policy; they are the conditions under which it still holds.

Mikanda mikatakata