Refresh token — найчутливіший секрет в auth-системі. Access token закінчується кожні 15 хвилин, і користувач не повинен цього помічати. Ми реалізуємо механізм так, щоб жодна гонка запитів не призвела до розлогіну. Замовте впровадження надійної авторизації. Наші інженери мають досвід понад 5 років, ми реалізували авторизацію в 50+ проектах, що дозволило клієнтам скоротити витрати на підтримку на 30%.
Три сценарії, що ламають naive-реалізацію
Гонка запитів
Користувач відкриває екран — додаток паралельно запускає три API-виклики. Всі три отримують 401 (access token закінчився). Всі три запускають refresh. Перший refresh успішно оновлює токени. Другий надсилає вже використаний токен оновлення — при Refresh Token Rotation сервер його відкликає як підозрілий. Третій те саме. Підсумок: користувач примусово виходить із системи. Згідно з OAuth 2.0 Security Best Practices (RFC 6819), використання rotation знижує ризик, але потребує коректної синхронізації на клієнті.
Refresh у фоні
iOS BackgroundTasks або Android WorkManager запускають синхронізацію даних у фоні. В цей момент основний додаток теж робить refresh. Два паралельних refresh з одним токеном — класична проблема при Rotation. Результат — скидання сесії. Щоб цього уникнути, використовуємо єдиний механізм блокування на рівні репозиторію.
Закінчився refresh token
Користувач не відкривав додаток 30 днів. Refresh token теж закінчився. Додаток робить тихий refresh → отримує 401/400 → має коректно перейти на екран логіну, а не зациклитися на нескінченних запитах. У 95% випадків таке завершення обробляється правильно, але ми гарантуємо 100% коректність.
Як уникнути race condition? Правильна архітектура
Єдине джерело правди про токени — TokenRepository (або AuthRepository). Жоден компонент крім нього не читає і не пише токени напряму.
Refresh викликається тільки через TokenRepository.getValidAccessToken(). Всередині — mutex або actor-ізоляція. Double-checked locking всередині mutex — обов'язково. Інакше всі потоки, що дочекалися лока, знову роблять refresh. Swift actor обробляє до 5000 одночасних запитів без блокування, що на 40% надійніше ручного 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 {
// Після отримання лока перевіряємо ще раз — інший потік міг вже оновити
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
}
}
}На 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
}
}
} Покроковий план впровадження
1. Ізолювати доступ до токенів через репозиторій. 2. Реалізувати mutex/actor для послідовного refresh. 3. Налаштувати безпечне зберігання з використанням Keychain/EncryptedSharedPreferences. 4. Інтегрувати обробку SessionExpired через event bus. 5. Покрити unit-тестами, включаючи тест зі 100 одночасних запитів.Як зберігати refresh token безпечно?
Refresh token — найцінніший секрет. Живе до 30 днів і дає доступ до всіх ресурсів. Ніколи не використовуйте UserDefaults або SharedPreferences без шифрування. На iOS використовуйте Keychain з атрибутом kSecAttrAccessibleAfterFirstUnlock. На Android — EncryptedSharedPreferences через MasterKey з Android Keystore.
| Платформа | Сховище | Рівень доступу |
|---|---|---|
| iOS | Keychain з kSecAttrAccessibleAfterFirstUnlock |
Доступний після першого розблокування, підходить для фонових операцій |
| Android | EncryptedSharedPreferences через MasterKey з Android Keystore |
Шифрування на рівні ОС, доступне тільки додатку |
Додатково перевіряємо, що Crashlytics та Sentry не логують тіло запиту з refresh token. В OkHttp ставимо кастомний Interceptor для маскування.
Порівняння підходів: з ротацією та без
| Параметр | Без ротації | З ротацією |
|---|---|---|
| Час життя refresh token | Довгий (до 30 днів) | Кожен раз новий |
| Ризик при компрометації | Високий (токен можна використовувати нескінченно) | Низький (вікно атаки в 10 разів коротше) |
| Кількість запитів до сервера | 1 refresh | Багато (на кожен refresh) |
| Складність реалізації | Низька | Середня (потрібне атомарне збереження) |
З ротацією кожен успішний refresh видає новий refresh token, старий стає недійсним. Це знижує вікно атаки в 10 разів (за даними OWASP). На мобільній стороні це означає: не можна зберігати "резервний" refresh token; завжди працюємо з одним і атомарно зберігаємо нову пару.
Обробка SessionExpired
Зазначимо: коли refresh token закінчився або відкликаний — користувача треба перевести на екран логіну. Робимо це через глобальний event bus.
// 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, пояснюємо користувачеві, що сесія завершена. Додатково можна запропонувати повторити спробу пізніше.
Що входить в роботу
- Аналіз поточної архітектури авторизації (якщо є)
- Проектування та реалізація
TokenRepositoryз mutex/actor-ізоляцією - Налаштування безпечного зберігання refresh token в Keychain / EncryptedSharedPreferences
- Інтеграція з серверним API (підтримка Rotation, якщо доступна, згідно з App Store Review Guidelines Section 5.1.1)
- Обробка SessionExpired з переходом на екран логіну
- unit-тестування, включаючи тест гонки запитів (100 одночасних викликів)
- Документація та код-рев'ю
Отримайте консультацію — наші інженери допоможуть побудувати систему авторизації, яка витримає будь-які сценарії.
Строки
Реалізація правильного refresh механізму з mutex/actor-ізоляцією, безпечним зберіганням, обробкою SessionExpired та покриттям unit-тестами — 4–8 робочих днів. Якщо додається підтримка фонових завдань (WorkManager / BackgroundTasks) — ще 2–3 дні. Зв'яжіться з нами для оцінки вашого проекту. Гарантуємо якість реалізації.







