The VPN connects, the menu bar shows it, and an internal host still times out. In that situation there are three candidate causes, and which one applies depends on how the tunnel was configured. Either the route command used a host address where a network address was required, or the profile itself installs a default route that swallows the decision, or the client is a PPP tunnel that needs a hook rather than a profile key. Check them in that order, because the first one is the cheapest to rule out.

Destination is a network address, mask describes the prefix

networksetup -setadditionalroutes takes triples of destination, subnet mask and gateway. A call that sends one internal subnet through the tunnel looks like this:

sudo networksetup -setadditionalroutes "VPN (L2TP)" 192.168.1.0 255.255.255.0 10.2.0.0

Note what the destination is: the network address, not the address of the server you are trying to reach. In the case that produced this article, the identical command with 192.168.1.20 in that position did nothing useful, and replacing it with 192.168.1.0 worked immediately. Same mask, same gateway, one different number. That is the entire failure in miniature.

The reason is arithmetic rather than policy. A host address paired with a /24 mask does not describe 192.168.1.0/24; it describes something the routing table cannot usefully hold, so the entry is rejected or ignored and the tunnel ends up carrying less than you intended.

When you want exactly one host, use the host address with a mask of 255.255.255.255. Mixed pairs are where this goes wrong, not the use of a host address as such. A /32 built from one address is a legitimate route; a /24 built from one address is a mistake.

The call replaces the list, and the name is literal

Two behaviours surprise people on the first attempt. The command sets the complete additional route list rather than appending, so a call that adds one prefix silently removes the others that were there before. Build the whole set in a single invocation.

The service name is also not the label you invented. Read it from the tool and copy it exactly, including the type suffix:

networksetup -listallnetworkservices

A name that differs by one character produces no error worth reading and no route. Copy and paste is the correct technique here, not retyping from memory.

When the profile claims the default route

Split routes lose to a default route for any destination they do not explicitly cover, and an L2TP profile configured for full tunnel installs exactly that. If the profile says everything goes through the tunnel, the additional route list changes very little. Your entry is not wrong; it is simply irrelevant to the destinations it never names. The control for this usually sits in the profile's send-all-traffic setting rather than in any route command.

Decide which model you actually want before editing more entries. Full tunnel with exceptions is a different configuration from split tunnel with specific prefixes, and mixing the two produces a table that nobody can predict.

If the organisation requires full tunnel, stop trying to split it locally and take the requirement to whoever owns the policy. A locally added route that contradicts it will be removed by the next profile update anyway.

PPP tunnels need a hook instead of a key

For PPP-based tunnels, defaultroute is the profile key that creates a default route; drop it and the client stops claiming every destination. Routes are then added after the interface appears, typically from /etc/ppp/ip-up:

#!/bin/sh
/sbin/route add -net 192.168.1.0 -interface ppp0

Keep the script owned by root and executable, and add a guard on the interface name when more than one tunnel profile exists on the machine. A hook that runs for every PPP link and assumes one device name is a small bug with a long life.

Verify the split with three requests

Capture the table while connected and note which interface serves the tunnel. Then check the specific destination rather than trusting the exit status of the command that created it. An exit code reports whether the tool accepted the arguments, not whether traffic takes the path you intended.

Finish with three requests that prove the split: one internal host by address, one internal name through the internal resolver, and one public site that should still use the local connection. If the public site now travels through the tunnel as well, the split never happened, whatever the route list appears to say.

Reconnect once more and repeat the internal request, because routes added by hand can outlive the interface that made them valid and quietly black-hole traffic after the tunnel drops.

Nsɛm a ɛfa ho