You need one screen to behave differently when the tunnel is up, and there is no call that tells you it is. That is the shipping constraint: on macOS no public framework reports whether an arbitrary VPN is connected, and on iOS the status belongs to the app that installed the configuration. The checks below walk from that limit to something you can ship, and any of them can end the search early.

Check 1: is the question answerable at all?

Sandboxing settles the question before any API does. A third-party process cannot enumerate another vendor's extension, and it cannot read that extension's configuration either, so there is no supported object to ask.

Every workaround meets the same wall. Interface listings, scutil --nc list and direct system-configuration reads either require entitlements the sandbox withholds or depend on behaviour Apple never promised. If the plan needs one of those, the plan is the problem. A feature built on an undocumented detail is a support ticket with a delivery date.

Check 2: does your app manage the tunnel itself?

If it does, you have a supported answer for your own configuration. Load the preferences, handle the error, then read the status and subscribe to changes instead of polling:

vpnManager.loadFromPreferences { error in
    // handle a load failure before reading status
}

NotificationCenter.default.addObserver(
    forName: .NEVPNStatusDidChange,
    object: nil,
    queue: nil
) { note in
    let status = (note.object as? NEVPNConnection)?.status
}

Be precise about what this returns: the state of the tunnel you installed. A user running a different client will show as disconnected, and that is not a bug in your code. Decide which of those two questions your feature is really asking before you build on the answer.

Check 3: what would a heuristic actually tell you?

The widely copied approach asks the system proxy dictionary for its scoped entries and reads the keys, which happen to be interface names:

let settings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as NSDictionary?
let scoped = settings?["__SCOPED__"] as? [String: Any]
let names = scoped?.keys ?? []

A key beginning with tap, tun, ppp, ipsec or utun implies that a tunnel-scoped interface is present. Matching the whole name fails at once, because iOS 12 shipped utun1 and utun2 and some profiles surface as ipsec4, ppp0 or utun0. Compare prefixes instead.

Even so, a name identifies no owner. System features and other vendors' extensions also create utun interfaces, and one can exist while carrying none of your traffic. Read it as a hint that something tunnel-shaped is around, nothing stronger. A false positive here is cheap if you only gate one feature, and expensive if you gate the login screen.

Check 4: can you test the destination instead?

If the app only needs to know whether an internal service is reachable, test that service. A short probe with a timeout answers the question the feature actually depends on, and it keeps working when the user switches clients or the tunnel changes shape.

Reachability is an observation about your path. An interface name is an inference about something adjacent to your path, which is why the heuristic produces arguments in bug reports and the probe produces evidence. If the probe fails while the user insists the VPN is on, you now have a specific path to debug: name resolution, route or service permission.

Check 5: can the platform enforce what you need?

When the requirement is a guarantee that nothing escapes the tunnel, the profile is where it belongs, not the detector. Turning on includeAllNetworks in a full-tunnel configuration on iOS 14 or later changes platform behaviour instead of asking your app to infer it. The flag is documented in NEVPNProtocol reference.

That is a different kind of answer: enforcement instead of observation. It also only applies to profiles you control, so it does not help a consumer app watching someone else's tunnel.

Check 6: make the failure safe and observable

Switch off only the capability that needs the tunnel and leave the rest of the app usable. A detector that hides the whole interface whenever it is unsure manufactures an outage for anyone whose tunnel uses a name you never anticipated. Fall back to the conservative behaviour instead: local features keep working and only the remote call that truly needs the tunnel is disabled. That choice is a product decision, and it deserves to be written down next to the heuristic.

Log what you observed: interface names, platform version, and the result of one probe. That turns the next report from an argument into a check. Then prefer a supported declaration where you have one, because a managed configuration can carry the intent explicitly and remove most of the reason to inspect names at all.

If checks 1 through 5 left you without a supported answer, ship the probe and label the result honestly. A documented best-effort signal is a better product decision than a private API that breaks on the next release.

Related articles