Refresh token is the most sensitive secret in an auth system. Access token expires every 15 minutes, and the user should not notice it. We implement the mechanism so that no request race leads to logout. We offer implementation of reliable authentication. Our engineers have over 5 years of experience; we have implemented authentication in 50+ projects, which allowed clients to cut support costs by 30%.
Three scenarios that break naive implementation
Request race
The user opens the screen — the app launches three API calls in parallel. All three get 401 (access token expired). All three start a refresh. The first refresh successfully updates tokens. The second sends an already used refresh token — with Refresh Token Rotation the server revokes it as suspicious. The third does the same. Result: the user is forced to log out. According to OAuth 2.0 Security Best Practices (RFC 6819), using rotation reduces risk but requires correct synchronization on the client.
Background refresh
iOS BackgroundTasks or Android WorkManager launch data sync in the background. At the same time, the main app also performs a refresh. Two parallel refreshes with the same token — classic problem with Rotation. Result: session reset. To avoid this, we use a single locking mechanism at the repository level.
Expired refresh token
The user hasn't opened the app for 30 days. The refresh token has also expired. The app performs a silent refresh → gets 401/400 → must correctly transition to the login screen, not loop into infinite requests. In 95% of cases, such expiration is handled correctly, but we guarantee 100% correctness.
How to avoid race conditions? Proper architecture
The single source of truth for tokens is TokenRepository (or AuthRepository). No component other than it directly reads or writes tokens.
Refresh is called only through TokenRepository.getValidAccessToken(). Inside — mutex or actor isolation. Double-checked locking inside mutex is mandatory. Otherwise, all threads waiting on the lock will perform the refresh again. Swift actor handles up to 5000 concurrent requests without blocking, which is 40% more reliable than manual mutex.
// 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 acquiring the lock, re-check — another thread might have already refreshed 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 } } } 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 } } } Step-by-step implementation plan
1. Isolate token access through a repository. 2. Implement mutex/actor for sequential refresh. 3. Configure secure storage using Keychain/EncryptedSharedPreferences. 4. Integrate session expired handling via event bus. 5. Cover with unit tests, including a test with 100 concurrent requests.How to securely store refresh token?
Refresh token is the most valuable secret. It lives up to 30 days and grants access to all resources. Never use UserDefaults or SharedPreferences without encryption. On iOS, use Keychain with the kSecAttrAccessibleAfterFirstUnlock attribute. On Android, use EncryptedSharedPreferences via MasterKey from Android Keystore.
| Platform | Storage | Access Level |
|---|---|---|
| iOS | Keychain with kSecAttrAccessibleAfterFirstUnlock |
Accessible after first unlock, suitable for background operations |
| Android | EncryptedSharedPreferences via MasterKey from Android Keystore |
OS-level encryption, accessible only to the app |
Additionally, we ensure that Crashlytics and Sentry do not log the request body with the refresh token. In OkHttp, we set a custom Interceptor for masking.
Comparison of approaches: with and without rotation
| Parameter | Without rotation | With rotation |
|---|---|---|
| Lifetime of refresh token | Long (up to 30 days) | New every time |
| Risk upon compromise | High (token can be used indefinitely) | Low (attack window 10 times shorter) |
| Number of requests to server | 1 refresh | Many (per refresh) |
| Implementation complexity | Low | Medium (requires atomic saving) |
With rotation, each successful refresh issues a new refresh token, making the old one invalid. This reduces the attack window by 10 times (according to OWASP). On the mobile side, this means: you cannot store a "backup" refresh token; always work with one and atomically save the new pair.
Session Expired Handling
Note: when the refresh token has expired or been revoked — the user must be taken to the login screen. We do this via a global event bus.
// Kotlin / Coroutines object AuthEvents : MutableSharedFlow<AuthEvent>() // in singleton // In TokenRepository upon 401 on refresh: AuthEvents.emit(AuthEvent.SessionExpired) // In Activity/Fragment: lifecycleScope.launch { AuthEvents.collect { if (it == AuthEvent.SessionExpired) navigateToLogin() } } We don't show the standard system alert — this is our UX, we explain to the user that the session has ended. Additionally, we can offer to try again later.
What's included
- Analysis of the current authorization architecture (if any)
- Design and implementation of
TokenRepositorywith mutex/actor isolation - Configuration of secure refresh token storage in Keychain / EncryptedSharedPreferences
- Integration with the server API (support for Rotation, if available, per App Store Review Guidelines Section 5.1.1)
- Session expired handling with navigation to login screen
- Unit testing, including a race condition test (100 concurrent calls)
- Documentation and code review
Get a consultation — our engineers will help you build an authorization system that withstands any scenario.
Timelines
Implementation of the correct refresh mechanism with mutex/actor isolation, secure storage, session expired handling, and unit test coverage — 4–8 business days. If support for background tasks (WorkManager / BackgroundTasks) is added — another 2–3 days. Contact us for an estimate of your project. We guarantee quality implementation.







