Ask an iOS app whether the phone is on a VPN and the honest answer is that it cannot know in the way you mean. The SDK exposes no flag for "device is tunnelled", and the private interfaces that might approximate one are closed to shipping apps. You can observe your app's own configuration, the state attached to it, and one or two indirect hints from the network stack. Each answers a different question, and most broken checks come from treating one of them as the whole answer.

The myth of a single VPN flag

The belief is that somewhere in the SDK there is a property that reports whether the device is tunnelled, in the way a reachability flag reports whether a host answers. No such public property exists. Even if it did, "a tunnel exists" and "my traffic uses it" are different claims, and only the second one matters to most apps.

That gap is why libraries advertising a one-line VPN detector tend to be reading something fragile, such as an interface name or a proxy key, and presenting it as a verdict.

Load the preferences before you trust a nil

The shared manager instance exists as soon as you ask for it, but its protocol property stays nil until the load callback completes. A nil protocol therefore means "not loaded yet" at least as often as it means "not installed". Do the reading inside the completion block, and only then decide: a protocol object that exists, with the enabled flag on, is what installed looks like.

A second signal sits on the connection object: the invalid state, which the system reports when this app has no configuration installed. Invalid status together with a nil protocol after a completed load is a genuine absence, not a timing race.

Connection changes arrive as notifications

Handlers passed to load and save report what happened to the configuration, not what the tunnel is doing. Waiting for one of them to fire at connect time is a race that never resolves. Watch the status-change notification instead, and read the connection object's current value when it arrives.

Once a configuration is installed, the connection state walks a short sequence: disconnected, connecting, connected, reasserting, disconnecting. Connected describes the tunnel and not the server behind it, so it is not evidence that traffic flows. On-demand can also bring a tunnel up while the app is suspended, and then the notification lands before you have anything to update.

The scoped proxy snapshot is a hint, not a verdict

CFNetwork can hand the app the current system proxy settings, and inside that result is a scoped dictionary keyed by interface name. Seeing a name that begins with tun, utun, ppp, ipsec or tap is a hint that a tunnel is present. It remains a snapshot of what this app can see, not a statement about where packets travel.

Providers disagree about naming, so a missing prefix proves nothing, and a present interface can exist while carrying no traffic. Reachability is weaker still: a successful request to a private address shows the path worked for that request at that moment, not that a VPN is installed.

The simulator borrows the Mac, and other apps stay invisible

The simulator has no network stack of its own; every connection it makes is the Mac's. A VPN running on the host Mac is therefore already in effect for the simulated app, and the simulator's own settings show nothing. If the tunnel came up after the simulator started, restarting it is usually faster than debugging the app. A host on a nonstandard port needs that port in the URL, or a local forward such as socat TCP-LISTEN:12345,fork TCP:host:12345.

Public APIs will not enumerate VPN profiles that belong to other apps or to configuration profiles. For one known app, a URL scheme probe through canOpenURL is the portable trick, and since iOS 9 the queried scheme has to be declared in your Info.plist; leave it out and the call returns false. OpenVPN Connect responds on openvpn://, which makes a decent example and a poor general detector.

Combine three observations and say what they mean

Stack three results in practice: a configuration that loaded and is enabled, a connection state that is not invalid, and a request to an address reachable only through the tunnel. Any one of them misleads alone; together they state what the app can know rather than what the user assumes.

Write the result as three lines in your diagnostics, not one boolean. When a user reports that the VPN is off while your app disagrees, the three lines show which observation diverged, and that is the difference between a support ticket you can act on and one that ends in "works for me".

Tënë sô zo