Реализация Refresh Token механизма в мобильном приложении
Refresh token — самая недооценённая часть auth-системы. Access token заканчивается — пользователь не должен этого замечать. На практике половина проблем "приложение разлогинивает" происходит именно от неправильного refresh-механизма.
Три сценария, которые ломают naive-реализацию
Гонка запросов. Пользователь открывает экран — приложение параллельно запускает три API-вызова. Все три получают 401 (access token истёк). Все три запускают refresh. Первый refresh успешно обновляет токены. Второй отправляет уже использованный refresh token — при Refresh Token Rotation сервер его отзывает как подозрительный. Третий то же самое. Итог: пользователь принудительно выходит из системы, хотя access token реально истёк только что.
Refresh в фоне. iOS BackgroundTasks или Android WorkManager запускают синхронизацию данных в фоне. В этот момент основное приложение тоже делает refresh. Два параллельных refresh с одним токеном — классическая проблема при Rotation.
Истёкший refresh token. Пользователь не открывал приложение 30 дней. Refresh token тоже истёк. Приложение делает тихий refresh → получает 401/400 → должно корректно перейти на экран логина, а не зациклиться на бесконечных запросах.
Правильная архитектура
Единственный источник правды о токенах — TokenRepository (или AuthRepository). Никакой компонент кроме него не читает и не пишет токены напрямую.
Refresh вызывается только через TokenRepository.getValidAccessToken(). Внутри — mutex или actor-изоляция:
// 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 {
// После получения лока перепроверяем — другой поток мог уже обновить
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 внутри mutex — обязательно. Иначе все потоки, дождавшиеся лока, снова делают refresh.
На iOS с 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
Refresh token — самый чувствительный секрет. Живёт дольше, даёт больше прав (получить новый access token).
- iOS: Keychain с
kSecAttrAccessibleAfterFirstUnlock(доступен после первой разблокировки, включая фоновые операции) илиkSecAttrAccessibleWhenUnlocked(только при разблокированном экране, если фон не нужен). - Android: EncryptedSharedPreferences через
MasterKeyиз Android Keystore.
Никогда не логируем refresh token. Проверяем, что Crashlytics, Sentry и другие SDK не захватывают HTTP-запросы с refresh token в теле. В OkHttp — кастомный Interceptor с маскировкой sensitive headers/body перед передачей в crashlytics.
Refresh Token Rotation
Если сервер поддерживает Rotation: каждый успешный refresh возвращает новый refresh token, старый инвалидируется. Это ограничивает окно атаки при компрометации токена.
Последствие для мобилки: нельзя сохранить "второй" refresh token как резервный. Всегда работаем с одним, атомарно сохраняем новую пару после refresh.
Обработка SessionExpired
Когда refresh token истёк или отозван — пользователю надо сообщить и перевести на экран логина. Делаем это через глобальный event bus или Notification/Flow:
// Kotlin / Coroutines
object AuthEvents : MutableSharedFlow<AuthEvent>() // в singleton
// В TokenRepository при 401 на refresh:
AuthEvents.emit(AuthEvent.SessionExpired)
// В Activity/Fragment:
lifecycleScope.launch {
AuthEvents.collect { if (it == AuthEvent.SessionExpired) navigateToLogin() }
}
Не показываем стандартный системный alert — это наш UX, объясняем пользователю, что сессия завершена.
Сроки
Реализация правильного refresh механизма с mutex/actor-изоляцией, корректным хранением, обработкой SessionExpired и покрытием unit-тестами (включая тест гонки запросов) — 4–8 рабочих дней. Если добавляется поддержка фоновых задач (WorkManager / BackgroundTasks) — ещё 2–3 дня.







