Issue
On Android, the native module creates new AuthorizationService(...) at every call site and never calls dispose() on it. The string dispose does not appear anywhere in RNAppAuthModule.java — I checked main today as well as the published 8.1.0 and 8.4.1 artifacts.
AuthorizationService's constructor eagerly binds to the selected browser's Custom Tabs service:
new AuthorizationService(context, config)
-> BrowserSelector.select(...)
-> CustomTabManager.bind(browserPackage)
-> CustomTabsClient.bindCustomTabsService(...)
-> Context.bindService(...)
That bind is released only by AuthorizationService.dispose() -> CustomTabManager.dispose() -> unbindService. AppAuth states the contract explicitly in the AuthorizationService javadoc:
instances of this class must be manually disposed when no longer required, to avoid leaks
So each call through this library leaks one ServiceConnection to the default browser's CustomTabsService. Android's ActivityManager caps outstanding bind requests per process/service at ~1000. Once that is crossed, bindService throws:
Error: Too many bind requests(999+) for service Intent { act=android.support.customtabs.action.CustomTabsService pkg=com.sec.android.app.sbrowser }
The exception is thrown synchronously from the AuthorizationService constructor, so the JS promise rejects. Crucially, the bind counter only resets when the process dies — so from that moment on every subsequent call fails for the rest of the process lifetime. In a long-lived app the user is silently unable to refresh their token until they force-stop it.
refresh() is by far the worst affected, because it is the call an app makes repeatedly and unattended — and it needs no browser at all. The Custom Tabs bind is pure collateral damage from the constructor.
Affected call sites
All six new AuthorizationService(...) in packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java on main (lines ~532, 614, 740, 753, 799, 849). None are disposed. The one in refreshWithConfiguration (~799) is the one that bites in practice.
Reproduction
- Android device with a Custom Tabs–capable default browser (seen with Samsung Internet).
- Call
refresh() repeatedly within a single process — e.g. an OP with a short access-token lifetime, refreshed on demand before API calls.
- After ~1000 refreshes without the process being killed, every
refresh() rejects with the error above and never recovers.
Observed in production on a device whose app process had stayed alive for ~10 days.
Suggested fix
Minimal, per call site — dispose in the response callback:
final AuthorizationService authService = new AuthorizationService(context, appAuthConfiguration);
AuthorizationService.TokenResponseCallback tokenResponseCallback = new AuthorizationService.TokenResponseCallback() {
@Override
public void onTokenRequestCompleted(@Nullable TokenResponse response, @Nullable AuthorizationException ex) {
authService.dispose();
// ...existing handling
}
};
Note that the authorize / endSession flows hand the service off to an Activity-based redirect flow, so disposal there has to happen after the redirect is handled rather than immediately.
Better still, mirror what flutter_appauth does with the same underlying net.openid:appauth library: keep a single lazily-created AuthorizationService, reuse it across calls, and dispose it on lifecycle teardown (onDetachedFromEngine in their case). See FlutterAppauthPlugin.java — createAuthorizationServices() / disposeAuthorizationServices(), plus their hardening PRs #170, #556 and #600.
Prior art
- auth0/Auth0.Android#517 — "Fix memory leak in CustomTabsService", the same class of leak in another OAuth SDK's Custom Tabs controller.
- openid/AppAuth-Android#1085, #91, #134, #166 — recurring reports around the bind/unbind side of
CustomTabManager.
expo-web-browser pairs bindCustomTabsService with unbindService and exposes warmUp / coolDown to callers, so the contract is well established elsewhere in the React Native ecosystem.
I'm happy to open a PR for the refresh / token-request paths if you'd like — let me know whether you'd prefer the minimal per-call dispose() or the cached-instance approach.
Environment
- Your Identity Provider: Keycloak (not provider-specific — any OP with short-lived access tokens will reach the threshold)
- Platform that you're experiencing the issue on: Android
- Your
react-native Version: 0.81.5 (Hermes, New Architecture)
- Your
react-native-app-auth Version: 8.1.0 — but confirmed still present on main / 8.4.1
- Are you using Expo? Yes
- Device: Samsung Galaxy Tab A7 (SM-T505), Android 12, default browser Samsung Internet
Issue
On Android, the native module creates
new AuthorizationService(...)at every call site and never callsdispose()on it. The stringdisposedoes not appear anywhere inRNAppAuthModule.java— I checkedmaintoday as well as the published 8.1.0 and 8.4.1 artifacts.AuthorizationService's constructor eagerly binds to the selected browser's Custom Tabs service:That bind is released only by
AuthorizationService.dispose()->CustomTabManager.dispose()->unbindService. AppAuth states the contract explicitly in theAuthorizationServicejavadoc:So each call through this library leaks one
ServiceConnectionto the default browser'sCustomTabsService. Android's ActivityManager caps outstanding bind requests per process/service at ~1000. Once that is crossed,bindServicethrows:The exception is thrown synchronously from the
AuthorizationServiceconstructor, so the JS promise rejects. Crucially, the bind counter only resets when the process dies — so from that moment on every subsequent call fails for the rest of the process lifetime. In a long-lived app the user is silently unable to refresh their token until they force-stop it.refresh()is by far the worst affected, because it is the call an app makes repeatedly and unattended — and it needs no browser at all. The Custom Tabs bind is pure collateral damage from the constructor.Affected call sites
All six
new AuthorizationService(...)inpackages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.javaonmain(lines ~532, 614, 740, 753, 799, 849). None are disposed. The one inrefreshWithConfiguration(~799) is the one that bites in practice.Reproduction
refresh()repeatedly within a single process — e.g. an OP with a short access-token lifetime, refreshed on demand before API calls.refresh()rejects with the error above and never recovers.Observed in production on a device whose app process had stayed alive for ~10 days.
Suggested fix
Minimal, per call site — dispose in the response callback:
Note that the
authorize/endSessionflows hand the service off to an Activity-based redirect flow, so disposal there has to happen after the redirect is handled rather than immediately.Better still, mirror what
flutter_appauthdoes with the same underlyingnet.openid:appauthlibrary: keep a single lazily-createdAuthorizationService, reuse it across calls, and dispose it on lifecycle teardown (onDetachedFromEnginein their case). SeeFlutterAppauthPlugin.java—createAuthorizationServices()/disposeAuthorizationServices(), plus their hardening PRs #170, #556 and #600.Prior art
CustomTabManager.expo-web-browserpairsbindCustomTabsServicewithunbindServiceand exposeswarmUp/coolDownto callers, so the contract is well established elsewhere in the React Native ecosystem.I'm happy to open a PR for the
refresh/ token-request paths if you'd like — let me know whether you'd prefer the minimal per-calldispose()or the cached-instance approach.Environment
react-nativeVersion: 0.81.5 (Hermes, New Architecture)react-native-app-authVersion: 8.1.0 — but confirmed still present onmain/ 8.4.1