The Same VPN App Needs Two Different Shapes on macOS
An NEPacketTunnelProvider packaged as an app extension works on the Mac App Store and nowhere else. Shipping the same app as a signed download means rebuilding the tunnel as a system extension.

The Same VPN App Needs Two Different Shapes on macOS
If your macOS app contains an NEPacketTunnelProvider, the distribution
channel you choose is not a packaging decision you make at the end. It decides
the architecture of your app.
- Mac App Store: the provider is an app extension (
.appex), living inContents/PlugIns. - Developer ID (direct download): the provider must be a system
extension (
.systemextension), living inContents/Library/SystemExtensions.
These are not two names for the same thing. They are different bundle types, with different entitlement values, a different install flow, and different code in your app. You cannot ship one artifact to both channels.
How we found out the expensive way
We had a working App Store build and assumed the direct-download version was an export-options problem. Sign the same archive with a Developer ID certificate, notarize, done.
It notarized. It stapled. Gatekeeper accepted it. It would not launch — the
kernel killed it at execve for requesting a restricted entitlement with no
profile to back it. We wrote about that failure mode
in a separate post; the short
version is that none of the standard checks catch it.
Getting a provisioning profile issued fixed the entitlement problem and surfaced the real one. The profile Apple issues for Developer ID distribution grants:
com.apple.developer.networking.networkextension = [
"packet-tunnel-provider-systemextension",
"app-proxy-provider-systemextension",
"content-filter-provider-systemextension",
"dns-proxy-systemextension",
…
]
Note the suffix on every tunnel value. Apple does not issue
packet-tunnel-provider for direct distribution. It issues
packet-tunnel-provider-systemextension. The profile is telling you what shape
it expects, and an app extension cannot claim it.
What actually changes in the project
Four things, and the third is the one that surprises people.
1. A separate target, not a rebuilt one. The App Store build still needs the appex. Keeping both means a macOS-only system-extension target alongside the existing app extension, sharing the same provider sources.
2. A different Info.plist. An appex declares itself through NSExtension:
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.networkextension.packet-tunnel</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
</dict>A system extension declares itself through NetworkExtension, and its
CFBundlePackageType is SYSX, not XPC!:
<key>NetworkExtension</key>
<dict>
<key>NEProviderClasses</key>
<dict>
<key>com.apple.networkextension.packet-tunnel</key>
<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
</dict>
<key>NEMachServiceName</key>
<string>$(TeamIdentifierPrefix)group.your.app</string>
</dict>3. It needs its own main. An app extension is launched for you by the
extension host. A system extension is an ordinary executable: if nothing hands
it to NetworkExtension and parks the run loop, the process starts and exits
immediately.
import Foundation
import NetworkExtension
autoreleasepool {
NEProvider.startSystemExtensionMode()
}
dispatchMain()One trap here that cost us a build: put this file anywhere your other targets'
source globs can reach and every app target picks it up, failing with
'main' attribute cannot be used in a module that contains top-level code.
Keep it in a directory only the system-extension target compiles.
4. The app has to ask for installation. With an appex, referencing the
provider bundle identifier from NETunnelProviderManager is enough. With a
sysex, macOS will not resolve that identifier until the extension is installed
and the user has approved it. So the app submits an activation request first:
let request = OSSystemExtensionRequest.activationRequest(
forExtensionWithIdentifier: extensionIdentifier,
queue: .main
)
request.delegate = self
OSSystemExtensionManager.shared.submitRequest(request)and handles requestNeedsUserApproval by telling the user to go to System
Settings → General → Login Items & Extensions → Network Extensions. Until
they do, the tunnel cannot start. That is a real change to first-run UX and it
belongs in your onboarding copy, not in an error toast.
The app also needs com.apple.developer.system-extension.install, which is
itself restricted, and which only appears in the provisioning profile once the
System Extension capability is enabled on the App ID.
Two operational notes
Enabling that capability invalidates profiles. Turning on System Extension for an App ID marks every existing provisioning profile for that bundle ID invalid, including ones other platforms reference by name in a build config. Ours took out the tvOS and visionOS App Store profiles. They regenerate fine under the same names, but if you do this on a Friday you will find out on Monday.
Hardened runtime is not on by default. Whatever generates your project may
not set ENABLE_HARDENED_RUNTIME. If it does not, codesign -dv reports
flags=0x0(none) and the notary service rejects the submission outright. Both
the app and the system extension need it.
Was it worth it
For us, yes, but the honest answer depends on what your tunnel does.
The alternative we considered was shipping the direct-download build with the tunnel disabled and only the in-process proxy mode available — no NetworkExtension entitlements at all, so no restricted-entitlement problem and no system-extension approval step. It is genuinely simpler, and for a product whose users mostly want a local proxy it would have been the right call.
We went with the system extension because a VPN client that cannot bring up a tunnel outside the App Store is a different, smaller product. But it is a week's work and a new support burden — every first-run now includes an approval step a user can decline — not a build setting.
Decide the channel before you write the provider. Retrofitting it afterwards is possible, which is what this post is about, but it is not the cheap path.