```html SwiftUI: How to Build Network Extension (iOS 17+, 2026)

How to Build a Network Extension in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: NetworkExtension, NEPacketTunnelProvider, NETunnelProviderManager Updated: May 12, 2026
TL;DR

Add a Packet Tunnel Provider extension target to your project, subclass NEPacketTunnelProvider, then use NETunnelProviderManager from your container app to start, stop, and observe the tunnel. Wire the status up to SwiftUI via NEVPNStatusDidChange notifications.

// Container app — configure and connect
import NetworkExtension

func startVPN() async throws {
    let managers = try await NETunnelProviderManager.loadAllFromPreferences()
    let manager = managers.first ?? NETunnelProviderManager()
    let proto = NETunnelProviderProtocol()
    proto.providerBundleIdentifier = "com.example.app.tunnel"
    proto.serverAddress = "vpn.example.com"
    manager.protocolConfiguration = proto
    manager.localizedDescription = "My VPN"
    manager.isEnabled = true
    try await manager.saveToPreferences()
    try manager.connection.startVPNTunnel()
}

Full implementation

A Network Extension lives in two distinct binaries: the tunnel provider extension process and the container app. The provider subclasses NEPacketTunnelProvider and handles raw IP packets; the container app owns the NETunnelProviderManager lifecycle and exposes controls to the user. Below is a complete pair — the provider implementation first, followed by a SwiftUI manager view.

// ─── PacketTunnelProvider.swift (Extension target) ───────────────────────
import NetworkExtension
import os.log

private let log = Logger(subsystem: "com.example.app.tunnel", category: "provider")

final class PacketTunnelProvider: NEPacketTunnelProvider {

    private var tunnelFileDescriptor: Int32 = -1

    // 1. Called by the system when the VPN should connect
    override func startTunnel(
        options: [String: NSObject]?,
        completionHandler: @escaping (Error?) -> Void
    ) {
        log.info("startTunnel called")

        // Build the tunnel network settings
        let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "198.51.100.1")
        settings.mtu = 1400

        let ipv4 = NEIPv4Settings(addresses: ["10.8.0.2"], subnetMasks: ["255.255.255.0"])
        ipv4.includedRoutes = [NEIPv4Route.default()]
        settings.ipv4Settings = ipv4

        let dns = NEDNSSettings(servers: ["1.1.1.1", "8.8.8.8"])
        settings.dnsSettings = dns

        // Apply settings, then signal success
        setTunnelNetworkSettings(settings) { [weak self] error in
            if let error {
                completionHandler(error)
                return
            }
            self?.startReadingPackets()
            completionHandler(nil)
        }
    }

    // 2. Read packets from the virtual tun interface and forward them
    private func startReadingPackets() {
        packetFlow.readPacketObjects { packets in
            for packet in packets {
                // Forward packet.data over your real transport here
                _ = packet.data
            }
            self.startReadingPackets() // re-arm
        }
    }

    // 3. Called when the VPN should disconnect
    override func stopTunnel(
        with reason: NEProviderStopReason,
        completionHandler: @escaping () -> Void
    ) {
        log.info("stopTunnel: \(reason.rawValue)")
        completionHandler()
    }

    // 4. Handle messages sent from the container app
    override func handleAppMessage(
        _ messageData: Data,
        completionHandler: ((Data?) -> Void)?
    ) {
        let reply = "ACK".data(using: .utf8)
        completionHandler?(reply)
    }
}


// ─── VPNManager.swift (Container app) ─────────────────────────────────────
import NetworkExtension
import SwiftUI

@MainActor
@Observable
final class VPNManager {

    private(set) var status: NEVPNStatus = .invalid
    private var manager: NETunnelProviderManager?

    private let bundleID = "com.example.app.tunnel"
    private let serverAddress = "vpn.example.com"

    init() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(statusDidChange),
            name: .NEVPNStatusDidChange,
            object: nil
        )
        Task { await load() }
    }

    // Load (or create) preferences from the system keychain
    func load() async {
        do {
            let managers = try await NETunnelProviderManager.loadAllFromPreferences()
            manager = managers.first(where: {
                ($0.protocolConfiguration as? NETunnelProviderProtocol)?
                    .providerBundleIdentifier == bundleID
            }) ?? NETunnelProviderManager()
            status = manager?.connection.status ?? .invalid
        } catch {
            print("VPNManager load error:", error)
        }
    }

    func connect() async throws {
        guard let manager else { return }
        let proto = NETunnelProviderProtocol()
        proto.providerBundleIdentifier = bundleID
        proto.serverAddress = serverAddress
        manager.protocolConfiguration = proto
        manager.localizedDescription = "My VPN"
        manager.isEnabled = true
        try await manager.saveToPreferences()
        try await manager.loadFromPreferences()
        try manager.connection.startVPNTunnel()
    }

    func disconnect() {
        manager?.connection.stopVPNTunnel()
    }

    @objc private func statusDidChange() {
        status = manager?.connection.status ?? .disconnected
    }
}


// ─── VPNControlView.swift (Container app UI) ──────────────────────────────
import SwiftUI
import NetworkExtension

struct VPNControlView: View {
    @State private var vpn = VPNManager()

    var body: some View {
        VStack(spacing: 24) {
            statusBadge
            Toggle(
                vpn.status == .connected ? "Disconnect" : "Connect",
                isOn: Binding(
                    get: { vpn.status == .connected || vpn.status == .connecting },
                    set: { on in
                        Task {
                            if on { try? await vpn.connect() }
                            else  { vpn.disconnect() }
                        }
                    }
                )
            )
            .toggleStyle(.switch)
            .accessibilityLabel("VPN toggle")
            .accessibilityHint(
                vpn.status == .connected ? "Tap to disconnect" : "Tap to connect"
            )
            .padding(.horizontal)
        }
        .padding()
        .navigationTitle("My VPN")
    }

    @ViewBuilder
    private var statusBadge: some View {
        Label(vpn.status.localizedDescription, systemImage: statusIcon)
            .font(.headline)
            .foregroundStyle(statusColor)
            .padding(.vertical, 8)
            .padding(.horizontal, 20)
            .background(statusColor.opacity(0.12), in: Capsule())
    }

    private var statusIcon: String {
        switch vpn.status {
        case .connected:    return "lock.fill"
        case .connecting:   return "arrow.trianglehead.clockwise"
        case .disconnecting:return "arrow.trianglehead.counterclockwise"
        default:            return "lock.open"
        }
    }

    private var statusColor: Color {
        switch vpn.status {
        case .connected:  return .green
        case .connecting, .disconnecting: return .orange
        default:          return .secondary
        }
    }
}

extension NEVPNStatus {
    var localizedDescription: String {
        switch self {
        case .invalid:       return "Not configured"
        case .disconnected:  return "Disconnected"
        case .connecting:    return "Connecting…"
        case .connected:     return "Connected"
        case .reasserting:   return "Reconnecting…"
        case .disconnecting: return "Disconnecting…"
        @unknown default:    return "Unknown"
        }
    }
}

#Preview {
    NavigationStack {
        VPNControlView()
    }
}

How it works

  1. Two-process architecture. PacketTunnelProvider runs in a sandboxed extension process managed by the OS. The container app communicates with it via NETunnelProviderManager and, for custom messages, NETunnelProviderSession.sendProviderMessage(_:). The OS boots and terminates the extension process independently of the app.
  2. setTunnelNetworkSettings(_:completionHandler:) in startTunnel tells the system which IP addresses, routes, and DNS servers to configure on the virtual interface. Calling the completion handler with nil signals that the tunnel is ready, which changes the connection status to .connected.
  3. packetFlow.readPacketObjects is the async read loop on the virtual TUN interface. Each call delivers a batch of NEPacket objects; you re-arm after each batch by calling readPacketObjects again. In production you'd forward the raw data over a real encrypted transport (e.g., WireGuard, a custom UDP socket).
  4. @Observable VPNManager wraps NETunnelProviderManager, keeping the SwiftUI layer thin. The NEVPNStatusDidChange notification fires on any connection-state change, and the @objc statusDidChange() observer bridges it back to the @MainActor status property that drives the UI.
  5. Toggle binding. Rather than binding a Bool property directly, the Binding(get:set:) initializer maps the NEVPNStatus enum to a boolean so the system Toggle behaves correctly during the transient .connecting and .disconnecting states.

Variants

Send a custom message from the app to the provider

// Container app
func sendMessage(_ payload: String) async throws -> String? {
    guard
        let session = manager?.connection as? NETunnelProviderSession,
        let data = payload.data(using: .utf8)
    else { return nil }

    return try await withCheckedThrowingContinuation { continuation in
        do {
            try session.sendProviderMessage(data) { replyData in
                let reply = replyData.flatMap { String(data: $0, encoding: .utf8) }
                continuation.resume(returning: reply)
            }
        } catch {
            continuation.resume(throwing: error)
        }
    }
}

// Provider — already handled by handleAppMessage above
// override func handleAppMessage(_ messageData: Data,
//     completionHandler: ((Data?) -> Void)?) { ... }

DNS Proxy Provider instead of Packet Tunnel

If you only need to intercept DNS queries (e.g., for content filtering or split-horizon DNS), subclass NEDNSProxyProvider and set its NEDNSProxyProviderProtocol instead. The entitlement changes to com.apple.developer.networking.networkextension with the dns-proxy value, and you handle flows via handleNewFlow(_:) rather than packetFlow. This requires a significantly narrower entitlement scope and is easier to get approved by Apple for non-VPN use cases.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a Network Extension (Packet Tunnel Provider) in SwiftUI for iOS 17+.
Use NetworkExtension, NEPacketTunnelProvider, NETunnelProviderManager, and NEVPNStatus.
Add a container-app VPNManager (@Observable) that loads preferences,
connects, disconnects, and observes NEVPNStatusDidChange notifications.
Build a VPNControlView with a Toggle and an accessible status badge.
Make it accessible (VoiceOver labels and hints for all interactive controls).
Add a #Preview with realistic sample data and a NavigationStack wrapper.

Drop this prompt into Soarias during the Build phase to scaffold both targets — the tunnel extension and the SwiftUI manager UI — in one pass, then iterate with follow-up prompts for your real transport layer.

Related

FAQ

Does this work on iOS 16?

The NETunnelProviderManager async/await APIs (loadAllFromPreferences() as an async function, saveToPreferences() returning Void with Swift concurrency) require iOS 17+. On iOS 16 you must use the callback-based overloads. The core NEPacketTunnelProvider class itself has been available since iOS 9, so the extension binary is backward-compatible if you guard the async calls.

Do I need special Apple approval to ship a Network Extension?

Yes. Packet Tunnel Provider and most other Network Extension subtypes require a special entitlement that you must request from Apple via the developer portal before your provisioning profile will include it. Personal Team accounts cannot use these entitlements. Submit a request at developer.apple.com → Account → Additional Capabilities, explaining your use case. DNS Proxy and App Proxy have similar requirements. Plan for a review turnaround of several business days.

What is the UIKit / pre-SwiftUI equivalent?

The NetworkExtension framework is UI-agnostic — it predates SwiftUI entirely. UIKit apps use the exact same NETunnelProviderManager APIs and observe the same NEVPNStatusDidChange notification via NotificationCenter. The only difference is that instead of an @Observable class driving a SwiftUI view, you update UILabels and UISwitch controls from the notification handler. The provider extension code is identical regardless of the UI framework.

Last reviewed: 2026-05-12 by the Soarias team.

```