All articles
kill-switch
2026-08-0610 min read

macOS VPN kill switch with pfctl

Build a macOS VPN kill switch with pfctl and an anchor file. Covers the utun interface, IPv6, LAN exceptions, and how to load the rules on boot.

A macOS VPN kill switch is a Packet Filter ruleset, loaded into an anchor, that blocks everything not leaving on the tunnel interface. macOS ships pf and ships pfctl to drive it, so nothing needs installing. What macOS does not ship is a switch in System Settings, which is why this ends up on the command line. This page covers the anchor file, IPv6, a LAN exception, and the utun interface problem that breaks most copied rulesets. It also covers how to get the rules back off a laptop that will not go online.

What Packet Filter has to block

The model is four rules, and pf expresses each of them directly.

  • Block all outbound traffic by default, so anything not explicitly passed is dropped
  • Pass everything on the tunnel interface, which is a utun device on macOS
  • Pass the VPN server address, port and protocol, so the tunnel can establish through the block
  • Pass loopback, and one named LAN subnet if you want printing and file shares to keep working

pf evaluates every rule and the last match wins, unless a rule carries quick, which stops evaluation there. That single behaviour explains most broken rulesets found online. Put the blanket block first, mark every exception quick, and the ruleset reads top to bottom the way you expect.

Read this before running anything. These rules cut outbound network access on the machine. Load them from a Mac you are sitting at, not over SSH or Screen Sharing. The session you would use to fix a mistake is the session the rules drop.

Find your utun interface

macOS does not give your VPN a stable interface name. It hands out utun0, utun1, utun2 and upward in the order interfaces are created, and several Apple services create their own. macOS creates utun devices for purposes of its own as well as for your VPN, so the lowest number is usually not the one you want. Do not choose an interface by its number. Identify yours by the address it carries.

Connect the tunnel first, then identify it. Two commands narrow it down quickly:

ifconfig -a | grep -i POINTOPOINT
ifconfig | grep -A3 utun

Match the interface whose inet address is the address on the Address line of your WireGuard config, or the address your .ovpn profile was assigned. That is your tunnel. Under the WireGuard app, sudo wg show also names the interface directly.

Hardcoding utun0 is the mistake that makes a working ruleset stop working after a reboot. You will find rulesets that write on utun with no number, on the theory that pf reads the bare name as a group covering every utun device. Do not adopt that without testing it on your own machine. Note what it costs even when it works, because it passes traffic on Apple's tunnel interfaces alongside yours. Prefer the exact name and re-check it after every reboot.

Building the macOS VPN kill switch with pfctl

Writing the anchor file

Put your rules in your own anchor file rather than editing Apple's. Create /etc/pf.anchors/killswitch with sudo nano /etc/pf.anchors/killswitch and write:

vpn_if     = "utun4"
vpn_server = "203.0.113.10"
vpn_port   = "51820"
lan_net    = "192.168.1.0/24"

set skip on lo0

block drop out all
block drop in all

pass out quick on $vpn_if all
pass in  quick on $vpn_if all

pass out quick proto udp to $vpn_server port $vpn_port
pass in  quick proto udp from $vpn_server port $vpn_port

pass out quick to $lan_net
pass in  quick from $lan_net

Replace all four macro values. For an OpenVPN profile, read the address, port and protocol from the remote and proto lines, and change proto udp to proto tcp if the profile connects over TCP. The rest of the file does not change between protocols.

set skip on lo0 exempts loopback entirely, which is cheaper and safer than writing pass rules for it. Leave it in.

Loading the anchor

macOS reads /etc/pf.conf at load time, and that file has a required order: options, then normalization, then queueing, then translation, then filtering. Add two lines to it, each in the right group. The anchor declaration goes with the other filtering anchors, and the load line goes with the other load line.

anchor "killswitch"
load anchor "killswitch" from "/etc/pf.anchors/killswitch"

Place anchor "killswitch" immediately after the existing anchor "com.apple/*" line, and the load anchor line immediately after the existing load anchor "com.apple" line. Putting either one earlier in the file produces a syntax error that names the wrong line, which wastes a lot of time.

Check the syntax before loading anything:

sudo pfctl -vnf /etc/pf.conf

-n parses without loading, so a mistake costs you an error message rather than your connection. When it parses cleanly, load and enable:

sudo pfctl -f /etc/pf.conf
sudo pfctl -e

