Я использую OWASP Mobile Top 10 (2024) как чек-лист защит: никаких встроенных секретов и открытого хранения, авторизация на сервере, TLS без доверия всем, проверенные диплинки и неэкспортируемые компоненты, проверенные SDK, а R8 лишь повышает цену атаки.
Учётные данные, хранение, криптография (M1, M9, M10): никаких API-ключей в APK, токены шифруются ключом из Keystore, ничего секретного в открытых SharedPreferences, никаких самописных алгоритмов
Аутентификация и авторизация (M3): кто что может делать, решает сервер; флаг на клиенте остаётся лишь пожеланием
Транспорт (M5): никакого cleartext, никакого TrustManager, доверяющего всем, пиннинг только вместе с планом ротации
Входные данные и поверхность платформы (M4, M8): проверяйте диплинки и extras интентов, по умолчанию android:exported="false", никакого WebView-моста, открытого для любого origin
Цепочка поставок и приватность (M2, M6): зафиксированные версии SDK и ревью того, что каждый новый SDK собирает и отправляет
Защита бинарника (M7): R8 и аттестация повышают стоимость вмешательства, но никогда не делают клиента доверенным
AndroidSenior
Безопасность мобилки
Keychain/Keystore, секурное хранилище, certificate pinning, jailbreak/root, ATS
Закрыто вопросов
0 / 24
- 0Повторить
- 2Часто спрашивают
Вопросы
Кодирование меняет формат, и обратить его может кто угодно; шифрование скрывает данные от всех, у кого нет ключа; хеширование даёт односторонний отпечаток, который обратить нельзя вообще. На Android ключ шифрования живёт в AndroidKeyStore, а не в коде.
Кодирование: Base64, URL-кодирование; секрета нет, поэтому токен в Base64 остаётся всё тем же токеном
Хеширование: SHA-256 проверяет целостность; паролям на сервере нужен медленный хеш с солью вроде Argon2id или bcrypt, потому что быстрый хеш перебирается на GPU
Симметричное шифрование: один ключ и шифрует, и расшифровывает (AES-GCM); быстрое, используется для данных на диске и внутри TLS
Асимметричное шифрование: пара из публичного и приватного ключа (RSA, EC); медленное, используется для подписей и согласования ключей, а не для больших объёмов данных
Где живёт ключ: генерируйте его внутри AndroidKeyStore, чтобы сырые байты никогда не попадали в ваш процесс; приложение может только попросить Keystore зашифровать, расшифровать или подписать
TLS использует оба вида: согласование ключа ECDHE, подтверждённое сертификатом сервера, даёт симметричный сессионный ключ AES или ChaCha20.
Из git его убирают, читая значение из local.properties или из секрета CI во время сборки. Но всё, что скомпилировано в APK, всё равно публично, поэтому ключ с реальными полномочиями остаётся на вашем сервере, а приложение ходит в ваш бэкенд.
Вне git: local.properties в .gitignore, CI подставляет значение из своего хранилища секретов; ключ, хоть раз попавший в историю git, считается скомпрометированным и требует ротации
**BuildConfig ничего не прячет**: apktool или strings достают его за минуты, а R8 строковые константы не трогает
Проксируйте всё, что стоит денег или читает данные пользователя: ключ вендора хранит ваш бэкенд, он же проверяет вашу сессию и ограничивает частоту запросов на аккаунт; ротация ключа тогда требует деплоя, а не релиза в стор
Если ключ обязан быть в приложении: ограничьте его у вендора: имя пакета плюс SHA-256 сертификата подписи, список разрешённых API, квота и алерт по биллингу
Некоторые ключи только выглядят секретными: конфиг Firebase или DSN краш-репортера являются идентификаторами; бэкенд защищают правила безопасности и App Check
Обе платформы по умолчанию блокируют cleartext HTTP, так что релиз, который его отправляет, несёт исключение, которое кто-то закоммитил: network security config на Android или исключение ATS в Info.plist.
Android: cleartext выключен по умолчанию для приложений с targetSdk 28+, но network_security_config.xml может разрешить его для отдельного домена, и исключение прячется именно там
Не пускайте это в релиз: разрешающий файл кладите в src/debug/res/xml/; файл в src/main действует на все типы сборки
**debug-overrides: место для trust anchor, который нужен перехватывающему прокси; он игнорируется, если сборка не debuggable
iOS**: NSAllowsArbitraryLoads отключает ATS для всего приложения; для узкого варианта есть запись NSExceptionDomains для конкретного хоста, и она должна быть только в Info.plist debug-конфигурации
Подвох iOS: если присутствует NSAllowsArbitraryLoadsInWebContent или NSAllowsLocalNetworking, NSAllowsArbitraryLoads полностью игнорируется
Выдайте staging настоящий сертификат и проверяйте итоговый релизный конфиг в CI.
TLS-стек проверяет, что цепочка подписана доверенным корневым CA, срок действия не истёк и имя хоста совпадает с сертификатом. Пиннинг сужает проверку: принимается только сертификат с публичным ключом, который вы зашили в приложение.
Цепочка: конечный сертификат подписан промежуточным, а промежуточный подписан корневым из системного хранилища; начиная с Android 7 приложения игнорируют CA, установленные пользователем, если network security config явно их не разрешает
Имя хоста: имя должно совпадать с одним из Subject Alternative Name; именно эту проверку чаще всего ломает самописный HostnameVerifier
Что даёт пиннинг: защиту от ошибочно выпущенного или поддельного сертификата CA, а перехват через CA прокси перестаёт работать даже на рутованном тестовом телефоне
Пиньте хеш SPKI, а не сертификат: ключ переживает перевыпуск сертификата; пиньте свой промежуточный сертификат или собственный ключ и всегда добавляйте резервный пин
Декларативный вариант: <pin-set expiration="..."> в network security config; после этой даты пины перестают действовать, а не превращают старые установки в кирпич
Цена: пропущенная ротация ключа ломает разом все установленные копии, поэтому многие команды пинят только ценные API, а для остального полагаются на аттестацию
Он полностью отключает аутентификацию в TLS: принимается любой сертификат от кого угодно для любого хоста, поэтому любой, кто находится на сетевом пути, может читать и менять каждый запрос. Если код лежит в src/main, он уезжает в прод.
**Пустой checkServerTrusted: ни одна цепочка не отвергается, так что самоподписанный сертификат прокси в кафешном Wi-Fi проходит
hostnameVerifier { _, _ -> true }: принимается даже валидный сертификат, выпущенный для другого домена
Шифрование без аутентификации ничего не стоит: трафик по-прежнему зашифрован, но ключом атакующего
Google Play это ищет**: небезопасный X509TrustManager или HostnameVerifier приводит к предупреждению о нарушении политики и может заблокировать обновления
Правильное решение: доверять CA staging только в debug: network security config в src/debug с <certificates src="@raw/staging_ca"/> для этого домена, или просто выдать staging настоящий сертификат
Пины зарегистрированы для example.com, а клиент ходит на api.example.com. OkHttp сопоставляет пины по точному имени хоста, не находит ни одного для этого хоста и молча пропускает проверку, поэтому проходит любой сертификат, которому доверяет устройство.
Обычный шаблон покрывает один хост: example.com пиннит только example.com; *.example.com покрывает ровно одну дополнительную метку (api.example.com), а **.example.com покрывает любую глубину и сам корневой домен
Отсутствие совпадения отключает пиннинг, а не вызывает ошибку: хост без пинов считается незапиненным, поэтому в разработке ничего не падает, а на ревью настройка выглядит правильной
Исправление: пиньте хост из BASE_URL или **.example.com, если все поддомены отдают сертификат с одним и тем же ключом, и сохраните резервный пин; любой другой хост, на который ходит приложение (CDN, сервер авторизации), остаётся незапиненным, пока его тоже не добавят
Как поймать в следующий раз: юнит-тест, проверяющий, что pinner.findMatchingPins("api.example.com") не пуст, и проверка релиза через mitmproxy с его CA в системном хранилище тестового телефона: каждый запрос к API должен падать с SSLPeerUnverifiedException
Ключ скомпилирован в APK, поэтому любой, кто распакует приложение, расшифрует токен любого пользователя; фиксированный нулевой IV и отсутствие проверки целостности ослабляют схему ещё сильнее.
Захардкоженный ключ: строка лежит в dex-файле; шифрование секретно ровно настолько, насколько секретен ключ, так что это обфускация, а не защита
Постоянный IV: CBC с одним и тем же IV превращает одинаковые открытые тексты в одинаковые шифртексты и выдаёт общие префиксы; IV должен быть случайным для каждого сообщения и храниться рядом с шифртекстом
Нет аутентификации: CBC без MAC позволяет атакующему незаметно менять биты; AES-GCM шифрует и проверяет целостность за один шаг
**Cipher.getInstance("AES") было бы ещё хуже: по умолчанию это ECB, который выдаёт закономерности в данных
Исправление**: ключ, сгенерированный внутри AndroidKeyStore, AES-GCM со случайным IV, который выбирает сам шифр, и этот IV хранится вместе с результатом
Везде, где объект с токеном превращается в строку: сгенерированный toString() data class, сообщение исключения, breadcrumb краш-репортера или собственный debug-лог SDK. Всё это покидает устройство вместе с отчётом.
**Сгенерированный toString()**: data class Session(val refreshToken: String) печатает токен везде, где логируется объект; дайте типам с учётными данными toString(), который печатает только длину
Сообщения исключений: IllegalStateException("bad response: $body") отправляет тело ответа в ваш краш-репортер
Breadcrumbs и аналитика: аргументы навигации, URL диплинков с кодом в query, свойства пользователя
Логирование сторонних SDK: verbose-режим, забытый в релизе; задавайте уровень логов каждого SDK по типу сборки
Вырезайте debug-логи в релизе: правило R8 -assumenosideeffects удаляет вызовы Log.d и Log.v; другие приложения ваш logcat не читают, но adb logcat и любой баг-репорт его читают
Поставьте на окно FLAG_SECURE: он блокирует скриншоты, затемняет записи и миниатюру в «Недавних»; скопированные секреты помечайте EXTRA_IS_SENSITIVE и очищайте по таймауту. Сфотографировать экран запретить нельзя.
**FLAG_SECURE: блокирует скриншоты, затемняет окно на записях и трансляциях и скрывает миниатюру в «Недавних»; флаг действует на окно, так что диалогам и bottom sheet он нужен отдельно
Обнаруживать вместо блокировки**: registerScreenCaptureCallback в Android 14 сообщает о скриншоте постфактум, addScreenRecordingCallback в Android 15 сообщает, что приложение записывают; обоим нужно обычное разрешение, выдаваемое при установке
Буфер обмена: никогда не копируйте секрет автоматически; на Android 13+ ставьте ClipDescription.EXTRA_IS_SENSITIVE, чтобы превью его скрывало, и очищайте буфер по таймауту через clearPrimaryClip()
Поля паролей: SecureTextField в Compose или inputType="textPassword" не дают клавиатуре запоминать значение
**На iOS нет FLAG_SECURE**: закрывайте окно, когда сцена теряет активность, чтобы снимок в переключателе приложений был пустым, и следите за UIScreen.isCaptured во время записи
Это tapjacking: другое приложение рисует поверх вашего, и нажатие пользователя попадает на вашу кнопку. Ставьте filterTouchesWhenObscured на чувствительные view, чтобы касания отбрасывались, пока их перекрывает чужое окно; по умолчанию это выключено.
Как это работает: фреймворк отбрасывает касание, пришедшее, пока окно другого приложения перекрывает ваше; каждый диалог, sheet или popup открывается отдельным окном, и флаг нужен ему тоже
Android 12 сузил атаку: касания, проходящие сквозь оверлей другого приложения, блокируются, если его непрозрачность выше 80%, так что остаётся полупрозрачное или частичное перекрытие
Скрывать оверлеи целиком: на API 31+ Window.setHideOverlayWindows(true) убирает оверлеи других приложений, пока виден чувствительный экран
Объяснять, а не глотать: проверяйте MotionEvent.FLAG_WINDOW_IS_OBSCURED и говорите пользователю, почему нажатие проигнорировано, иначе кнопка просто выглядит сломанной
Что это не закрывает: accessibility-сервис читает экран и нажимает без всякого оверлея; для всего, что двигает деньги, подтверждение должно быть на стороне сервера
iOS: приложения не могут рисовать поверх других приложений, поэтому такого класса атак там нет
Кастомная схема вроде myapp:// никому не принадлежит, поэтому ссылки, предназначенные вам, может получить другое приложение. Считайте каждую входящую ссылку входными данными от атакующего: вызовом эндпоинта, который может дёрнуть кто угодно на устройстве.
Используйте ссылки, которые проверяет ОС: App Links (assetlinks.json) и Universal Links привязаны к домену, которым вы владеете
Для OAuth предпочитайте проверенный https-редирект: конкурирующее приложение может получить код на кастомной схеме; если иначе нельзя, PKCE делает украденный код бесполезным
Считайте ссылку неаутентифицированным запросом: заново проверяйте сессию и права пользователя на сервере, разрешайте только известные хост и путь, всё разрушительное подтверждайте на экране
Ничего секретного в URL: ссылки оседают в истории браузера, логах серверов и заголовке referrer
Intent redirection: по умолчанию android:exported="false", и никогда не запускайте Intent, извлечённый из extras чужого приложения: через вас он доберётся до ваших приватных компонентов
endsWith("example.com") пропускает и evilexample.com, домен, который может зарегистрировать кто угодно. Любая веб-страница или приложение отправляет myapp://web?url=https://evilexample.com/, и активити открывает страницу атакующего с access-токеном пользователя в заголовке Authorization.
Почему проверка суффикса не работает: хост состоит из меток, а не является просто строкой: evilexample.com заканчивается теми же символами, но это другой регистрируемый домен; проверки через contains и startsWith ломаются так же (example.com.evil.io)
Схема тоже входит в проверку: этот же код загружает и http://shop.example.com, то есть отправляет токен открытым текстом любому, кто находится на сетевом пути
Исправление: разберите URL один раз строгим парсером (toHttpUrlOrNull() из OkHttp), сравните scheme с "https", а host сравнивайте с точным набором хостов, и загружайте именно этот разобранный объект: Uri.host и парсер браузера могут по-разному трактовать \\ и @; даже endsWith(".example.com") доверяет всем поддоменам, включая забытый, который кто-то перехватил
Как поймать в следующий раз: юнит-тест валидатора на враждебных входных данных (https://evilexample.com, http://example.com, https://example.com.evil.io, https://evil.com\\@shop.example.com), а каждая экспортируемая активити, которая читает URL из интента, становится обязательным пунктом security-ревью
Удаление приложения на iOS не удаляет его записи в Keychain. Контейнер приложения исчезает, записи остаются, поэтому новая установка находит старый refresh-токен и молча восстанавливает предыдущую сессию.
Почему: записи Keychain принадлежат группе доступа, производной от вашего Team ID, а не установленному бинарнику; даже записи ThisDeviceOnly переживают переустановку на том же телефоне
Исправление косвенное: UserDefaults удаляется вместе с приложением, так что отсутствующий флаг первого запуска означает свежую установку: очистите свои записи Keychain до того, как что-то прочитает сессию
Выбирайте класс доступности осознанно: WhenUnlockedThisDeviceOnly для токена сессии; запись по умолчанию переезжает с зашифрованным бэкапом на новый телефон, а kSecAttrSynchronizable синхронизирует её через iCloud Keychain
Android по умолчанию ведёт себя иначе: удаление стирает каталог данных и ключи приложения в AndroidKeyStore, так что переустановка из APK начинается с чистого листа; при переустановке из Play файлы всё же могут вернуться через Auto Backup, поэтому держите токены вне бэкапа (noBackupFilesDir или правила исключения)
Это классический кроссплатформенный вопрос: один и тот же код на Flutter или KMP ведёт себя на двух платформах по-разному.
Play Integrity даёт вашему серверу вердикт о запросе, подписанный Google: тот ли это бинарник, что распространил Play, проходит ли устройство проверки целостности, есть ли у пользователя лицензия. Проверка на root работает внутри процесса атакующего и возвращает то, что он подставит хуком.
Что подтверждается: appIntegrity (PLAY_RECOGNIZED), метки устройства MEETS_BASIC_INTEGRITY, MEETS_DEVICE_INTEGRITY и MEETS_STRONG_INTEGRITY, accountDetails (LICENSED), плюс необязательные сигналы вроде запущенных рискованных приложений и статуса Play Protect
Решает сервер: приложение получает только непрозрачный токен; бэкенд расшифровывает его через эндпоинт Google decodeIntegrityToken и проверяет имя пакета, хеш запроса и время
Привязывайте к действию: передавайте хеш запроса через setRequestHash, чтобы токен, перехваченный при логине, нельзя было переиграть для платежа
Чего он не умеет: он не остановит настоящий немодифицированный телефон в бот-ферме и ничего не скажет об украденной сессии; он повышает стоимость атаки, но не является идентификацией; у вызовов к тому же есть суточная квота
Понижайте доверие, а не блокируйте: кастомные прошивки честно не проходят device integrity; вместо блокировки требуйте дополнительную аутентификацию или отключайте рискованные функции, а клиентские проверки на root оставьте дешёвым сигналом для аналитики (на iOS роль Play Integrity играет App Attest)
Passkey представляет собой пару ключей, привязанную к вашему домену и хранимую платформой, поэтому приложение никогда не держит общий секрет, а фишинговому сайту нечего собрать. Ваш домен должен поручиться за приложение, сервер должен провести WebAuthn-церемонию, а приложение должно вызвать API платформы.
Домен: assetlinks.json должен содержать связь delegate_permission/common.get_login_creds для вашего пакета и хеша подписи; на iOS нужна запись webcredentials в Associated Domains плюс размещённый файл ассоциации
Сервер: выдаёт challenge, в котором ваш домен указан как relying party, затем проверяет подписанный ответ и сохраняет публичный ключ; это WebAuthn-сервер, обычно библиотека
Android: Credential Manager: createCredential для регистрации, getCredential для входа, одна шторка и для passkeys, и для сохранённых паролей на время миграции
Файл ассоциации строго обязателен: со сломанным файлом показывается пустая шторка, которая выглядит точь-в-точь как баг SDK
Оставьте путь восстановления: пользователь, потерявший аккаунт Google или Apple, теряет и credential
Частая ошибка: считать локальную биометрию аутентификацией; аутентификация означает проверку подписанного challenge на сервере
Генерируйте ключ внутри защищённого железа и никогда не держите его сами: Android Keystore и iOS Secure Enclave позволяют приложению только попросить ключ подписать или расшифровать, а аттестация ключа даёт серверу проверить, что это действительно так.
Android: создавайте ключ через провайдер AndroidKeyStore с KeyGenParameterSpec; материал ключа хранит TEE или чип StrongBox, и в ваш процесс он не попадает
StrongBox считается необязательным железом: setIsStrongBoxBacked(true) бросает StrongBoxUnavailableException на многих телефонах, так что откатывайтесь на TEE
Аттестация превращает обещание в факт: setAttestationChallenge(...) даёт цепочку сертификатов; сервер проверяет, что она ведёт к корням аттестации Google, содержит его challenge, называет ваш пакет и сертификат подписи и сообщает аппаратный уровень безопасности
Неизвлекаемый не значит неиспользуемый: малварь или root на устройстве всё равно могут попросить Keystore подписать; ценные ключи закрывайте аутентификацией пользователя
iOS: ключи Secure Enclave используют EC P-256 (RSA не поддерживается; CryptoKit в iOS 26 добавляет постквантовые типы ML-DSA/ML-KEM); SecAccessControl с .biometryCurrentSet привязывает использование к текущей биометрии, а App Attest даёт серверу похожее доказательство
Биометрический API, вернувший true, ничего не доказывает: кто может пропатчить приложение, тот пропатчит и boolean. В правильном флоу биометрия разблокирует ключ, так что доказательством служит успешная расшифровка, и переключать нечего.
Android: BiometricPrompt с CryptoObject поверх ключа, созданного с setUserAuthenticationRequired(true); Cipher работает только после того, как система аутентифицировала пользователя
Закрыть ключ может только Class 3: BIOMETRIC_WEAK не принимает CryptoObject, поэтому заранее решите, что делать телефону, где есть только распознавание лица
Добавление нового отпечатка даёт сигнал, а не краш: с setInvalidatedByBiometricEnrollment(true) новый отпечаток заставляет Cipher.init бросить KeyPermanentlyInvalidatedException; проведите полный вход и заново оберните токен новым ключом (обёртывание тоже требует одного запроса или шифруйте открытой половиной асимметричного ключа); после постоянной блокировки используйте полный вход или AUTH_BIOMETRIC_STRONG or AUTH_DEVICE_CREDENTIAL
iOS: храните секрет в Keychain за SecAccessControl с .biometryCurrentSet, чтобы Face ID запускало само чтение записи; LAContext.evaluatePolicy сам по себе возвращает только Bool
Частая ошибка: хранить пароль, чтобы отправлять его повторно; храните refresh-токен, обёрнутый ключом, привязанным к биометрии
R8 переименовывает ваши классы и члены и вырезает неиспользуемый код, но строки, вызовы фреймворка, ресурсы и сама логика остаются читаемыми, поэтому вырезать проверку можно за полдня. Любое обнаружение работает в процессе, который контролирует атакующий, поэтому оно служит сигналом для сервера, а решения принимает сервер.
Что R8 скрывает, а что нет
Скрыто: ваши собственные имена (a.b.c() вместо PremiumGate.isActive()), неиспользуемый код, часть структуры после инлайнинга и слияния классов.
Остаётся читаемым: строковые константы, которые указывают прямо на проверку, каждый вызов фреймворка и JDK (PackageManager, Cipher), ресурсы, манифест и нативные библиотеки. jadx превращает dex обратно в Java, apktool собирает APK заново из отредактированного smali, apksigner подписывает его ключом атакующего.
Обнаружение переупаковки
Переподписанный APK несёт другой сертификат: сравнивайте SHA-256 от signingInfo.apkContentsSigners с ключом подписи приложения в Play, а не с upload-ключом, иначе каждая установка из Play будет выглядеть подменённой.
Эту проверку атакующий тоже вырежет. Подделать он не может вердикт Play Integrity, проверенный на сервере: appRecognitionVerdict равен UNRECOGNIZED_VERSION для бинарника, который Play не распространял.
Обнаружение Frida и хуков
Frida не нужен изменённый APK: на рутованном телефоне frida-server переписывает методы во время работы, и isPremium() возвращает true без единого изменённого байта.
Сигналы: frida в /proc/self/maps, порт по умолчанию 27042, подключённый отладчик, классы Xposed или LSPosed. Frida может первым перехватить сам детектор, поэтому сигналы уходят на сервер вместе с integrity-токеном, и сервер повышает уровень проверки (повторная аутентификация, лимиты на платежи), а приложение не падает.
Шифрование хранилища (file-based на Android, Data Protection на iOS) защищает выключенный телефон или телефон до первой разблокировки. Дальше ваши файлы читает всё, что работает от имени приложения, поэтому шифрование в приложении полезно, только если у его ключа есть свойство, которого нет у системного.
Ключ, требующий присутствия пользователя: setUserAuthenticationRequired(true) на Android, .biometryCurrentSet на iOS; данные недоступны даже вашему процессу, пока пользователь не аутентифицируется
Ключ, который можно уничтожить: удаление одного алиаса в Keystore или записи в Keychain разом делает гигабайты нечитаемыми; именно это делает logout и удалённое стирание настоящими
Ключ, который не переезжает: аппаратных ключей в бэкапах нет, поэтому восстановленные файлы остаются нечитаемыми; заложите это в логику
Кэши текут сильнее базы: HTTP-кэш, кэш картинок, хранилище WebView и логи; logout должен удалять и их
Защита файлов на iOS: класс по умолчанию открывает файлы после первой разблокировки; .completeFileProtection снова закрывает их, пока устройство заблокировано
Если ни одно из этих свойств не важно для вашей модели угроз, дополнительное шифрование лишь добавит баг в управлении ключами.
JavaScript-интерфейс привязан к WebView, а не к origin. Его может вызвать любой фрейм любой страницы, которую WebView когда-либо загрузит (редирект, рекламный iframe, внедрённый скрипт), так что мост становится неаутентифицированным публичным API вашего приложения.
**У addJavascriptInterface нет проверки origin**: аннотация @JavascriptInterface закрыла старую уязвимость через рефлексию, но не доступ недоверенного контента к мосту
Замена: WebViewCompat.addWebMessageListener принимает правила разрешённых origin и доставляет postMessage только из подходящих фреймов; где он не поддерживается, не делайте моста вовсе, а не возвращайтесь к старому API
iOS: у WKUserContentController.add(_:name:) та же проблема со всеми фреймами; используйте content world и проверяйте message.frameInfo.isMainFrame и security origin
Отдавайте локальный контент по https: WebViewAssetLoader вместо file://, allowFileAccess выключен, и никогда не вызывайте proceed() в onReceivedSslError
Проектируйте мост как публичный API: с ограничением по origin, с несколькими командами и без метода, отдающего токен; если странице нужно ваше API, выдайте ей собственный ограниченный короткоживущий токен
Access-токен самодостаточен, как JWT: API доверяет ему до истечения срока и не спрашивает сервер, жива ли ещё сессия, поэтому выход должен быть отзывом на сервере, а не локальной очисткой.
Два токена, две задачи: access-токен проверяется без обращения к базе, поэтому его время жизни должно измеряться минутами, а не часами; refresh-токен и есть сессия, и сервер проверяет его при каждом использовании
Выход требует вызова сервера: отзовите refresh-токен (эндпоинт отзыва по RFC 7009), чтобы сервер завершил сессию, и только потом очищайте хранилище; для случая с потерянным телефоном дайте выход на всех устройствах
Ротируйте refresh-токены: каждое обновление возвращает новый токен и аннулирует старый; токен, использованный дважды, означает кражу или повтор, и сервер убивает всё семейство сессий
Баг, который бывает только в проде: одновременные 401 должны делить одно обновление (single-flight), иначе второй запрос предъявит ротированный токен и разлогинит пользователя
Когда минуты ждать слишком долго: держите на сервере denylist отозванных id сессий, который API проверяет для рискованных вызовов
Сделайте украденный токен бесполезным: DPoP (RFC 9449) привязывает токен к ключу в Keystore или Secure Enclave, так что скопировать токен с устройства недостаточно
Стабильного аппаратного идентификатора приложениям больше не дают, поэтому переформулируйте требование: спрашивайте, брало ли это устройство триал, а не кто это устройство, и храните этот факт на сервере.
**ANDROID_ID: с Android 8 свой для каждого ключа подписи приложения и сбрасывается при сбросе к заводским настройкам; IMEI и серийный номер с Android 10 требуют привилегированных разрешений
Рекламный идентификатор**: требует разрешения AD_ID, превращается в нули, когда пользователь его удаляет, и предназначен для рекламы, а не для антифрода
Сгенерированный вами UUID идентифицирует установку: на Android он умирает вместе с удалением приложения; на iOS копия в Keychain переживает переустановку, но и переезжает с бэкапом на другой телефон, если она не ThisDeviceOnly; identifierForVendor сбрасывается, когда удалены все ваши приложения
Что сделано именно для этого: device recall в Play Integrity хранит три бита на устройство, которые переживают переустановку и сброс к заводским настройкам (пока бета, доступ по заявке); DeviceCheck на iOS даёт два бита на устройство для каждого разработчика; читает и пишет их ваш сервер
Фингерпринтинг: модель, локаль и размер экрана нестабильны и создают проблемы с политиками сторов
Потратьте один бит на «триал использован» и подкрепите его сигналами аккаунта и платежей: упорный злоумышленник просто купит ещё один телефон.
В рантайме это ничто не поймает: библиотека работает в вашем процессе с вашими разрешениями, вашими файлами и вашими ключами в Keystore. Работающие меры живут в графе зависимостей, в сборке и в ревью релиза.
Между вашим кодом и SDK нет песочницы: SDK может читать ваши SharedPreferences, пользоваться каждым разрешением, которое дал пользователь, и ходить на любой хост
Время сборки открывает дыру пошире: Gradle-плагин или annotation processor выполняет произвольный код на CI-машине, где лежат ваши ключи подписи, не добавив в приложение ни строчки
Фиксируйте и проверяйте: точные версии в version catalog, dependency locking в Gradle, verification-metadata.xml с контрольными суммами SHA-256 и проверка wrapper в CI, чтобы подменённый gradle-wrapper.jar ронял сборку
Обновляйте автоматически, мержите осознанно: Renovate или Dependabot с периодом выдержки и человек, читающий changelog всего, что касается сети или хранилища
Сравнивайте трафик: пропустите чистую установку релиз-кандидата через прокси и сравните список хостов с прошлым релизом; именно эта проверка ловит такой случай, и именно её команды пропускают
Следите за итоговым манифестом: новое разрешение или экспортированный компонент из библиотеки появляется там раньше, чем всплывёт на ревью в сторе