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, 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 and in a gist 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 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 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 "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:
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:
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
}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, and for an iCloud Private Relay address, 172.224.236.1 from the list Apple publishes:
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).
Private Relay doesn't reach the app's check. Apple's guide for network operators 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? 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, Fastify, Next.js, Django, FastAPI, Flask, Rails, and PHP and WordPress.


