Connect to OpenVPN servers from Flutter apps on Android, iOS and macOS. Contributions through issues and pull requests are highly appreciated!
- Android: a bundled OpenVPN 2.7 engine (ics-openvpn, OpenSSL 3.5) with
16 KB page size support required by Google Play, per-app VPN (allow or
bypass apps), a connection notification and no
MainActivitychanges. - iOS and macOS: a NetworkExtension packet tunnel running OpenVPN 3 in your app's extension, with the same Dart API.
- One API everywhere: stage, traffic and log streams, an awaitable
connect()anddisconnect(), error reasons, session timeouts and encrypted private keys.
| Android | iOS | macOS | |
|---|---|---|---|
| Minimum version | 5.0 (API 21) | 12.0 | 10.15 |
| Engine | OpenVPN 2.7, bundled | OpenVPN 3 (OpenVPNAdapter) in your Network Extension | same as iOS |
| Detailed stages | yes | connecting / connected / reconnect / disconnecting | same as iOS |
| Per-app VPN | allowedPackages, bypassPackages |
– | – |
| Session timeout | yes | yes (with the provided extension code) | same as iOS |
| Engine log | yes | yes (with the provided extension code) | same as iOS |
Flutter 3.10 / Dart 3.0 or newer.
flutter pub add openvpn_flutterOpenVPN runs as its own process from a native library, so the library must be
extracted when the app is installed. Recent Android Gradle Plugin versions do
not extract native libraries unless the app asks for it, and the VPN then stops
right after connecting ("OpenVPN process exited"). Add this to the android
block of android/app/build.gradle.kts:
android {
packaging {
jniLibs {
useLegacyPackaging = true
}
}
}or, in android/app/build.gradle:
android {
packaging {
jniLibs {
useLegacyPackaging = true
}
}
}This covers APKs and App Bundles (Google Play). Without it, connect() fails
with a native_libraries_not_extracted error that says the same, and the
Gradle build prints a warning.
The VPN runs as a foreground service with a notification. On Android 13+ ask for the notification permission, for example with permission_handler:
await Permission.notification.request();The VPN works without it, but the notification is then hidden.
-
Fill in the VPN declaration in the Play Console (VpnService).
-
Declare the systemExempted foreground service type (used for VPN apps on Android 14+) in the Play Console's foreground service declaration.
-
Only ship the ABIs you need to make the app smaller:
android { defaultConfig { ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") } } }
- Icon: add your own
android/app/src/main/res/drawable/ic_notification.png(or.xml); it replaces the engine's icon. - Title: the
nameyou pass toconnect(). - "Disconnect" button label: override the
cancel_connectionstring inandroid/app/src/main/res/values/strings.xml. initialize(showTrafficInNotification: false)keeps the notification static instead of refreshing it with the traffic every two seconds (some launchers show that as a blinking icon).
A foreground service cannot run without a notification, so it cannot be hidden.
The engine is downloaded from JitPack.
The plugin adds that repository to the project. If your settings.gradle(.kts)
uses dependencyResolutionManagement with RepositoriesMode.FAIL_ON_PROJECT_REPOS,
add it there:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}Nothing else is needed: the plugin asks for the VPN permission itself, so the
onActivityResult / connectWhileGranted code of 1.x can be removed from your
MainActivity (it is harmless if it stays).
VPN apps on iOS run the VPN in a Network Extension. You add one to your app and
put the provided PacketTunnelProvider.swift in it. VPN extensions do not run
in the simulator: test on a device.
Add the App Groups and Network Extensions (Packet Tunnel) capabilities to the Runner target:
In Xcode, File → New → Target… → Network Extension, name it (for example)
VPNExtension, provider type Packet Tunnel:
Give the extension the same two capabilities and the same App Group:
Set the extension's Minimum Deployments to the same iOS version as Runner. An extension that requires a newer iOS than the device fails with "Update required" in the system VPN settings.
The extension runs OpenVPN with OpenVPNAdapter.
With CocoaPods, add a top-level target to ios/Podfile (not nested inside
target 'Runner', or the extension inherits every Flutter plugin and fails to
link):
target 'VPNExtension' do
use_frameworks!
pod 'OpenVPNAdapter', :git => 'https://github.com/ss-abramchuk/OpenVPNAdapter.git', :tag => '0.8.0'
endWithout CocoaPods, add it with Swift Package Manager instead: File → Add Package
Dependencies…, https://github.com/ss-abramchuk/OpenVPNAdapter.git, version
0.8.0, added to the VPNExtension target.
Replace VPNExtension/PacketTunnelProvider.swift with
PacketTunnelProvider.swift.
It reads what the plugin sends (configuration, credentials, private key
password, session end), answers status requests and writes the OpenVPN log and
errors to the App Group so the app can show them.
Writing your own provider (another OpenVPN engine)
The plugin does not depend on OpenVPNAdapter; any packet tunnel provider that follows this contract works (TunnelKit/Partout, a fork, ...):
protocolConfiguration.providerConfigurationholdsconfig(the.ovpncontent,Data),groupIdentifier,username,passwordandprivateKeyPassword(Data, may be empty),name(String) andexpireAt(NSNumber, milliseconds since 1970, optional).startTunnel(options:)receivesusername,password,privateKeyPasswordandexpireAttoo.handleAppMessagereceivesOPENVPN_STATSand answers JSON withconnected_on(milliseconds),byte_in,byte_out,packets_in,packets_out.- Log lines go to
<App Group container>/openvpn_flutter/vpn.log, one per line:<milliseconds>\t<level>\t<message>. - The last fatal error goes to the App Group's
UserDefaultsunderopenvpn_flutter.lastError.
In the Runner target's Build Phases, drag Embed Foundation Extensions (or "Embed App Extensions") above Thin Binary and Run Script. Otherwise Xcode reports "Cycle inside Runner; building could produce unreliable results".
Pass the App Group and the extension's bundle identifier to initialize(), see
Usage.
macOS uses the same NETunnelProviderManager API, so the Dart side and the
PacketTunnelProvider.swift above are the same. Complete the iOS setup first,
then apply the macOS-specific steps below. What differs is how the provider is
packaged:
| Distribution | Provider is a | Entitlement value (com.apple.developer.networking.networkextension) |
|---|---|---|
| Mac App Store | App Extension (.appex) |
packet-tunnel-provider |
| Developer ID (outside the store) | System Extension | packet-tunnel-provider-systemextension (Developer ID profile), packet-tunnel-provider while developing |
Use a Packet Tunnel App Extension for Mac App Store distribution. Use a Packet Tunnel System Extension for a Developer ID build distributed outside the store. The rest of this section describes the System Extension setup, which is the configuration used by a notarized, directly distributed app.
In Xcode, give Runner and VPNExtension the same Development Team. Use a unique
extension bundle identifier, for example com.example.app.VPNExtension, and
pass that exact identifier as providerBundleIdentifier to initialize().
For a System Extension, check these target settings:
- Product Type is System Extension and the built wrapper ends in
.systemextension(WRAPPER_EXTENSION = systemextension). - Skip Install is
YES, Hardened Runtime is enabled, and the macOS deployment target is no newer than Runner's deployment target. - Runner has a target dependency on
VPNExtensionand an Embed System Extensions copy-files phase whose destination isContents/Library/SystemExtensions. Put that phase before Flutter's Thin Binary and Run Script phases to avoid a dependency cycle. - Every Debug, Profile and Release configuration selects the intended signing identity, provisioning profile and entitlements file. Do not leave Release on an empty entitlements file unless a release pipeline deliberately stamps those entitlements before signing.
Enable App Groups, Network Extensions → Packet Tunnel, and System
Extension on Runner. Enable the matching App Group and Network Extension on
VPNExtension. The resulting entitlements are conceptually:
<!-- Runner -->
<key>com.apple.developer.networking.networkextension</key>
<array><string>packet-tunnel-provider</string></array>
<key>com.apple.developer.system-extension.install</key>
<true/>
<key>com.apple.security.application-groups</key>
<array><string>$(TeamIdentifierPrefix)group.com.example.app</string></array>
<!-- VPNExtension during development -->
<key>com.apple.developer.networking.networkextension</key>
<array><string>packet-tunnel-provider</string></array>
<key>com.apple.security.application-groups</key>
<array><string>$(TeamIdentifierPrefix)group.com.example.app</string></array>The Developer ID provisioning profile for the provider uses
packet-tunnel-provider-systemextension. The entitlements embedded in the
signed app, extension and profiles must agree; inspect release artifacts with
codesign -d --entitlements :- <path> and
security cms -D -i <profile>.
App Group identifiers are Team-ID-prefixed on macOS. Pass the resolved value,
such as ABCDE12345.group.com.example.app, to initialize(groupIdentifier:).
Passing only group.com.example.app can put the app and provider in different
containers, leaving shared logs and status empty.
The System Extension is a standalone executable. Add this main.swift:
import Foundation
import NetworkExtension
autoreleasepool {
NEProvider.startSystemExtensionMode()
}
CFRunLoopRun()Use CFRunLoopRun(), not dispatchMain(). OpenVPNAdapter attaches its inbound
packet socket to the main Core Foundation run loop; with dispatchMain() the
tunnel can connect and send traffic while never receiving packets.
The extension's Info.plist must use SYSX as CFBundlePackageType, include a
user-facing NSSystemExtensionUsageDescription, and declare:
<key>NetworkExtension</key>
<dict>
<key>NEMachServiceName</key>
<string>ABCDE12345.group.com.example.app.VPNExtension</string>
<key>NEProviderClasses</key>
<dict>
<key>com.apple.networkextension.packet-tunnel</key>
<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
</dict>
</dict>NEMachServiceName must contain the real Team ID in the final built plist and
must match the value authorized by the signing profile. Write the Team ID out
when an unsigned CI build is signed later: $(TeamIdentifierPrefix) is resolved
while Xcode builds the plist and can become empty when no provisioning profile
is present; signing afterwards does not reprocess the plist.
This plugin already ships a Flutter Swift Package at
darwin/openvpn_flutter/Package.swift; Flutter consumes it for the Runner
plugin. That package does not replace the VPN engine inside your separate
provider target.
Prefer Swift Package Manager for the provider: in Xcode select File → Add
Package Dependencies…, add
https://github.com/ss-abramchuk/OpenVPNAdapter.git at 0.8.0, and attach the
OpenVPNAdapter product to VPNExtension only.
If using CocoaPods instead, keep VPNExtension as a top-level Podfile target.
CocoaPods may not embed its frameworks into a System Extension automatically;
add its generated framework embed script as the extension's final build phase:
"${PODS_ROOT}/Target Support Files/Pods-VPNExtension/Pods-VPNExtension-frameworks.sh"Before creating or starting the VPN configuration, Runner must submit an
OSSystemExtensionRequest.activationRequest for the provider bundle identifier.
Keep its delegate alive until the request finishes. A request that needs user
approval remains in flight even after the UI is updated, so prevent duplicate
activation requests.
System extensions are installed only from an application in /Applications.
Copy the built app there and launch that copy; a build run from Flutter's build
directory or a Gatekeeper-translocated location cannot be activated. The first
activation requires approval in System Settings → General → Login Items &
Extensions → Network Extensions. Re-submit activation after replacing the
extension with a newer build.
For Developer ID distribution, sign from the inside out: OpenVPNAdapter and
other nested frameworks, VPNExtension.systemextension with its entitlements,
then Runner. Do not use codesign --deep. Verify the finished app before
notarization:
codesign --verify --deep --strict --verbose=2 YourApp.app
spctl --assess --type execute --verbose=4 YourApp.appA System Extension runs as root and may not see the same App Group container as
Runner. Provider messages such as OPENVPN_STATS remain the reliable path for
live status. Shared-file logs may be unavailable, and lastError() may then
only report the error supplied by NetworkExtension on macOS 13 or later.
import 'package:openvpn_flutter/openvpn_flutter.dart';
late final OpenVPN openvpn;
@override
void initState() {
super.initState();
openvpn = OpenVPN(
onVpnStageChanged: (stage, raw) => setState(() => this.stage = stage),
onVpnStatusChanged: (status) => setState(() => this.status = status),
);
openvpn.initialize(
// iOS and macOS:
groupIdentifier: 'group.com.example.vpn',
providerBundleIdentifier: 'com.example.app.VPNExtension',
localizedDescription: 'Example VPN', // name in the system VPN settings
// State of a VPN that was already running (app reopened while connected):
lastStage: (stage) => setState(() => this.stage = stage),
lastStatus: (status) => setState(() => this.status = status),
);
}stageStream, statusStream and logStream report the same as the callbacks;
currentStage and currentStatus hold the latest values. Call dispose() when
you no longer need the instance (the VPN keeps running).
try {
final started = await openvpn.connect(
ovpnFileContent, // the content of the .ovpn file
'Japan #1', // shown in the Android notification
username: username, // for auth-user-pass configurations
password: password,
);
if (!started) {
// The user refused the VPN permission (stage: VPNStage.denied).
}
} on PlatformException catch (e) {
// Invalid configuration, missing engine, iOS entitlements, ...
print('${e.code}: ${e.message}');
}connect() completes once the connection is starting; the stages tell how it
goes. To wait for the outcome:
await openvpn.connect(config, 'Japan #1');
final stage = await openvpn.stageStream
.firstWhere((stage) => stage.isConnected || stage.isDisconnected)
.timeout(const Duration(seconds: 30), onTimeout: () => openvpn.currentStage);
if (!stage.isConnected) {
print('Failed: ${await openvpn.lastError()}');
await openvpn.disconnect(); // stop retrying
}Other options:
await openvpn.connect(
config,
'Japan #1',
// Encrypted <key>:
privateKeyPassword: 'key passphrase',
// Disconnect automatically, even with the app closed (Android enforces it in
// the VPN service, iOS/macOS in the packet tunnel provider):
expireAt: DateTime.now().add(const Duration(hours: 1)),
// Android: only these apps use the VPN...
allowedPackages: ['com.android.chrome'],
// ...or every app except these (not both):
// bypassPackages: ['com.example.banking'],
// Whether the configuration uses a client certificate; detected when null.
certIsRequired: null,
);Calling connect() while connected switches to the new server.
await openvpn.disconnect(); // completes once the tunnel is downfinal status = await openvpn.status(); // or currentStatus / statusStream
status.connectedOn; // DateTime?, null while not connected
status.duration; // '01:02:03'
status.byteIn; // bytes received, as a string
status.byteOut; // bytes sent
status.speedIn; // bytes per second
status.speedOut;| Stage | Meaning |
|---|---|
prepare |
Waiting for the Android VPN permission dialog |
vpn_generate_config, resolve, tcp_connect, wait_connection, authenticating, get_config, assign_ip, add_routes |
Connection progress (Android) |
connecting |
Connecting |
connected |
The tunnel is up |
reconnect |
The connection dropped and is coming back |
no_network / paused |
Waiting for a network / paused while the screen is off (Android) |
auth_pending |
Waiting for extra authentication (Android) |
disconnecting, exiting |
Stopping |
disconnected |
No tunnel |
denied |
The user refused the VPN permission |
auth_failed |
The server rejected the credentials or certificate |
error |
The connection failed or stopped on its own; see lastError() |
unknown |
A stage this version does not know |
stage.isConnected, stage.isConnecting and stage.isDisconnected group them.
final logs = await openvpn.getLogs(); // the engine log so far
openvpn.logStream.listen((log) => print('${log.level.name}: ${log.message}'));
final reason = await openvpn.lastError(); // why the last connection failedOn iOS and macOS the log and errors come from the packet tunnel provider above.
Android has these as system settings: "Always-on VPN" and "Block connections
without VPN". openvpn.openVpnSettings() opens that screen.
openvpn.initialize(disconnectOnTaskRemoved: true);initialize, connect, disconnect, status and the streams also work from
background isolates and services (no activity). The VPN permission has to be
granted once from the UI first (requestPermissionAndroid() or a connect()).
final config = await OpenVPN.filteredConfig(ovpnFileContent); // keeps one random remote- Android: add
useLegacyPackaging = true(see Android setup). TheonActivityResult/connectWhileGrantedcode inMainActivityis no longer needed. connect()returnsFuture<bool>(falsewhen the permission is refused) and throwsPlatformExceptionfor invalid configurations: wrap it intry/catch. On Android it used to never complete.disconnect()returns aFuturethat completes once the tunnel is down.- New stages:
add_routes,reconnect,auth_pending,auth_failed,no_network,paused. Exhaustiveswitches overVPNStageneed them; stages that used to beunknown(likereconnect) now have their own value. certIsRequiredis detected from the configuration when omitted. On Android it no longer appendsclient-cert-not-requiredto the configuration.- iOS: replace
PacketTunnelProvider.swiftwith the new one, use OpenVPNAdapter0.8.0, and give the extension the same deployment target as the app. VpnStatus.packetsIn/packetsOutarenullon Android (they repeated the byte counts);VpnStatus.toJson()encodesconnected_onas an ISO 8601 string.bypassPackagesonly excludes those apps; it no longer lets every app bypass the VPN on its own.- The Android engine is OpenVPN 2.7 with OpenSSL 3.5. Servers that cannot
negotiate ciphers keep working when the configuration names its
cipher; see Troubleshooting for BF-CBC and weak certificates.
await openvpn.lastError() and await openvpn.getLogs() usually tell what went
wrong; the issues below are the frequent ones.
Android: "OpenVPN process exited", "no process running", connects then
disconnects, works in debug but not from Google Play. The native libraries are
not extracted: add useLegacyPackaging = true
(Android setup).
Android: "You must select a User certificate". The configuration has neither
a client certificate (<cert>/<key>) nor auth-user-pass. Pass a username and
password, or fix the configuration.
"No usable data channel cipher", VPN Gate or OpenVPN 2.3 servers stuck
reconnecting. OpenVPN 2.6+ only falls back to the configuration's cipher when
told to; the plugin adds data-ciphers-fallback for you on Android. For servers
that rely on the old BF-CBC default without a cipher line, add to the
configuration:
data-ciphers AES-256-GCM:AES-128-GCM:BF-CBC
data-ciphers-fallback BF-CBC
providers legacy default
"FATAL: could not read Private Key", "private key password verification
failed". The <key> is encrypted: pass privateKeyPassword.
auth_failed. Wrong credentials, or the server refused the session (some
public servers limit sessions per IP address).
"MissingForegroundServiceTypeException". Fixed since 1.3.4; update.
16 KB page size warnings in Google Play. Fixed in 2.0.0.
iOS: connects and disconnects immediately. Check lastError() and the log,
then: the extension's deployment target (see below), the App Group (same in
both targets and in initialize()), credentials for auth-user-pass
configurations, and options OpenVPN 3 does not support.
iOS: "Update required" in the VPN settings. The extension's deployment target is newer than the device's iOS: lower it to match Runner.
iOS: "permission denied" (NEConfigurationErrorDomain 10). The app lacks the Network Extensions capability or provisioning profile, or runs in the simulator.
iOS: "Cycle inside Runner". Move Embed Foundation Extensions above Thin Binary (iOS setup).
iOS: "Module 'openvpn_flutter' not found". Open Runner.xcworkspace, not
Runner.xcodeproj, and run pod install in ios/. With Swift Package Manager
on Flutter versions before 3.44, use CocoaPods instead:
flutter config --no-enable-swift-package-manager.
iOS: "Undefined symbol" errors (MarketplaceKit, __swift_FORCE_LOAD_...).
The extension's Podfile target is nested inside target 'Runner'; make it a
top-level target.
WireGuard, IKEv2, Windows, Linux. Not supported; this plugin is for OpenVPN on Android, iOS and macOS.
- openvpn_flutter (GPLv3)
- ics-openvpn, through openvpn_library, for the Android engine
- OpenVPNAdapter for the iOS and macOS engine
If you appreciate this work, give it a thumbs up or support it with a cup of coffee.