[Question]: JWT keeps expiring in background despite alwaysAutoRefresh=true on Supabase 3.1.4
General info
- [x] I checked the troubleshooting page for similar problems
What is your question?
Problem
I'm experiencing frequent JWT expiration issues in my Android application that primarily runs in the background. Despite configuring alwaysAutoRefresh = true, and enableLifecycleCallbacks = false, sessions aren't consistently refreshed when the app is in background state.
Configuration
I'm using Supabase version 3.1.4 with Kotlin. Here's my client configuration:
val client by lazy {
createSupabaseClient(
supabaseUrl = SUPABASE_URL,
supabaseKey = SUPABASE_KEY
) {
defaultSerializer = KotlinXSerializer(json)
install(Postgrest)
install(Realtime)
install(Auth) {
alwaysAutoRefresh = true
enableLifecycleCallbacks = false
}
install(Functions)
}
}
Questions
- Is there a recommended approach for handling token refreshing in Android apps that spend significant time in background state?
- Are there specific configuration options I'm missing to make the built-in refresh mechanism work properly in background?
Relevant log output (optional)
Error updating request status | Error: io.github.jan.supabase.postgrest.exception.PostgrestRestException: JWT expired
URL: https://XXXX.supabase.co/rest/v1/device_health_commands?id=eq.XXXX-XXXX&select=%2A
Headers: [Authorization=[Bearer XXXXXXXXXXXX], Prefer=[return=representation], Content-Profile=[public], apikey=[XXXXXXXXXXXX], X-Client-Info=[supabase-kt/3.1.4], Accept=[application/json], Accept-Charset=[UTF-8]]
Http Method: PATCH
Can you enable debug logs in the SupabaseClientBuilder and check for any Auth logs while its in background? Or when that exception happens
Supabase-Auth
- Here are all logs related to the Supabase-Auth .
- Wether I put the app in the background or not, I don't have anything else printed in the logs from Supabase-Auth.
2025-04-19 14:05:44.193 5794-5794 Supabase-Auth com.sapiensgo.android D Initializing Auth plugin...
2025-04-19 14:05:44.193 5794-5794 Supabase-Auth com.sapiensgo.android D Initialized Auth plugin
2025-04-19 14:05:44.194 5794-5829 Supabase-Auth com.sapiensgo.android I Loading session from storage...
2025-04-19 14:05:44.224 5794-5832 Supabase-Auth com.sapiensgo.android D Importing session UserSession(accessToken=XXXXXXXXX, refreshToken=XXXXXXXXX, providerRefreshToken=null, providerToken=null, expiresIn=604800, tokenType=bearer, user=UserInfo(appMetadata={"provider":"email","providers":["email"]}, aud=authenticated, confirmationSentAt=2024-12-13T08:18:52.259882Z, confirmedAt=2024-12-13T08:19:05.729970Z, createdAt=2024-12-13T08:18:52.232731Z, email=XXXXXXXXX, emailConfirmedAt=2024-12-13T08:19:05.729970Z, factors=[], id= XXXXXXXXX, identities=[Identity(id= XXXXXXXXX, identityData={"email":"XXXXXXXXX","email_verified":false,"phone_verified":false,"sub":"XXXXXXXXX"}, identityId=cdaa5549-a095-4133-9984-953f28d4568b, lastSignInAt=2024-12-13T08:18:52.251735Z, updatedAt=2024-12-13T08:18:52.251795Z, createdAt=2024-12-13T08:18:52.251795Z, provider=email, userId=XXXXXXXXX)], lastSignInAt=2025-04-17T09:38:53.203616321Z, phone=, role=authenticated, updatedAt=2025-04-17T09:38:53.206398Z, userMetadata={"email":"XXXXXXXXX","email_verified":false,"phone_verified":false,"sub":"XXXXXXXXX"}, phoneChangeSentAt=null, newPhone=null, emailChangeSentAt=null, newEmail=null, invitedAt=null, recoverySentAt=2025-04-17T09:38:43.439217Z, phoneConfirmedAt=null, actionLink=null), type=, expiresAt=2025-04-24T09:38:52.209139Z) from Storage, auto refresh is set to true.
2025-04-19 14:05:44.231 5794-5832 Supabase-Auth com.sapiensgo.android D Session saved to storage (auto refresh enabled)
2025-04-19 14:05:44.231 5794-5832 Supabase-Auth com.sapiensgo.android D Session imported successfully. Starting auto refresh...
2025-04-19 14:05:44.232 5794-5832 Supabase-Auth com.sapiensgo.android D Auto refresh started.
2025-04-19 14:05:44.232 5794-5829 Supabase-Auth com.sapiensgo.android D Refreshing session in 3d 18h 57m 7.976252s.
2025-04-19 14:05:44.233 5794-5832 Supabase-Auth com.sapiensgo.android I Successfully loaded session from storage!
JWT Event Logs
- Here are some breadcrumb logs from Sentry when we have an event of JWT expired
Timber
error
05:49:47.059 PM
Error getting phone number for SIM slot: 2 | Error: io.github.jan.supabase.postgrest.exception.PostgrestRestException: JWT expired
URL: https://XXXXXXXXX.supabase.co/rest/v1/phone_numbers?device_id=eq.XXXXXXXXX&sim_slot=eq.2&is_active=eq.true&select=%2A
Headers: [Authorization=[Bearer XXXXXXXXX], Content-Type=[application/json], Prefer=[], Accept-Profile=[public], apikey=[XXXXXXXXX], X-Client-Info=[supabase-kt/3.1.4], Accept=[application/json], Accept-Charset=[UTF-8]]
Http Method: GET
---------------------
---------------------
HTTP
info
05:49:45.606 PM
GET: https://XXXXXXXXX.supabase.co/rest/v1/phone_numbers [401]
{
host: wpvjenmnmvrueqieadzs.supabase.co,
http.end_timestamp: 1744998586790,
http.query: device_id=eq.XXXXXXXXX&sim_slot=eq.2&is_active=eq.true&select=%2A,
http.start_timestamp: 1744998585606,
path: /rest/v1/phone_numbers,
protocol: HTTP_2,
response_content_length: 88
}
---------------------
---------------------
SMSReceiver
debug
05:49:45.491 PM
Incoming SMS received on SIM slot: 2 | Subscription ID: 3
---------------------
---------------------
SMSReceiver
info
05:49:45.406 PM
Incoming SMS received | Starting processing
---------------------
---------------------
Device Event
info
05:49:40.234 PM
{
action: SCREEN_OFF,
extras: {
reason: 2,
why: 3
}
}
---------------------
---------------------
Device Event
info
05:49:39.754 PM
{
action: DREAMING_STARTED
}
---------------------
---------------------
Device Event
info
05:49:13.092 PM
{
action: SCREEN_ON,
extras: {
why: 2
}
}
---------------------
---------------------
Network Event
info
05:49:12.543 PM
{
action: NETWORK_CAPABILITIES_CHANGED,
download_bandwidth: 687,
network_type: wifi,
signal_strength: -54,
upload_bandwidth: 47025,
vpn_active: false
}
---------------------
---------------------
Device Event
info
05:49:12.478 PM
{
action: DREAMING_STOPPED
}
---------------------
---------------------
I had this happen to me today too. Unfortunately, I wasn't logging the session status.
Error message:
Received message without event: RealtimeMessage(topic=realtime:db-changes, event=system, payload={"message":"Token has expired 0 seconds ago","status":"error","extension":"system","channel":"db-changes"}, ref=null)
The app was in the foreground for 7 minutes when this happened. Otherwise, I don't see anything relevant in my logs.
I'm still working out issues with subscriptions, so I can't say if this is a regression.
Could this bug be related to the previously fixed issue in v2.4.3 – Major bug fix? Specifically the one described as:
“Fix a major bug causing sessions to be refreshed later than they should be”
No. The session expires sometime in the background and the problem is hard to pin point without logs around the time of expiration. Looking into it.
@Yahouedeou Do you have a reproducer? Or how is your app run in the background?
@jan-tennert
Reproducer
- I don't have a simple reproducer for this issue. Our app uses standard Android background services with WakeLock to keep running when the app isn't in the foreground. We are not using an external library for background processes. I could share with you the GitRepo if needed.
More context around the JWT issue
-
At the beginning we used the default JWT Access token expiry time settings and had some issues, so recently increased the JWT Access token expiry time to 604800s but still we are experiencing issues with background sessions not refreshing.
-
Yesterday I did some tests, and by setting a very short JWT Access token expiry (120s), the session refresh works perfectly when the app is in the background. We can see the refresh operations happening in our logs.
-
This makes me wonder if there's something about how Supabase handles longer lived tokens, or if we need to implement something specific to better handles tasks in the background.
-
Our application is running 99% of the time in background mode with our users. The app has to be active/reacheable in order to receive FCM messages from our Backend.
What happens when the refresh fails? It looks like there's no retry, but I'm not confident on that. A lot of devices shut down the wifi after a period of inactivity. If that happens and auth tries to refresh the token, what happens?
It will retry on every exception except on rest exceptions (so on network fails and on internal server issues) https://github.com/supabase-community/supabase-kt/blob/4fd0c66812f55c6d093282a64cea615e3a35e8ff/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthImpl.kt#L460-L476
- From all the bugs we had, I never saw a retry.
- To give you an example, we just had a bug 5min ago on a device from a user, on the homepage of the application we are fetching data, every time the page loads. The data showed was empty, doesn't matter how much we reloaded the page. We had to completely close the app and launch it again to get the session refreshed.
- And here is a sentry logs regarding another case when calling a edge function, but with the same sessions with the JWT expired.
From all the bugs we had, I never saw a retry.
Well a retry is not necessarily common, if there is never a server issue or the internet is always on there won't be a retry ever.
To give you an example, we just had a bug 5min ago on a device from a user, on the homepage of the application we are fetching data, every time the page loads. The data showed was empty, doesn't matter how much we reloaded the page. We had to completely close the app and launch it again to get the session refreshed.
Well logs on that would be helpful
And here is a sentry logs regarding another case when calling a edge function, but with the same sessions with the JWT expired.
But the logs show a different error: invalid jwt?
In any case what we can do is add more checks to the session in generel. For example:
- Check if the session is expired when it's accessed and if it is, then log some data like if the session job is still running etc, then force a refresh before making a request
@sproctor This should also prevent your issue for now and we gain more information about why the auto refresh might be stopped
Afaik, supabase doesn't have an option to allow for leeway in tokens. If you do a check before a request, make sure they have some time, like 10 - 30 seconds, before they expire.
Afaik, supabase doesn't have an option to allow for leeway in tokens. If you do a check before a request, make sure they have some time, like 10 - 30 seconds, before they expire.
Yea I'd use a similar threshold to the auto refresh timer, but a little bit less obviously so its not clashing
Experiencing the same issue, after some time in background, the jwt is invalid/expired and not refreshed automatically. closing the app and relaunching seems the only way to refresh it after it occurs.
@Yahouedeou did you find any workarounds or have gathered more info?
The background refresh still didn't work properly in my case, especially when the app is in the background for a long time @fethij
-
The solution I implemented is to force the check of the session expiry, every time before calling the DB / Edge functions. Meaning that I check if the expiry date is already past or due soon.
-
When refreshing session, you have to make sure to also you also import the new refreshed session, otherwise this new session will not be used in your app.
val refreshToken = currentSession.refreshToken
// Use the explicit refresh token method
val refreshedSession = SupabaseClient.client.auth.refreshSession(refreshToken = refreshToken)
// Explicitly import the refreshed session to ensure it's used as the current session
SupabaseClient.client.auth.importSession(refreshedSession)
For Supabase improvements, a few things could be automated/implemented, or optional configurations:
- When a session is refreshed, automatically import it.
- When a session has JWT expired, try to refresh the current session. cc @jan-tennert
When a session is refreshed, automatically import it.
You can use refreshCurrentSession() for that
When a session has JWT expired, try to refresh the current session.
Yea that should be happening automatically, other solutions like checking the session before a request or refreshing upon an error response both do not seem ideal especially because they just fix an issue without actually fixing the issue which is the automatic refresh mechanism
I'm also experiencing this issue with JWT expiration errors on both Android and iOS.
Errors I'm seeing:
io.github.jan.supabase.postgrest.exception.PostgrestRestException: JWT expired
Code: PGRST301
Hint: null
Details: null
URL: https://api.pushscroll.com/rest/v1/billing?user_id=eq.cd375b03-e1c6-40f0-bfa0-84742e83d92e&limit=1&select=%2A
Headers: [Authorization=[Bearer..], Content-Type=[application/json], Prefer=[], Accept-Profile=[public], apikey=[..-s], X-Client-Info=[supabase-kt/3.2.5], X-Supabase-Client-Platform=[iOS], X-Supabase-Client-Platform-Version=[18.6.2], Accept=[application/json], Accept-Charset=[UTF-8]]
Http Method: GET
io.github.jan.supabase.postgrest.exception.PostgrestRestException: JWT expired Code: PGRST301BadRequestRestException: JWT expired Code: PGRST301
Debug Information:
I added this debug function to help troubleshoot:
internal fun debugInfo(): Map<String, Any> {
val sessionStatus = when (val status = supabase.auth.sessionStatus.value) {
is SessionStatus.Authenticated -> {
"Authenticated(isNew=${status.isNew}, source=${status.source}, emptyAccessToken=${status.session.accessToken.isEmpty()}, type=${status.session.type}, expiresIn=${status.session.expiresIn}, userIsNull=${status.session.user == null})"
}
is SessionStatus.NotAuthenticated,
is SessionStatus.Initializing,
is SessionStatus.RefreshFailure -> {
status.toString()
}
}
val sessionIsNull = supabase.auth.currentSessionOrNull() == null
val userIsNull = supabase.auth.currentUserOrNull() == null
return mapOf(
"sessionStatus" to sessionStatus,
"sessionIsNull" to sessionIsNull,
"userIsNull" to userIsNull,
)
}
Data from Sentry for sessionStatus:
sessionIsNull and userIsNull are always false
Additional Context:
- This doesn't only happen at app startup
- Looking at Sentry breadcrumbs, other requests from different SDKs succeeded in the past
- Affects both Android and iOS platforms
- Happy to share Sentry link privately if that helps with debugging
I don't have more details, but I also encounter this lost session issue recently. Was on v3.0.3 though, not sure if it matters or not.