The Token Refresh Machinery, and How Much of It Auth0 Already Does
TL;DR
- A thrown error inside credential refresh cascades all the way into a full logout.
- I built retry, a singleton, and a cooldown around refresh — then found
getCredentialsalready renews automatically. - The perpetual loading screen was an expired refresh token returning undefined with nowhere to go.
- Auth0 never proactively revokes; an expired refresh token simply fails on use.
- Testing this properly means mocking Auth0 and asserting a revoke follows, not corrupting tokens by hand.
A day on the refresh path, which started with a self-inflicted wound and ended somewhere more useful.
The self-inflicted part
My apps wouldn’t connect to the backend. Metro was on 8081 while my environment variables said 8080. Changed them, rebuilt, still broken — because I’d then pointed the app at the Metro server rather than the actual backend, which wasn’t running. Classic.
The error path that logs users out
With the backend actually up, the real problem: our Auth0 library blocks credential refreshes, and it refreshes on its own schedule, which means it fails exactly when tokens are near or past expiry.
Tracing our code:
loadCredentialstriggers the fetch.- Errors go to a handler that routes the ones it recognises; the rest throw.
- A thrown error bubbles up into a logout, firing Auth0’s WebAuth clear-session call and tearing down credentials.
So a transient refresh failure logs the user out. iOS at least shows an alert they can back out of. Android just goes, and we can’t fine-tune that without a custom solution.
What I built
Three mechanisms, in order.
Retry. Access token retrieval now retries up to three times, 500ms apart, to absorb transient connectivity problems without hammering Auth0.
A singleton TokenManager. Only one promise may manage a credentials refresh at a time. Concurrent refreshes corrupt state and burn rate limit. The method checks for an in-flight promise and returns that rather than starting a new request.
A 1000ms cooldown. If no promise exists and the cooldown has elapsed, a refresh proceeds.
What I then read
Auth0’s docs on getCredentials confirm it already handles renewal automatically using the stored refresh token. That doesn’t make the singleton useless — it still collapses concurrent callers into one request — but some of my refresh logic is duplicating the library’s, and I need to know exactly where that boundary sits before adding more.
This matters more than it sounds. Half the behaviour I was debugging was mine competing with Auth0’s.
The double-logging that was correct
On iOS, a single pull-to-refresh produced multiple “waiting for existing refresh operation” logs. I assumed one API call meant one refresh promise, so this looked broken.
It wasn’t. Both the backlog and another service, hills, called endpoints simultaneously, so two callers legitimately queued behind one refresh. The singleton was doing its job and saying so.
Which leaves the question I ended on: if the singleton already collapses concurrent refreshes, what is the cooldown buying me? I’d rather remove it than carry a mechanism I can’t justify.
The perpetual loading screen
Android sat on a black screen; iOS sat on a loading screen that never resolved. Same underlying story.
I’d half-assumed Auth0 revokes tokens server-side at expiry. It doesn’t — an expired refresh token simply fails when used. Our test phone had been idle long enough for the refresh token lifetime to run out, which I confirmed against the calendar. Plausible if Idana hadn’t picked up the device inside the window.
So: token expired, refresh attempted, refresh failed, and the error returned undefined instead of routing to re-login. Undefined propagated into a loading state with no exit.
Two gaps, only one about tokens. There’s no hard rethrow and no checkpoint saying “refresh failed, start a login” — undefined shouldn’t be a survivable return value here. And there was my assumption that expiry gets handled for us.
Cleaning up the error handling first, routing through handleAuthError rather than returning unhandled errors, at least made the logs readable. The app then froze at “error during credential refresh” and dropped into a logout it shouldn’t have entered, with “no previous credentials” printing during initial load — which points at loadCredentials.
Testing what doesn’t happen on its own
The baseline works: logging out on iOS successfully revoked the token, El Tokarino as I’ve been calling it.
The hard part is that the logs look consistently smooth, so I can’t distinguish robust error handling from error handling that has never been exercised.
I went looking for where to inject a failure. getOrRefreshCredentials was the obvious candidate, but I moved to the web authorization step to see whether an interruption there gets caught or sails past something important. The constraint: whatever I force has to correspond to something that could actually happen in production, or the test proves nothing.
The approach that worked came to me after a break. Mock Auth0’s functions, make one throw, assert that a revoke follows. That tests the security property I care about rather than my ability to corrupt a token by hand.
One useful thing learned along the way: max_age governs how long before a user must re-authenticate, and it works against the auth_time claim — which ID tokens already carry, so I don’t need to track it myself.
Next is refresh token rotation, and deciding which of my three mechanisms survive now that I know what the library covers.
ryer.io