Implementing Refresh Token Mechanism in Mobile App
Refresh token — most underestimated part of auth system. Access token expires — user shouldn't notice. In practice, half of "app logs out user" problems come from incorrect refresh mechanism.
Three scenarios breaking naive implementation
Request race. User opens screen — app launches three API calls in parallel. All three get 401 (access token expired). All three start refresh. First refresh successfully updates tokens. Second sends already used refresh token — with Refresh Token Rotation server revokes it as suspicious. Third same. Result: user force logged out even though access token really just expired.
Background refresh. iOS BackgroundTasks or Android WorkManager starts data sync in background. At same time main app also does refresh. Two parallel refresh with one token — classic problem with Rotation.
Expired refresh token. User didn't open app 30 days. Refresh token also expired. App does silent refresh → gets 401/400 → should correctly go to login screen, not loop on endless requests.
Correct architecture
Only source of truth for tokens — TokenRepository (or AuthRepository). No other component reads/writes tokens directly.
Refresh called only via TokenRepository.getValidAccessToken(). Inside — mutex or actor isolation:
// Android / Kotlin
class TokenRepository(
private val api: AuthApi,
private val storage: TokenStorage
) {
private val refreshMutex = Mutex()
private var refreshJob: Deferred<String>? = null
suspend fun getValidAccessToken(): String {
val current = storage.getAccessToken()
if (current != null && !current.isExpired()) return current
return refreshMutex.withLock {
// After getting lock re-check — another thread might have refreshed already
val refreshed = storage.getAccessToken()
if (refreshed != null && !refreshed.isExpired()) return@withLock refreshed
val newTokens = api.refresh(storage.getRefreshToken()
?: throw SessionExpiredException())
storage.saveTokens(newTokens)
newTokens.accessToken
}
}
}
Double-checked locking inside mutex — mandatory. Otherwise all threads waiting lock do refresh again.
On iOS with Swift Concurrency — actor:
actor TokenStore {
private var isRefreshing = false
private var waiters: [CheckedContinuation<String, Error>] = []
func getValidToken(refresher: AuthService) async throws -> String {
let stored = storage.accessToken
if let token = stored, !token.isExpired { return token.value }
if isRefreshing {
return try await withCheckedThrowingContinuation { waiters.append($0) }
}
isRefreshing = true
do {
let tokens = try await refresher.refresh(storage.refreshToken)
storage.save(tokens)
waiters.forEach { $0.resume(returning: tokens.accessToken) }
waiters.removeAll()
isRefreshing = false
return tokens.accessToken
} catch {
waiters.forEach { $0.resume(throwing: error) }
waiters.removeAll()
isRefreshing = false
throw error
}
}
}
Refresh Token storage
Refresh token — most sensitive secret. Lives longer, gives more rights (get new access token).
- iOS: Keychain with
kSecAttrAccessibleAfterFirstUnlock(accessible after first unlock, including background ops) orkSecAttrAccessibleWhenUnlocked(only when unlocked, if background not needed). - Android: EncryptedSharedPreferences via
MasterKeyfrom Android Keystore.
Never log refresh token. Check that Crashlytics, Sentry and other SDKs don't capture HTTP requests with refresh token in body. In OkHttp — custom Interceptor with masking sensitive headers/body before passing to crashlytics.
Refresh Token Rotation
If server supports Rotation: each successful refresh returns new refresh token, old invalidated. This limits attack window on token compromise.
Consequence for mobile: cannot save "second" refresh token as backup. Always work with one, atomically save new pair after refresh.
SessionExpired handling
When refresh token expired or revoked — must tell user and move to login screen. Do this via global event bus or Notification/Flow:
// Kotlin / Coroutines
object AuthEvents : MutableSharedFlow<AuthEvent>() // in singleton
// In TokenRepository on 401 on refresh:
AuthEvents.emit(AuthEvent.SessionExpired)
// In Activity/Fragment:
lifecycleScope.launch {
AuthEvents.collect { if (it == AuthEvent.SessionExpired) navigateToLogin() }
}
Don't show standard system alert — this is our UX, explain to user that session finished.
Timeframe
Implementing correct refresh mechanism with mutex/actor isolation, correct storage, SessionExpired handling, and unit test coverage (including request race test) — 4–8 business days. If adding background task support (WorkManager/BackgroundTasks) — another 2–3 days.







