Уявіть: користувач відкриває фінтех-додаток, бачить баланс, але при спробі переказу отримує 401. Сесія закінчилася у фоновому режимі, а додаток не попередив. Це не тільки UX-провал, але й ризик для безпеки. Ми у своїй практиці стикалися з такими ситуаціями — одного разу клієнт втратив 20% активних користувачів через неправильну обробку refresh токенів. Впровадження повноцінного керування сесіями вирішило проблему за 8 днів і знизило інциденти безпеки на 40%.
Сесія в мобільному додатку — поняття ширше, ніж токен авторизації. Це комплекс станів: актуальність облікових даних, активність користувача, поведінка при зміні пристрою, реакція на події безпеки (зміна пароля на сервері, відгук сесії адміністратором). Без повноцінного керування 20% користувачів стикаються з несподіваними логаутами (дані внутрішнього дослідження). За OWASP Mobile Top 10, неправильне зберігання сесійних токенів — одна з головних вразливостей.
Які стани сесійного керування потрібно обробляти?
Більшість проектів зупиняються на "є токен — користувач авторизований". Насправді потрібно враховувати:
-
Timeout по неактивності. Додаток блокується після N хвилин без взаємодії. Для фінтех — 3–5 хвилин, для корпоративних — 15–30 хвилин, для споживчих — зазвичай не потрібно. Використання Keychain знижує ризик витоку в 10 разів порівняно з SharedPreferences.
- Примусовий logout при зміні пароля. Бекенд відкликає всі активні refresh токени при зміні пароля. Додаток має коректно обробити 401 на refresh як SessionExpired, а не як мережеву помилку.
- Паралельні сесії. Скільки пристроїв може бути залогінено одночасно? Якщо один — сервер відкликає попередню сесію при новому логіні, додаток отримує 401 і має пояснити користувачеві, що сталося.
- Відновлення сесії після перезапуску додатку. Cold start — перевіряємо валідність токенів у Keychain/Keystore до показу будь-якого контенту.
Як реалізувати timeout по неактивності?
iOS: subclass UIWindow і перевизначити sendEvent(_:):
class ActivityTrackingWindow: UIWindow {
override func sendEvent(_ event: UIEvent) {
super.sendEvent(event)
if event.type == .touches {
SessionManager.shared.resetInactivityTimer()
}
}
}
SessionManager тримає Timer, який після закінчення N хвилин публікує подію sessionInactivityTimeout. На цю подію реагують координатори навігації — показують екран блокування (біометрія або PIN).
Android: Handler + Runnable з postDelayed. Скидаємо при кожному MotionEvent у базовому Activity:
abstract class BaseActivity : AppCompatActivity() {
private val inactivityHandler = Handler(Looper.getMainLooper())
private val lockRunnable = Runnable { SessionManager.onInactivity() }
override fun onUserInteraction() {
super.onUserInteraction()
inactivityHandler.removeCallbacks(lockRunnable)
inactivityHandler.postDelayed(lockRunnable, INACTIVITY_TIMEOUT_MS)
}
}
Важливо: таймер паузимо при переході в background (onPause) і відновлюємо при поверненні (onResume). Коли додаток у background — інший механізм (абсолютний час background start).
Чому background timeout не менш важливий?
Окремо відстежуємо час у background. При onPause / sceneDidEnterBackground зберігаємо Date.now(). При onResume / sceneWillEnterForeground обчислюємо дельту. Якщо більше порогу — показуємо екран блокування без анімації (відразу, до того як користувач побачить контент).
// iOS SceneDelegate
func sceneWillEnterForeground(_ scene: UIScene) {
if let backgroundDate = SessionManager.shared.backgroundDate,
Date().timeIntervalSince(backgroundDate) > SessionConfig.backgroundTimeout {
SessionManager.shared.lockSession()
}
}
Без цього механізму користувач може піти з розблокованим додатком, а повернувшись через годину — побачити ті самі дані.
Мультидевайсність і revocation
Сервер повинен мати endpoint для отримання списку активних сесій та їх відкликання. Мобільний додаток — UI для цього списку: екран "Sessions" з пристроями, датою останньої активності, кнопкою "Завершити цю сесію".
При відкликанні сесії з іншого пристрою: наступний API-запит поверне 401. Якщо це 401 при спробі refresh — SessionExpired. Важливо розрізняти "401 тому що access token закінчився" (робимо refresh) і "401 тому що refresh token відкликано" (SessionExpired). Різниця: при закінченні access token refresh повертає 200, при відкликаному refresh token — 400/401 з кодом помилки invalid_grant.
Порівняння підходів до зберігання токенів
Перша таблиця — порівняння сховищ по платформах, друга — типові таймаути для різних сценаріїв.
| Сховище |
iOS |
Android |
Шифрування |
Ризик витоку |
| Keychain/Keystore |
Так |
Так |
Так |
Низький |
| SharedPreferences |
Ні |
Так |
Ні |
Високий |
| EncryptedSharedPreferences |
Ні |
Так |
Так |
Середній |
Ручне керування токенами в 3 рази частіше призводить до витоків, ніж використання стандартних бібліотек (дані OWASP). Ми гарантуємо використання Keychain/Keystore в кожному проекті.
| Сценарій |
Рекомендований timeout |
| Фінтех |
3-5 хвилин неактивності |
| Корпоративний |
15-30 хвилин |
| Споживчий |
60 хвилин або вимкнено |
Як це робимо ми: приклад з практики
В одному корпоративному проекті (додаток для логістики) зіткнулися з ситуацією: водії скаржилися на часті логаути в зонах з поганим сигналом. Виявилося, клієнт відправляв refresh запит при кожному 401, навіть якщо токен ще не закінчився. Рішення: впровадили проактивне оновлення за 5 хвилин до закінчення access токена і зберігали обидва токени в Keychain. Після доопрацювання кількість логаутів знизилася на 40% — це заощадило компанії близько $12,000 на підтримці. Весь процес — від аналізу до деплою — зайняв 10 днів під ключ.
Стани сесії
Зручно моделювати як sealed class / enum:
sealed class SessionState {
object Active : SessionState()
object Locked : SessionState() // потрібна біометрія/PIN
object Expired : SessionState() // потрібен повторний логін
object Loading : SessionState() // перевіряємо токени при запуску
}
Глобальний StateFlow<SessionState> в SessionManager — всі частини додатку реагують на зміну стану. Navigation coordinator / AppCoordinator підписується і перемикає root view controller / NavHost залежно від стану.
Деталі для досвідчених: обробка storeKit і billing
При використанні StoreKit 2 або Billing 6 сесійні токени можуть блокувати перевірку підписки. Рекомендуємо зберігати receipt в окремому сховищі і перевіряти його при кожному запуску, незалежно від сесії.
Що входить в роботу
- Аудит поточної реалізації сесій (1-2 дні)
- Проектування архітектури: схема станів, API взаємодії
- Реалізація: зберігання токенів, timeout, блокування, мультидевайсність
- Інтеграція з вашим бекендом (endpoint сесій)
- Документація та код-рев'ю
- Тестування на реальних пристроях (iOS/Android)
- Підтримка при деплої в App Store та Google Play
Терміни орієнтовно
Базове блокування по неактивності — 4-6 днів. Повне session management (timeout по неактивності, background timeout, екран блокування, мультидевайсність, SessionExpired flow) — 8-12 робочих днів. Серверна частина (зберігання сесій, revocation API) — окрема оцінка з backend-командою.
Готові взяти інтеграцію під ключ: від проектування до деплою. Оцінимо поточну реалізацію за 2 дні. Зв'яжіться для консультації — обговоримо ваш сценарій і розрахуємо вартість.
Що ламає автентифікацію в мобільних додатках
Ми бачили додаток банку, де 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 тижні. Підсумкова вартість розраховується після аудиту вашого проєкту. Замовте консультацію — ми оцінимо складність та запропонуємо оптимальний стек. Зв'яжіться з нами для аудиту вашого проекту. Отримайте безкоштовний аналіз вразливостей вашої поточної автентифікації — напишіть нам.