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 дні. Зв'яжіться з нами для оцінки вашого проекту. Гарантуємо якість реалізації.
Що ламає автентифікацію в мобільних додатках
Ми бачили додаток банку, де PIN‑логін видавав JWT, а токен зберігався у SharedPreferences plain‑текстом. Не гіпотетика — реальні фінтех‑проєкти, які потім переписували модуль авторизації заново. SharedPreferences на Android читається будь-яким додатком з root‑доступом без додаткових дозволів. На iOS аналог — UserDefaults замість Keychain. Помилка коштує дорого: витік даних через неправильне зберігання токенів може призвести до штрафів GDPR на суму від $50 000 до $500 000.
Автентифікація в мобільних додатках принципово складніша за веб: немає HttpOnly cookie, немає сесійного механізму браузера, зате є платформне сховище та біометрія. Ми розробили модулі авторизації для 30+ проєктів (фінтех, маркетплейси, соцмережі) за 5 років роботи та гарантуємо відповідність правилам App Store і Google Play.
Як захистити токени при OAuth 2.0 автентифікації?
iOS Keychain — зашифроване сховище на рівні ОС. Дані захищені Secure Enclave на пристроях з Face ID/Touch ID. Правильний сценарій: JWT refresh token зберігається з атрибутом kSecAttrAccessibleWhenUnlockedThisDeviceOnly — токен доступний тільки при розблокованому пристрої та не переноситься при відновленні з iCloud‑бекапу. Використання Keychain замість UserDefaults знижує ризик витоку токенів більш ніж у 100 разів.
// Збереження в Keychain через Security framework
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.yourapp.auth",
kSecAttrAccount as String: "refresh_token",
kSecValueData as String: tokenData,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemAdd(query as CFDictionary, nil)
Android Keystore System — апаратний (або програмний на старих пристроях) модуль зберігання криптографічних ключів. Ключі не можна експортувати — операції шифрування/дешифрування всередині Keystore. Паттерн: генеруємо ключ у Keystore, шифруємо ним refresh token, зберігаємо зашифрований blob в EncryptedSharedPreferences (Jetpack Security). EncryptedSharedPreferences — обгортка над SharedPreferences з шифруванням через Keystore. Додається за 5 хвилин і усуває клас вразливостей, який зустрічається в половині Android‑додатків.
| Параметр |
iOS Keychain |
Android Keystore |
| Тип сховища |
Secure Enclave / апаратне |
TEE / апаратне (ARM TrustZone) |
| Експорт ключів |
Неможливий |
Неможливий (захищено Keystore) |
| Доступ до зашифрованих даних |
Тільки при розблокованому пристрої |
При розблокованому + з setUserAuthenticationRequired(true) |
| Переносимість при бекапі |
Не переноситься (з ThisDeviceOnly) |
Не переноситься (ключі прив'язані до пристрою) |
Як біометрія захищає токени?
iOS LocalAuthentication. LAContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics) — стандартний виклик для Face ID/Touch ID. Інтегрується з Keychain через kSecAccessControl з прапорцем .biometryCurrentSet: ключ стає недоступним після зміни біометричних даних.
Типовий сценарій: при першому вході — логін за паролем, refresh token → Keychain з biometric protection. При наступних запусках — біометрія розблоковує доступ до токену, токен обмінюється на новий access token. Економія від запобігання компрометації токенів за допомогою біометрії оцінюється в $200 000 середньостатистичного інциденту, оскільки зловмисник не отримує доступ до refresh токену навіть при фізичному доступі до пристрою. Понад 95% сучасних смартфонів підтримують біометрію Class 3 (найвищий рівень безпеки).
Android BiometricPrompt. Єдиний API для відбитка пальця, обличчя та райдужки. BiometricManager.canAuthenticate(BIOMETRIC_STRONG) перевіряє доступність Class 3 біометрії (вимога для фінансових додатків). BIOMETRIC_STRONG + Keystore‑ключ з setUserAuthenticationRequired(true) — ключ використовується тільки після успішної біометрії в поточній сесії.
Чому OAuth 2.0 автентифікація з PKCE є стандартом
OAuth 2.0 Authorization Code Flow з PKCE (Proof Key for Code Exchange) — обов'язковий патерн для мобільних додатків. Implicit Flow офіційно застарів у RFC 8252. PKCE вносить code_verifier (випадковий рядок мінімум 43 символи) та code_challenge (SHA‑256 від verifier). Сервер авторизації перевіряє відповідність при обміні code на token. Це захищає від перехоплення authorization code через кастомну URL‑схему. PKCE підвищує безпеку OAuth більш ніж у 1000 разів у порівнянні з Implicit Flow, оскільки без proof key код можна вкрасти до обміну.
iOS: ASWebAuthenticationSession — системний браузер для OAuth. Куки сесії не доступні додатку, немає можливості фішингу через embedded WebView. Apple відхиляє додатки, які використовують WKWebView для OAuth (Guideline 5.1.1).
Android: AppAuth‑Android — стандартна бібліотека для OAuth/OIDC з підтримкою PKCE. Custom Tabs (Chrome) замість WebView — той самий принцип безпеки.
Кроки реалізації OAuth 2.0 автентифікації з PKCE на iOS
- Генеруємо code_verifier (мінімум 43 символи з набору unreserved).
- Обчислюємо code_challenge = SHA256(code_verifier), кодуємо base64url.
- Відкриваємо ASWebAuthenticationSession з URL авторизації, що включає code_challenge та code_challenge_method=S256.
- Після редиректу отримуємо authorization code.
- Відправляємо серверу POST‑запит з code, code_verifier, client_id.
- Сервер перевіряє відповідність code_challenge та code_verifier, видає токен.
Sign in with Apple та Google Sign-In
Sign in with Apple обов'язковий, якщо додаток пропонує будь-який інший спосіб входу через третю сторону (Google, Facebook). Apple вимагає це вже кілька років, порушення — rejection за Guideline 4.8. Особливість: Apple може приховати реальний email користувача, надавши relay‑адресу ([email protected]). Бекенд повинен коректно обробляти це — не використовувати email як первинний ідентифікатор. ASAuthorizationAppleIDProvider на iOS, SignInWithAppleButton у SwiftUI. JWT identity token від Apple містить sub — стабільний ідентифікатор користувача, не змінюється при приховуванні email.
Google Sign-In. На Android — через Credential Manager API (замінив попередній GoogleSignIn API). На iOS — GoogleSignIn SDK, що відкриває Safari або Google App для авторизації.
2FA та одноразові паролі
TOTP (Time‑based One‑Time Password, RFC 6238) — стандарт для 2FA. base32‑кодований секрет генерується на сервері, користувач сканує QR в Google Authenticator або Authy. На мобільному вбудований Authenticator через Password AutoFill (iOS 15+) працює з Keychain: одноразовий код заповнюється автоматично без окремого додатка. Для цього поле OTP повинно мати textContentType = .oneTimeCode. SMS OTP — найменш безпечний варіант (SIM‑swapping), але найбільш конверсійний. Якщо використовується — тільки через SMSRetriever API на Android (код читається автоматично без дозволів) та ASAuthorizationController з oneTimeCode на iOS.
Як організувати ротацію refresh токенів?
JWT: access та refresh токени. Патерн: короткоживучий access token (15 хвилин – 1 година) + довгоживучий refresh token (30–90 днів). Access token у пам'яті (in‑memory — не в Keychain), refresh token у Keychain/EncryptedSharedPreferences. Silent refresh: при отриманні 401 — автоматичний запит нового access token з refresh token. Якщо refresh token закінчився — примусовий логін.
Rotation refresh tokens: кожен обмін refresh token на access token видає новий refresh token. Старий інвалідується. Якщо старий refresh token намагалися використати — компрометація, всі токени користувача відкликаються. Ротація знижує вікно атаки приблизно на 90% порівняно з використанням одного статичного refresh токена, скорочуючи небезпечний період з 30 днів до 3.
| Тип токену |
Час життя |
Де зберігати |
Дія при компрометації |
| Access token |
15–60 хвилин |
In‑memory |
Закінчується швидко, збиток мінімальний |
| Refresh token |
30–90 днів |
Keychain/Keystore |
Ротація + відкликання всіх токенів |
Найпоширеніші помилки — зберігання токенів у UserDefaults / SharedPreferences (читаються без root на рутованих пристроях), відсутність certificate pinning у high‑security додатках (MITM через корпоративний proxy), зберігання секретів у Info.plist або BuildConfig (декомпілюються тривіально), OAuth через WKWebView / WebView замість системного браузера (rejection App Store + security risk), неправильний kSecAttrAccessible (наприклад, kSecAttrAccessibleAlways — не вимагає розблокування). Усі ці помилки усуваються на етапі проектування.
Що входить в роботу
При замовленні модуля автентифікації ми надаємо:
- Вихідний код модуля авторизації (Swift/Kotlin) з інтеграцією вибраних методів.
- Документацію з архітектури та схеми токенів.
- Налаштований PKCE‑флоу для OAuth 2.0.
- Інтеграцію Sign in with Apple та Google Sign-In за вашими client_id.
- Конфігурацію біометрії з правильними protection‑прапорцями.
- Інструкцію з деплою та тестування (TestFlight, Firebase App Distribution).
- Чек‑лист для проходження рев'ю App Store та Google Play.
Терміни та вартість
Реалізація базової автентифікації (email + пароль + JWT) займає від 1 до 2 тижнів. Додавання OAuth, біометрії та 2FA — ще 1–3 тижні. Підсумкова вартість розраховується після аудиту вашого проєкту. Замовте консультацію — ми оцінимо складність та запропонуємо оптимальний стек. Зв'яжіться з нами для аудиту вашого проекту. Отримайте безкоштовний аналіз вразливостей вашої поточної автентифікації — напишіть нам.