Skip to content

[firebase_auth] getIdTokenResult() throws an uncatchable fatal exception on Android when the native token refresh fails on a background thread #18561

Description

@luanbatistadenga

Steps to Reproduce

We don't have a fully deterministic local repro — this surfaced in production via Crashlytics, and matches the same general class of bug as the closed #11311 ("Crash when calling user.getIdToken(true)"), but with a different (also uncaught) underlying FirebaseAuthException.

  1. Have a signed-in User on Android.
  2. Call user.getIdTokenResult(true) (force refresh) from a Dart try/catch block.
  3. Have the native token refresh fail — either due to network connectivity to securetoken.googleapis.com, or because the refresh token itself is no longer valid (user-token-expired / a generic [firebase_auth/unknown] "The user's credential is no longer valid. The user must sign in again." message).

Expected results

The FirebaseAuthException should be catchable by the surrounding Dart try/catch, the same way it is for a normal awaited Future rejection.

Actual results

The exception surfaces as an uncaught, fatal crash, even though the call site has an enclosing try/catch (and, in our case, correct typed classification of the exception's .code/.message in the catch block — none of which ever runs).

Crashlytics-reported stack trace (thread name is notable — the exception originates on a Firebase-managed background thread, not the calling Dart isolate/await chain):

Thread: Firebase Background Thread #3 (crashed)
io.flutter.plugins.firebase.crashlytics.FlutterError: [firebase_auth/unknown] The user's credential is no longer valid. The user must sign in again.. Error thrown .
  at ._extractReplyValueOrThrow (package:firebase_auth_platform_interface/src/pigeon/messages.pigeon.dart:26)
  at FirebaseAuthUserHostApi.getIdToken (package:firebase_auth_platform_interface/src/pigeon/messages.pigeon.dart:1988)
  at MethodChannelUser.getIdTokenResult (package:firebase_auth_platform_interface/src/method_channel/method_channel_user.dart:58)
  at FirebaseUserExtension.id (package:denga_mobile/src/core/data/extensions/firebase_user_extension.dart:5)
  at _AppState.authHandle (package:denga_mobile/src/app/app.dart:135)

A second variant of the exact same issue (same blame frame, FirebaseUserExtension.id) shows the network-failure flavor instead:

[firebase_auth/unknown] An internal error has occurred. [ Failed to connect to securetoken.googleapis.com/142.251.132.42:443. Error thrown .

Root cause (hypothesis)

The call site is wrapped like this:

Future<String> get id async {
  final result = await getIdTokenResult(); // previously getIdTokenResult(true)
  final id = result.claims?['apiId'] ?? uid;
  return id;
}

...called from inside a try { ... } catch (exception, s) { ... } block that already has correct, specific handling for this exact FirebaseAuthException (matching both typed .codes like user-token-expired and, as a fallback for the generic unknown code, the message text "credential is no longer valid" / "must sign in again"). That catch block never runs for these events — the exception is fatal at the OS/engine level instead.

Given the crashing thread is named "Firebase Background Thread #3" (not the Flutter UI/platform thread), this looks like the same general category of bug we independently found and reported for google_sign_in_android in #190939: a native SDK operation completes/errors on its own background thread, and the plugin's platform-channel reply path does not correctly hop back through the awaited Dart Future's normal error-propagation path, so the Dart-side try/catch around the await never gets a chance to run.

Mitigation applied on our side

We removed the forced refresh (getIdTokenResult(true) → getIdTokenResult()), since the underlying custom claim we read (apiId) is set once at account-creation time server-side and does not need an unconditional forced refresh on every auth-state change. This reduces how often this code path triggers a native token refresh at all, but does not fix the underlying issue: any code that legitimately needs forceRefresh: true (or that hits this during Android's normal background token refresh) can still hit an uncatchable fatal crash.

Versions

  • firebase_auth: 6.5.4
  • Flutter 3.44.9 (stable), Dart 3.12.2
  • Affected devices (observed via Crashlytics): multiple, Android only. 226 impacted users / 260 events over 7 days in production for this issue.
flutter doctor -v
[✓] Flutter (Channel stable, 3.44.9, on macOS, locale en-US)
    • Flutter version 3.44.9 on channel stable
    • Framework revision 6b182d2c75 (6 days ago) • 2026-08-05
    • Engine revision b9499e4c25212536ba3a4eec4f5c1905fb3214fe
    • Dart version 3.12.2 • DevTools 2.57.0

Activity

  1. SelaseKay commented on Aug 12, 2026

    @SelaseKay
    Contributor

    Hi @luanbatistadenga, thanks for the detailed report. I'm looking into it.

  2. SelaseKay commented on Aug 12, 2026

    @SelaseKay
    Contributor

    Hi @luanbatistadenga, thanks again for the detailed report and Crashlytics stacks.

    I've dug into this against the current firebase_auth / Crashlytics code paths, and here's what that stack is telling me.

    The top frame is io.flutter.plugins.firebase.crashlytics.FlutterError with the ... Error thrown . suffix. That type/message is created when a Dart error is recorded into Crashlytics (via recordError / recordFlutterFatalError with fatal: true) — it is not a native Firebase Auth abort on Firebase Background Thread #3.

    The frames below it are the normal pigeon path:

    _extractReplyValueOrThrow → FirebaseAuthUserHostApi.getIdToken → MethodChannelUser.getIdTokenResult → your id / authHandle

    So the platform reply did reach Dart and was turned into a FirebaseAuthException. Crashlytics then recorded that Dart failure as a fatal event (which is why the console shows it as a crash). The Firebase Background Thread #N label on these Flutter fatal reports is often Crashlytics/Firebase processing, not proof that Auth crashed that native thread.

    This pattern usually means:

    1. The Auth failure became an uncaught async error (for example the Future from getIdTokenResult / your id getter wasn’t awaited inside the try, or it escaped the zone), and
    2. A global handler recorded it as fatal — commonly:
    PlatformDispatcher.instance.onError = (error, stack) {
      FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
      return true;
    };

    and/or FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError.

    In that setup, Crashlytics will correctly show a fatal crash even though this isn’t a native process-killing Auth crash. A normal await getIdTokenResult(...) inside try/catch should be catchable; if catch never runs, the Future likely never failed inside that try.

    Could you double-check that authHandle actually awaits id / getIdTokenResult inside the try, and share how Crashlytics is wired in main()?

  3. added
    blocked: customer-responseWaiting for customer response, e.g. more information was requested.
    and removed
    Needs AttentionThis issue needs maintainer attention.
    type: crashA compile error or crash
    on Aug 12, 2026
  4. luanbatistadenga commented on Aug 12, 2026

    @luanbatistadenga
    Author

    You were right, and this was a useful correction — thank you.

    I traced every call site of the id getter in our app. _AppState.authHandle (the one in the original report) does correctly await inside its try, and its catch block already classifies both observed message variants (a network-connect failure and a revoked-session failure) without ever reaching a fatal report.

    The actual source turned out to be a different, unrelated call site: UserStore.clearCurrentUserCache(), which is invoked without await from a failure handler, and had no try/catch around its own call to getIdTokenResult. Since nothing awaits that Future, any rejection there is a genuinely uncaught async error with no chance for any local catch to intercept it — it goes straight to our global zone handler, which reports everything that reaches it as fatal with no classification. That fully explains why both message variants ended up unclassified: they never went through authHandle's catch at all.

    So: not a native-thread escape in the plugin, just an ordinary unawaited-Future bug in our own code. Fixed here: denga-app/mobile#434. Leaving this issue open only as background evidence that this general failure class (an Auth call failing during a background/refresh path) exists in the wild — no ask on your end beyond what's already here.

  5. added
    Needs AttentionThis issue needs maintainer attention.
    and removed
    blocked: customer-responseWaiting for customer response, e.g. more information was requested.
    on Aug 12, 2026
  6. SelaseKay commented on Aug 12, 2026

    @SelaseKay
    Contributor

    Hi @luanbatistadenga, thanks for confirming. Closing issue as it doesn't require a fix on our end.

  7. added
    resolution: userThis was a user issue, e.g. invalid configuration or code.
    and removed
    Needs AttentionThis issue needs maintainer attention.
    on Aug 12, 2026
  8. locked and limited conversation to collaborators on Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    platform: androidIssues / PRs which are specifically for Android.plugin: authresolution: userThis was a user issue, e.g. invalid configuration or code.type: bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions