Skip to content

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

@ikicodedev

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions