react-native-ovpn
v0.1.2
Published
OpenVPN client for React Native — Android, iOS, and Expo. New Architecture (TurboModule), kill switch, custom DNS, bounded auto-reconnect.
Downloads
409
Maintainers
Readme
react-native-ovpn
OpenVPN client for React Native — Android, iOS, and Expo. Built as a TurboModule (New Architecture compatible), with bounded auto-reconnect, kill switch, custom DNS, and a foreground-service notification.
import { OpenVPNClient } from 'react-native-ovpn';
const client = new OpenVPNClient();
client.on('state', (s) => console.log(s)); // 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'disconnecting'
client.on('stats', ({ bytesIn, bytesOut }) => {});
client.on('error', (err) => console.warn(err.code, err.message));
await client.requestPermission();
await client.connect({
config: ovpnFileContents,
username: 'alice',
password: 's3cret',
});
// ...
await client.disconnect();
client.dispose();Demo
https://github.com/user-attachments/assets/6d19d50c-0ed3-4f73-994e-027c241ab149
The video renders inline on GitHub. On npmjs.com, open it directly.
Table of contents
Features
| Feature | Android | iOS | | --- | --- | --- | | Connect / disconnect | ✅ | ✅ | | State + stats + log events | ✅ | ✅ | | Auto-reconnect with bounded exponential backoff | ✅ | ✅ | | Username + password auth | ✅ | ✅ | | Certificate-only auth | ✅ | ✅ | | TLS-auth / tls-crypt / tls-crypt-v2 | ✅ | ✅ | | Legacy cipher fallback (AES-128-CBC) — auto-injected | ✅ | ✅ | | Custom DNS override | ✅ | ✅ | | Kill switch | ✅ | ❌ (Apple limitation) | | Foreground service notification | ✅ | n/a | | Per-app routing (allowed / disallowed apps) | ✅ | ❌ | | New Architecture (TurboModule + Fabric) | ✅ | ✅ | | Expo config plugin | ✅ | ✅ |
Install
npm install react-native-ovpn
# or
yarn add react-native-ovpn
# or
pnpm add react-native-ovpnExpo (recommended)
Add the config plugin to your app.config.js (or app.json):
export default {
expo: {
// ...
plugins: [
[
'react-native-ovpn',
{
// iOS only — Apple requires an App Group shared between the host
// app and the PacketTunnel extension. Format: group.<your-bundle-id>.
iosAppGroup: 'group.com.example.myapp',
// Optional — the extension's bundle id. Defaults to
// <host-bundle-id>.OpenVPNTunnel
iosExtensionBundleIdentifier: 'com.example.myapp.OpenVPNTunnel',
// Optional — Android notification channel name shown to users
androidNotificationChannelName: 'VPN',
// Optional — path (relative to the project root) to a small PNG/vector
// notification icon. Falls back to the app icon if omitted.
androidNotificationIcon: './assets/notification-icon.png',
},
],
],
},
};Then regenerate the native projects:
npx expo prebuild --clean
npx expo run:android
# or run:ios after the iOS extension setup belowiOS extension is manual. Apple requires you to add the PacketTunnel extension target through Xcode (App Groups, Network Extensions entitlements, signing). The package ships a Swift template at
ios/PacketTunnelProvider/. See iOS PacketTunnel setup below.
Bare React Native
Android
The native side is powered by ics-openvpn, vendored as a prebuilt AAR. autolinking handles the rest. You will need:
Permissions in
android/app/src/main/AndroidManifest.xml:<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />Activity result for the
VpnService.prepare()system dialog — ourrequestPermission()method takes care of this for you.MinSdk of 23 or higher. If you're on Expo SDK 50+, this is already the default.
Java desugaring (already enabled by React Native 0.71+ via the default Gradle config).
iOS
Open ios/<YourApp>.xcworkspace in Xcode and add the PacketTunnel
extension target — see iOS PacketTunnel extension setup.
Then run pod install from the ios directory.
iOS PacketTunnel extension setup
⚠️ iOS requires a separate Network Extension target inside your Xcode project. Apple does not allow this to be created from Expo's config plugin or
pod installalone — it must be added manually, one time, in Xcode.
- In Xcode, File → New → Target → Network Extension → Packet Tunnel Provider
- Name it OpenVPNTunnel (or match your
iosExtensionBundleIdentifier) - Add both the host app and the extension to the same App Group
(matches
iosAppGroupin the plugin config) - Replace the auto-generated
PacketTunnelProvider.swiftwith the one that ships atnode_modules/react-native-ovpn/ios/PacketTunnelProvider/PacketTunnelProvider.swift - Add the Network Extensions entitlement to both targets
(
com.apple.developer.networking.networkextension→packet-tunnel-provider) - The extension target needs the same
OpenVPNAdapterpod —pod installafter editing yourPodfileto include the extension - Re-build
API
OpenVPNClient
import { OpenVPNClient } from 'react-native-ovpn';
const client = new OpenVPNClient();| Method | Returns | Notes |
| --- | --- | --- |
| requestPermission() | Promise<boolean> | Shows the OS VpnService.prepare dialog (Android) / Network Extension permission (iOS). Must be called before the first connect(). Resolves true once granted. |
| connect(options) | Promise<void> | Resolves once the tunnel reaches connected. Rejects on hard errors (auth failure, malformed config, etc.). |
| disconnect() | Promise<void> | Tears down the tunnel and cancels any pending reconnect attempts. |
| getStatus() | Promise<Status> | Snapshot of the current native state — useful on app reopen if your JS process was killed but the foreground service stayed alive. |
| getStats() | Promise<Stats> | Latest byte counters. Cheaper than subscribing to stats events. |
| on(event, listener) | void | Subscribe to a tunnel event. See Events. |
| off(event, listener) | void | Unsubscribe a single listener. |
| removeAllListeners() | void | Drop all listeners across all events. |
| dispose() | void | Remove all listeners and tear down native subscriptions. Call when the client instance is no longer needed. |
ConnectOptions:
type ConnectOptions = {
/** Full .ovpn file contents as a string. */
config: string;
/** username/password for `auth-user-pass` configs. Pass empty strings for cert-only. */
username: string;
password: string;
/** Enable kill switch — block all traffic when tunnel drops. Android only. */
killSwitch?: boolean;
/** Override DNS servers used inside the tunnel. */
dns?: string[];
/** Android: only these apps tunnel through the VPN. Mutually exclusive with disallowedApps. */
allowedApps?: string[];
/** Android: every app EXCEPT these tunnels. Mutually exclusive with allowedApps. */
disallowedApps?: string[];
/** Customize the foreground service notification. Android only. */
notification?: NotificationOptions;
/** Bounded auto-reconnect policy. */
reconnect?: ReconnectOptions;
};
type NotificationOptions = {
title?: string;
text?: string;
smallIcon?: string; // resource name (without extension) of a drawable
};
type ReconnectOptions = {
maxRetries?: number; // default 5
baseDelayMs?: number; // default 1000
maxDelayMs?: number; // default 60000
};Events
client.on(event, listener) — listener signatures:
| Event | Payload | When |
| --- | --- | --- |
| state | VPNState | Tunnel state transitions: 'idle' → 'connecting' → 'connected'. Drops emit 'reconnecting' first, then 'disconnected' only after retries are exhausted. |
| stats | { bytesIn: number; bytesOut: number; durationMs: number } | Throttled bandwidth counters (every ~1s on Android, ~3s on iOS). |
| log | string | Single log line from the upstream OpenVPN engine. Verbose — only attach when debugging. |
| error | OpenVPNError | Recoverable or fatal errors. See Errors. |
| reconnecting | { attempt: number; delayMs: number } | Fires per retry attempt while the scheduler is active. |
type VPNState =
| 'idle'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'disconnecting'
| 'disconnected';Types
type Status = {
state: VPNState;
/** ms since epoch when 'connected' was first reached (this session). */
connectedSince?: number;
/** Server hostname or IP currently in use. */
server?: string;
localIp?: string;
remoteIp?: string;
};
type Stats = {
bytesIn: number;
bytesOut: number;
durationMs: number;
};Errors
import { OpenVPNError, ERROR_CODES, type ErrorCode } from 'react-native-ovpn';ErrorCode is a union of:
| Code | Meaning | Recoverable? |
| --- | --- | --- |
| AUTH_FAILED | Bad username/password, expired cert, blocked account. | ❌ |
| TLS_HANDSHAKE_FAILED | Server cert mismatch or untrusted CA. | ❌ |
| CONNECTION_REFUSED | Server rejected the connection (capacity, IP ban). | ⚠️ retry maybe |
| CONNECTION_TIMEOUT | Couldn't reach the server. | ⚠️ retry |
| DNS_RESOLUTION_FAILED | Couldn't resolve the remote hostname. | ⚠️ retry |
| PERMISSION_DENIED | User declined the VPN system dialog. | ❌ (re-call requestPermission) |
| MALFORMED_CONFIG | The .ovpn couldn't be parsed. | ❌ |
| RECONNECT_EXHAUSTED | All retry attempts failed. | ❌ |
| NATIVE_ERROR | Anything else surfaced from the native engine. | depends |
HARD_ERROR_CODES lists the non-recoverable codes. The client uses this set internally to stop the auto-reconnect loop on auth-class failures.
Recipes
Kill switch
Blocks all traffic when the tunnel drops, so your app never accidentally leaks plain-text packets. Android only — iOS doesn't expose this to non-Apple VPN apps.
await client.connect({
config: ovpn,
username: 'alice',
password: 's3cret',
killSwitch: true,
});Custom DNS
Override DNS servers inside the tunnel (e.g. force Cloudflare's 1.1.1.1 instead of the server's defaults):
await client.connect({
config: ovpn,
username: '',
password: '',
dns: ['1.1.1.1', '1.0.0.1'],
});Per-app routing (disallowed/allowed apps)
Tunnel only your app:
await client.connect({
config: ovpn,
username: '',
password: '',
allowedApps: ['com.your.app'],
});Or tunnel everything except Spotify (it has its own region locks):
await client.connect({
// ...
disallowedApps: ['com.spotify.music'],
});Handling auto-reconnect
By default the client retries 5 times with exponential backoff (1s → 2s → 4s → … capped at 60s). Bump it for flaky networks:
client.on('reconnecting', ({ attempt, delayMs }) => {
console.log(`reconnect ${attempt} in ${delayMs}ms`);
});
await client.connect({
config: ovpn,
username: '',
password: '',
reconnect: { maxRetries: 10, baseDelayMs: 500, maxDelayMs: 30_000 },
});When all retries fail you'll get state: 'disconnected' + an error with code RECONNECT_EXHAUSTED.
Reading live bandwidth stats
const [stats, setStats] = useState({ bytesIn: 0, bytesOut: 0 });
useEffect(() => {
const handler = (s) => setStats(s);
client.on('stats', handler);
return () => client.off('stats', handler);
}, []);Customizing the foreground notification
Android requires a sticky notification while the VPN service runs.
await client.connect({
// ...
notification: {
title: 'My App VPN',
text: 'Connected to United States',
smallIcon: 'notification_icon', // res/drawable/notification_icon.png
},
});To change the notification channel name, configure it once in the Expo plugin (androidNotificationChannelName) — it ships in the manifest at prebuild time.
OpenVPN config support
The library passes your .ovpn to the upstream engine as-is. Most directives work. Known-good and known-bad below.
✅ Supported directives
client, dev tun, proto tcp|udp, remote, resolv-retry, nobind, persist-key, persist-tun, remote-cert-tls, auth-user-pass, cipher, data-ciphers, auth, verb, mute, keepalive, <ca>...</ca>, <cert>...</cert>, <key>...</key>, <tls-auth>...</tls-auth>, <tls-crypt>...</tls-crypt>, <tls-crypt-v2>...</tls-crypt-v2>, tls-cipher, comp-lzo, compress, redirect-gateway, route, dhcp-option DNS, script-security (parsed but ignored — see below).
Legacy ciphers (AES-128-CBC, AES-256-CBC): The library auto-injects
data-ciphers AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305:AES-256-CBC:AES-128-CBC
and data-ciphers-fallback AES-128-CBC if your .ovpn doesn't specify
them, so VPN Gate / SoftEther / older corporate servers negotiate cleanly.
⚠️ Caveats
| Directive | Behavior |
| --- | --- |
| dev tap | Not supported — Layer-2 tunneling isn't available on Android/iOS. |
| script-security, up, down | Stripped at runtime — sandboxed platforms don't allow shell hooks. |
| management | Stripped — the engine speaks management to the wrapper internally. |
| pkcs12 | Inline <ca>/<cert>/<key> works; PKCS#12 file paths don't. Extract to PEM first. |
| --config /path/to/file.ovpn | N/A — pass the contents as a string in connect({ config }). |
❌ Not supported
fragment, mssfix N, route-method, dhcp-renew, pull-filter, route-noexec, client-cert-not-required (deprecated), route-delay (silently ignored).
Troubleshooting
RESOLVE: Cannot resolve host address: <hostname>
DNS is failing inside the tunnel pre-handshake. Common causes:
- Server is offline (VPN Gate / public servers rotate every few hours)
- Phone's underlying internet is down — try opening a browser
- Custom DNS in
connect({ dns })is wrong — drop it temporarily
TLS Error: TLS handshake failed
Your .ovpn's embedded CA cert doesn't match the server's. Re-download the
.ovpn from your provider — server certs rotate.
AUTH_FAILED after a known-good username/password
- Some providers issue a separate VPN password (not your dashboard login). Check the provider's docs.
- 2FA-protected accounts need an application password that pre-applies the OTP.
The tunnel connects, but no traffic flows
redirect-gateway def1 bypass-dhcpshould be in the.ovpn— without it, onlyroutedirectives get installed.- On Android, check Settings → Connections → More connection settings → VPN — your app should appear as the active VPN.
Android: app killed but tunnel stays running, then UI shows "Not Connected"
The JS process can die while the VpnService (a foreground service) stays alive. Call getStatus() on app reopen to reconcile:
useEffect(() => {
client.getStatus().then((s) => {
if (s.state === 'connected') {
// update UI to connected state
}
});
}, []);Android: Samsung / Xiaomi / OPPO killing the service after ~5 minutes
These OEMs aggressively kill background services. Have your app prompt for Battery optimization exemption:
import * as IntentLauncher from 'expo-intent-launcher';
import { Platform } from 'react-native';
if (Platform.OS === 'android') {
await IntentLauncher.startActivityAsync(
'android.settings.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS',
{ data: `package:${YOUR_PACKAGE_ID}` }
);
}You must also declare the permission in your manifest (Expo: add to app.config.js android.permissions: ['REQUEST_IGNORE_BATTERY_OPTIMIZATIONS']).
iOS: extension fails to load, app reports PERMISSION_DENIED
- App Group entitlement is missing on one of the two targets (host or extension). Both need it, with the same group identifier.
- The PacketTunnel target's bundle id must start with the host app's bundle id (
com.example.app.OpenVPNTunnelif host iscom.example.app).
How it works
Android: A thin Kotlin wrapper around ics-openvpn (vendored as a prebuilt AAR). Tunneling runs in a
VpnService(OpenvpnService). The wrapper translatesconnect()parameters into the engine'sProfileBuilder, then forwards engine state callbacks back over a TurboModule event emitter.iOS: Uses OpenVPNAdapter running in a Packet Tunnel Provider extension. The host app talks to the extension via
NETunnelProviderManager. App Group shared user defaults pass state events between the two processes.Auto-reconnect: A pure-JS scheduler (
Schedulerclass) drives retries with exponential backoff. The state event collapsesdisconnected → reconnectingin the same JS tick so the UI never paints "Not Connected" between retries.Codegen: Native specs in
src/NativeOpenvpn.tsproduce both old-architecture and Fabric-compatible bindings via React Native's codegen.
License
Wrapper code (this package): MIT.
Important — copyleft inheritance: This library embeds two upstream projects that ship under copyleft licenses:
- Android: ics-openvpn → GPL-2.0
- iOS: OpenVPNAdapter → AGPL-3.0
Any app that ships react-native-ovpn inherits those obligations:
- ✅ Personal projects, internal-only apps, open-source apps → fine
- ✅ Compliance for GPL/AGPL → publish your app's source under a GPL/AGPL-compatible license, or
- ❌ Closed-source commercial distribution → not legal without separate commercial agreements with the upstream maintainers (Arne Schwabe for ics-openvpn, Sergey Abramchuk for OpenVPNAdapter)
Use accordingly. See LICENSE in the package root for the MIT text covering
this library's own code.
Contributing
Issues and PRs welcome. Please open an issue first for substantial changes.
Maintained by @Raselj71.
