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
- Subscribe to a Realtime channel (e.g. via
supabase.channel('x').onPostgresChanges(...).subscribe()).
- Let the device sleep / page go to background long enough for the access token's
exp to lapse.
- 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.
- 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).
Summary
RealtimeChannel'sjoinPush.receive('ok', ...)callback inrealtime_channel.dart(line ~156 in v2.4.2, ~179 onmain) calls:setAuththrowsFormatException('InvalidJWTToken: Invalid value for JWT claim "exp" with value …')when the cachedsocket.accessTokenhas already expired. The callback isasyncand has notry/catch, so the exception escapes through thePush.triggerasync chain intorunZonedGuarded. With Sentry / Crashlytics installed it is logged as a fatal unhandled error, even though the app keeps running and the nexttokenRefreshedevent recovers normally.The supabase wrapper already handles this case for the
_handleTokenChangedpath:…but the
joinPush.receive('ok', ...)re-auth path bypasses that wrapper.Reproduction
supabase.channel('x').onPostgresChanges(...).subscribe()).expto lapse.joinPushreceives'ok', and the callback callssetAuthwith the stale token before the SDK gets a chance to refresh it.FormatExceptionpropagates 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 onmain, so it should still reproduce on^2.10.Stack (de-minified, from a Sentry event):
Suggested fix
Wrap the
setAuthcall in the'ok'callback with the sameFormatException/InvalidJWTTokenfilter the supabase wrapper uses, so that an expired cached token at re-subscribe time is silently swallowed (the auth refresh listener will re-callsetAuthwith a fresh token shortly after):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.3realtime_client: 2.4.2gotrue: 2.x