pfctl -e enables pf for this boot only. The pfctl manual page macOS ships documents -E as enabling the packet filter and incrementing the pf enable reference count. It documents -X as releasing the reference represented by the token you pass back to it. That pair exists for tools that have to co-operate with other users of pf. Use -e for a kill switch you drive by hand.

When the parse fails

pfctl -vnf reports two errors far more often than any other, and both name a line that is not the line at fault.

The first is placement. /etc/pf.conf requires its statements in one order: options, then normalization, then queueing, then translation, then filtering. Put anchor "killswitch" above the scrub or translation anchors and pfctl rejects the file, usually pointing at the next line rather than at yours. Move the declaration down to sit with the other filtering anchors and re-check.

The second is the set skip on lo0 line inside the anchor file. set is an option, and options belong to the main ruleset rather than to an anchor. Published macOS rulesets carry it inside the anchor and report it working, so leave it there and test. If pfctl -vnf names that line, delete it from the anchor and put it in /etc/pf.conf instead, above the anchor declaration and with the other options. Loopback is then skipped globally, which is the same outcome.

Check after every edit rather than at the end. A file that parses is a file you can load without losing the network, and -n is the difference between an error message and a walk to another machine.

Checking what is loaded

Never assume the rules took. Read them back:

sudo pfctl -s info
sudo pfctl -s rules
sudo pfctl -a killswitch -s rules

-s info prints Status: Enabled when pf is running. -s rules prints the main ruleset, where you should see the anchor referenced rather than your rules. -a killswitch -s rules prints the rules inside your anchor, which is where your block and pass lines actually live. If the third command prints nothing, the anchor loaded empty and the kill switch is not doing anything.

The full command set is short enough to keep on one card.

CommandWhat it doesWhen you need it
sudo pfctl -vnf /etc/pf.confParses the config without loading itEvery single time, before -f
sudo pfctl -f /etc/pf.confLoads the ruleset, replacing what is loadedAfter any edit to the anchor file
sudo pfctl -e / sudo pfctl -dEnables and disables pf for this boot-d is the command that gets you back online
sudo pfctl -s infoPrints Status: Enabled or DisabledWhen rules are loaded but nothing is blocked
sudo pfctl -a killswitch -s rulesPrints the rules inside your anchorTo confirm the anchor is not empty
sudo pfctl -a killswitch -F rulesFlushes the rules inside your anchor onlyTo remove the kill switch without touching Apple's rules

Every flag in that table is documented in the pfctl manual page macOS ships, which man pfctl opens. The anchor rules are not, because they are yours. Parse them with -vnf after every edit, and read them back with -a killswitch -s rules after every load.

IPv6

The block drop out all line blocks IPv6 along with IPv4, because pf without an address family qualifier matches both. That is the correct default and it is why the ruleset above needs no separate IPv6 block.

The exceptions are where it goes wrong. pass out quick to $lan_net matches an IPv4 subnet only, and pass out quick proto udp to $vpn_server matches whichever family the server address belongs to. If your VPN server has an IPv6 endpoint, or your LAN uses IPv6 for local discovery, those need their own pass lines with the correct addresses.

Several published macOS rulesets solve this with a blanket block out inet6 all instead. That works, and it also kills IPv6 inside the tunnel. If your config carries ::/0 in AllowedIPs, or your provider assigns an IPv6 address, a blanket block silently removes something you are paying for. Filter it rather than disabling it.

LAN exceptions

The two $lan_net lines are what keep AirPrint, a NAS and your router admin page reachable while the tunnel is down. Nothing leaks without them, so this is a usability decision made deliberately.

Scope it to the subnet your router actually hands out. Read it from System Settings, or run ipconfig getifaddr en0 and take the first three octets. A /24 is almost always right. Copying 192.168.0.0/16 from another guide opens every private address on every network the laptop ever joins, and a laptop joins a lot of networks.

AirPrint and Bonjour discovery also use multicast, which the blanket block drops. Confirm that is what you are hitting before writing a rule for it. Run sudo pfctl -d, retry the printer, then run sudo pfctl -e again. If discovery works with pf disabled and fails with it enabled, what you need is a multicast pass rule rather than a wider subnet.

Making it survive a reboot

This is the section no incumbent has, and there are two separate problems inside it.

The first is that pf is not enabled at boot. macOS loads /etc/pf.conf during startup but does not turn pf on, so your rules are present and inert until you run pfctl -e. Fixing that means adding a LaunchDaemon of your own in /Library/LaunchDaemons that runs pfctl -e -f /etc/pf.conf at load. The launchctl subcommands for registering a daemon have changed across macOS releases, so read man launchctl on the machine you are configuring rather than copying an invocation from a guide. Then reboot and confirm with sudo pfctl -s info that pf came up enabled.

The second is that /etc/pf.conf is an operating system file. A macOS update can replace it, which silently removes your two lines while leaving /etc/pf.anchors/killswitch untouched. The kill switch then looks installed and does nothing. That is the reason for keeping your rules in a separate anchor file rather than pasting them into pf.conf: after an update you re-add two lines instead of rewriting a ruleset.

There is a third quirk worth knowing before you build tooling around this. A bug report on the Apple Developer Forums states that from Ventura onward, pfctl retains anchors after flushing rules, states and tables. The report adds that the only way to remove the leftover anchors is to reboot. Check your own machine with sudo pfctl -s Anchors after a flush rather than assuming it either way. If the anchor is still listed, reboot before you conclude that something is broken.

Turning it off

Include this in your notes, because a stuck packet filter on a laptop is worse than no packet filter at all. One command restores connectivity:

sudo pfctl -d

That disables pf entirely and takes effect immediately. Do that first, diagnose afterwards. To remove the rules rather than disable the filter, flush the anchor and reload:

sudo pfctl -a killswitch -F rules
sudo pfctl -f /etc/pf.conf

To remove the kill switch permanently, delete the two lines you added to /etc/pf.conf, delete /etc/pf.anchors/killswitch, unload your LaunchDaemon, and reload with sudo pfctl -f /etc/pf.conf. Reboot afterwards, given the anchor behaviour noted above.

Confirming it blocks

Connect, confirm browsing works, drop the tunnel, confirm traffic stops. That is a sanity check and it is where every competing page ends.

It tests one application, in the foreground, after the risky interval has closed. It also cannot tell you whether the thing that leaked was an address or a hostname, and those are different failures with different fixes. The full method uses a packet capture on the physical interface, exercises name resolution separately from raw addresses, and drops the tunnel several different ways. It is written up in how to test if a VPN kill switch works.

Quick answers

Does macOS have a built-in VPN kill switch
Not as a system setting. The firewall in System Settings filters inbound connections only and has no concept of a tunnel. Packet Filter is the built-in mechanism, but it ships with no rules and no interface beyond pfctl. The WireGuard app is not where to look either. Open the tunnel's settings and search for an option that blocks untunneled traffic, because the wording varies by build. If there is nothing there, the pf ruleset on this page is your mechanism.
Tunnelblick kill switch
Tunnelblick has one built in, and most pages ranking for this term do not say so. Tunnelblick's own documentation places it on the Settings tab of the Configurations panel in VPN Details, where the On unexpected disconnect option can be set to Disable network access. Read the rest of that documentation before you rely on it. Tunnelblick states that all network access is disabled, including access to your local network. It also states that access stays disabled until you re-enable it, even after you restart the computer, and that a Re-enable Network Access command appears in the menu for exactly that. Tunnelblick warns against using the feature while more than one VPN is connected. The pf ruleset on this page is for everything Tunnelblick does not manage.
Will these rules survive a macOS update
The anchor file will. The two lines in /etc/pf.conf may not, because that file belongs to the operating system and an update can replace it. Check after every major update by running sudo pfctl -a killswitch -s rules. Empty output means the anchor is no longer being loaded, and the fix is to re-add the two lines rather than to rebuild anything.
How to disable vpn kill switch
Run sudo pfctl -d. That disables Packet Filter immediately and restores network access, without deleting any of your rules. To remove the rules as well, flush the anchor with sudo pfctl -a killswitch -F rules, then reload with sudo pfctl -f /etc/pf.conf. To remove it permanently, delete the two lines from /etc/pf.conf and unload your LaunchDaemon.
Does vpn affect local network
Yes, once this ruleset is loaded. block drop out all blocks your LAN alongside the internet, so AirPrint, network shares and the router admin page stop responding until you add the subnet exception. Multicast discovery is blocked separately from unicast traffic, so a printer can be reachable by address while remaining invisible in a browse list. For where a kill switch fits across operating systems, start at the kill switch setup guide. The client-side steps live in installing WireGuard and using an OpenVPN profile. Keep what a kill switch does and does not do alongside this page, because a kill switch does not fix bad DNS settings by itself. If the tunnel will not come up at all, that is connection troubleshooting rather than a filter problem.
Put it into practice

A 24-hour PulseVPN trial takes about a minute to set up and asks for no card, no email, and no name.

Start free trial

Related reading