Skip to content

realtime_client: setAuth call inside joinPush 'ok' callback can throw uncaught FormatException for expired token #1363

Description

@ikicodedev

Summary

RealtimeChannel's joinPush.receive('ok', ...) callback in realtime_channel.dart (line ~156 in v2.4.2, ~179 on main) calls:

if (socket.accessToken != null) {
  await socket.setAuth(socket.accessToken);
}

setAuth throws FormatException('InvalidJWTToken: Invalid value for JWT claim "exp" with value …') when the cached socket.accessToken has already expired. The callback is async and has no try/catch, so the exception escapes through the Push.trigger async chain into runZonedGuarded. With Sentry / Crashlytics installed it is logged as a fatal unhandled error, even though the app keeps running and the next tokenRefreshed event recovers normally.

The supabase wrapper already handles this case for the _handleTokenChanged path:

try {
  await realtime.setAuth(token);
} on FormatException catch (e) {
  if (e.message.contains('InvalidJWTToken')) {
    // ignored
  } else {
    rethrow;
  }
}

…but the joinPush.receive('ok', ...) re-auth path bypasses that wrapper.

Reproduction

  1. Subscribe to a Realtime channel (e.g. via supabase.channel('x').onPostgresChanges(...).subscribe()).
  2. Let the device sleep / page go to background long enough for the access token's exp to lapse.
  3. Resume — the channel reconnects, joinPush receives 'ok', and the callback calls setAuth with the stale token before the SDK gets a chance to refresh it.
  4. The FormatException propagates out of the async callback to whatever zone error handler is installed.

Observed in our Flutter Web app on realtime_client: 2.4.2 / supabase: 2.6.3. Code path is unchanged on main, so it should still reproduce on ^2.10.

Stack (de-minified, from a Sentry event):

realtime_client/src/realtime_channel.dart   joinPush.receive 'ok' callback
realtime_client/src/realtime_client.dart    setAuth → throw FormatException('InvalidJWTToken …')

Suggested fix

Wrap the setAuth call in the 'ok' callback with the same FormatException / InvalidJWTToken filter the supabase wrapper uses, so that an expired cached token at re-subscribe time is silently swallowed (the auth refresh listener will re-call setAuth with a fresh token shortly after):

joinPush.receive('ok', (response) async {
  final serverPostgresFilters = response['postgres_changes'];
  if (socket.accessToken != null) {
    try {
      await socket.setAuth(socket.accessToken);
    } on FormatException catch (e) {
      if (!e.message.contains('InvalidJWTToken')) rethrow;
      // Stale cached token — auth refresh will re-call setAuth shortly.
    }
  }
  // …rest of handler
});

Happy to send a PR if useful — just want to confirm the desired behaviour first (silent swallow, vs. emitting on the channel's status callback).

Versions

  • supabase: 2.6.3
  • realtime_client: 2.4.2
  • gotrue: 2.x
  • Flutter 3.x, web (dart2js, release).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions