# Detect a VPN in an iOS app: the interface check vs the IP check

Interface names like utun0 can't tell you a phone is on a VPN. Check where the connection comes from in Swift instead, with no API key in the app.

Search for how to detect a VPN in an iOS app, and most answers that rank check the names of the phone's network interfaces. That asks whether this phone is running a VPN, and Apple's developer support says no documented meaning goes with those names. The question a streaming service, a game or a bank is asking is a different one: does this connection come from a VPN? The address the connection comes from answers that, and [`VPNDetection`](https://docs.vpndetection.io/integrations/swift), our Swift package, looks it up. We ran its code against the live API on October 12, 2026, with VPNDetection 4.5.1 and Swift 6.3.3 on Linux, and every output below is from those runs. It's the same package an app adds for iOS 16 and later. The interface check only builds for Apple's platforms, so what it does on an iPhone is quoted from Apple's developer forums.

## The interface check

The answers on [Stack Overflow](https://stackoverflow.com/questions/16285093/how-to-detect-iphones-vpn-connectivity) and in [a gist](https://gist.github.com/m1guelpf/aaa27bde09584b1c5a9ac236de1c8964) read the `__SCOPED__` dictionary that `CFNetworkCopySystemProxySettings()` returns, which is keyed by interface name, and call the connection a VPN when a name is or contains `tap`, `tun`, `ppp` or `ipsec`. One Stack Overflow answer adds `utun1` and `utun2`. [Talsec's article](https://docs.talsec.app/appsec-articles/articles/how-to-detect-vpn-using-swift) recommends an SDK that monitors for VPN interfaces.

Apple doesn't document what an interface name means. A developer whose check for `utun0` and `utun1` failed asked on Apple's forums, and Quinn of Apple's Developer Technical Support [answered](https://developer.apple.com/forums/thread/81150) that "there's no documented mapping between iOS interface names and their user-level functionality". `utun` interfaces also exist with no VPN on. Asked whether a Mac can have more than one with no VPN running, another Apple DTS engineer [answered](https://developer.apple.com/forums/thread/671678) "Yes, there can be."

The gist's comment says it leaves `utun` out for that reason. It matches with `key.contains("tun")`, though, and `"utun0".contains("tun")` is `true`.

Where the check does work, it only sees a tunnel on this phone. A VPN running on the Wi-Fi router the phone has joined never shows up in its interfaces.

## The IP check

`myIP()` asks our API about the address your app's request came from. When the phone's traffic goes through a VPN, that's the VPN server's address. When it doesn't, it's the carrier's or the Wi-Fi network's. In Xcode, add the package under File > Add Package Dependencies, or in `Package.swift`:

```swift
dependencies: [
    .package(
        url: "https://github.com/vpndetection-io/sdk-swift.git",
        from: "4.5.1"
    ),
],
```

and `.product(name: "VPNDetection", package: "sdk-swift")` in your target's dependencies. The check is one call:

```swift
import VPNDetection

let vpnCheck = VPNDetectionClient()

/// Whether this connection comes from a VPN, or nil when the check can't run:
/// no network, or the day's free lookups are spent.
func connectionIsVPN() async -> Bool? {
    try? await vpnCheck.myIP().isVpn
}
```

```swift
switch await connectionIsVPN() {
case true?: print("This connection comes from a VPN.")
case false?: print("No VPN on this connection.")
case nil: print("Couldn't check.")
}
```

From one of our servers, it printed `No VPN on this connection.`

No key goes in the app. Without one, the API answers `ip` and `is_vpn`, and allows 1,000 lookups a day per source address. A key built into an app can be read by anyone who downloads it, so keep yours on your server. On cellular, carriers often put many phones behind one shared IPv4 address, and those phones share its 1,000 lookups. Once they're spent, `connectionIsVPN()` returns `nil`.

`myIP()` gets the answer `lookup()` gives for that address. Here's what `lookup()` answers for a Mullvad server, `193.32.248.66` from [Mullvad's own server list](https://mullvad.net/en/servers), and for an iCloud Private Relay address, `172.224.236.1` from [the list Apple publishes](https://mask-api.icloud.com/egress-ip-ranges.csv):

```swift
import Foundation
import VPNDetection

let env = ProcessInfo.processInfo.environment
let client = env["VPNDETECTION_API_KEY"].map { VPNDetectionClient(apiKey: $0) }
    ?? VPNDetectionClient()

for ip in ["193.32.248.66", "172.224.236.1"] {
    let result = try await client.lookup(ip)
    print(ip, "isVpn:", result.isVpn, "isRelay:", result.isRelay as Any)
}
```

Without a key:

```
193.32.248.66 isVpn: true isRelay: nil
172.224.236.1 isVpn: false isRelay: nil
```

With a key on the Max plan:

```
193.32.248.66 isVpn: true isRelay: Optional(false)
172.224.236.1 isVpn: false isRelay: Optional(true)
```

`nil` means the field isn't in your plan, not that the answer is no. `is_vpn` comes back without a key, a free key adds the Tor and relay flags, `location` and `asn`, and every paid plan answers every field ([pricing](https://vpndetection.io/pricing)).

Private Relay doesn't reach the app's check. Apple's [guide for network operators](https://developer.apple.com/support/prepare-your-network-for-icloud-private-relay/) says it protects "web browsing in Safari, DNS resolution queries, and insecure http app traffic", so `myIP()`, over HTTPS, sees the phone's own address. Your website sees the relay address when the same person visits in Safari, and [Should you block iCloud Private Relay?](https://vpndetection.io/blog/block-icloud-private-relay) covers what to do with it.

## Decide on your server

Both checks run on a phone you don't control. On a jailbroken phone, or in a patched copy of your app, `connectionIsVPN()` returns whatever its owner likes, and so does an interface check. Use the app's answer for the experience, such as telling someone their VPN will stop a stream before they press play. Make the decisions that cost you money, a signup, a purchase or a region's catalog, on your server: look up the address the request to your server came from, with your key kept there.

Each framework post does that lookup: [Express](https://vpndetection.io/blog/detect-vpn-users-express-js), [Fastify](https://vpndetection.io/blog/detect-vpn-users-fastify), [Next.js](https://vpndetection.io/blog/detect-vpn-users-nextjs), [Django](https://vpndetection.io/blog/detect-vpn-users-django), [FastAPI](https://vpndetection.io/blog/detect-vpn-users-fastapi), [Flask](https://vpndetection.io/blog/detect-vpn-users-flask), [Rails](https://vpndetection.io/blog/detect-vpn-users-rails), and [PHP and WordPress](https://vpndetection.io/blog/detect-vpn-users-php-wordpress).

