Считайте клиент враждебным: настоящие секреты держите на сервере, долгоживущие токены в Keychain или Keystore, общайтесь только по HTTPS, а каждое решение об авторизации пусть принимает сервер. OWASP Mobile Top 10 (редакция 2024 года) группирует это по категориям M1–M10.
Учётные данные и аутентификация (M1, M3): никаких секретов в бинарнике, OAuth с PKCE в системном браузере, короткоживущие access-токены, каждое право проверяется на сервере
Цепочка поставок (M2): проверенные пакеты, закоммиченный pubspec.lock, обновления небольшими PR с ревью
Ввод и вывод (M4): deep link, сообщения из WebView и данные с сервера недоверенные; параметры SQL биндятся, маршруты и хосты проверяются по allowlist
Канал связи (M5): TLS везде, никаких глобальных исключений ATS или cleartext, пиннинг там, где этого требует модель угроз
Приватность (M6): собирайте меньше, вычищайте PII из логов и аналитики, прячьте чувствительные экраны в переключателе приложений
Защита бинарника (M7): --obfuscate --split-debug-info повышает цену чтения приложения; Play Integrity и App Attest позволяют серверу распознать модифицированную сборку
Конфигурация, хранение, криптография (M8–M10): в релизе нет debug-флагов и утечек через бэкап, токены лежат в flutter_secure_storage, для шифрования используются стандартные AEAD-шифры со случайными ключами
FlutterSenior
Безопасность
Безопасное хранилище, certificate pinning, обфускация, OWASP Mobile Top 10
Закрыто вопросов
0 / 24
- 0Повторить
- 4Часто спрашивают
Вопросы
Токены и ключи кладите в flutter_secure_storage (он отдаёт их в Keychain на iOS или в шифр на ключе из Keystore на Android), access-токен держите в памяти, а пароль не храните вовсе. SharedPreferences, Hive без шифрования и обычные файлы читаются из бэкапа или на рутованном телефоне.
Делите данные по вреду от утечки: токены, ключи и PII идут в secure storage или в зашифрованную базу; настройки и feature-флаги могут остаться в SharedPreferences
Храните меньше: держите refresh-токен, а не пароль; кешируйте только то, что нужно офлайн-экрану
Большие данные: secure storage рассчитан на маленькие значения; базу шифруйте через SQLCipher или Hive-бокс через HiveAesCipher, а сам ключ держите в secure storage
Жизненный цикл: очищайте secure storage при логауте и при первом запуске после переустановки, потому что на iOS записи Keychain переживают удаление приложения
Границы: secure storage защищает данные в покое; на рутованном или джейлбрейкнутом устройстве работающее приложение всё равно можно прочитать
Четыре проблемы: токены лежат в обычном файле настроек, access-токен печатается в лог устройства, ключ подписи берётся из предсказуемого генератора, а выход из аккаунта оставляет refresh-токен, так что сессия переживает логаут.
Открытое хранение: SharedPreferences хранит данные в незашифрованном XML или plist, который попадает в бэкапы и читается на рутованном телефоне; токены идут в flutter_secure_storage
Логирование: debugPrint не вырезается из релизной сборки; он попадает в logcat и в breadcrumbs крешей, поэтому токен не логируют никогда, даже частично
Слабая случайность: Random() реализует быстрый PRNG с предсказуемым выходом; ключ, которым подписываются запросы, берётся из Random.secure(), а лучше с сервера
Неполный выход: удаляйте и refresh-токен, вызывайте на сервере эндпоинт отзыва, чтобы скопированный токен перестал работать, и чистите кеши с данными пользователя
Сначала определите, какой это ключ: публикуемый идентификатор может ехать в сборку, настоящий секрет не может никогда. Всё, что приложение читает в рантайме, прочитает и атакующий с бинарником.
Публикуемые ключи: конфиг Firebase или ключ карт задуманы видимыми; ключи Maps ограничивают по bundle id и отпечатку подписи, а данные Firebase защищают Security Rules и App Check
Настоящие секреты: secret key платёжного провайдера или всё, что тратит деньги, уводите за свой эндпоинт бэкенда с аутентификацией по сессии пользователя, а ключ пусть держит сервер
Тайники, которые не работают: значения --dart-define компилируются в бинарник, .env-ассет остаётся обычным файлом внутри APK, а strings.xml достаётся командой strings
Планируйте ротацию: считайте, что любой ключ, попавший в сборку когда-нибудь утечёт, поэтому пусть клиент получает короткоживущие учётные данные с бэкенда после входа
Это сетевые правила уровня ОС, которые применяют платформенные HTTP-стеки вроде URLSession, OkHttp и WebView: обе системы по умолчанию блокируют обычный HTTP и позволяют добавить узкие исключения по доменам. Собственный HttpClient из Dart, который по умолчанию стоит под http и Dio, работает поверх сокетов и их обходит.
iOS (ATS): настраивается в Info.plist под NSAppTransportSecurity; блокирует HTTP и TLS ниже 1.2, исключения задаются по доменам в NSExceptionDomains
Android: res/xml/network_security_config.xml, подключённый из манифеста; cleartext выключен по умолчанию, <domain-config> разрешает staging- или корпоративный CA, а <debug-overrides> принимает CA прокси только в debug-сборках
Когда их трогают: legacy-эндпоинт без нормального TLS, корпоративный root CA, которому ОС не доверяет, или отладочный прокси
Каких клиентов они касаются: URLSession, OkHttp, WebView, cupertino_http, cronet_http, медиаплееры; свои trust anchors и пины здесь до HttpClient из Dart не доходят
Падение только в релизе с http или Dio: сначала проверьте <uses-permission android:name="android.permission.INTERNET"/> в android/app/src/main/AndroidManifest.xml; шаблон добавляет его только в debug- и profile-манифесты
Частая ошибка: разрешить cleartext или произвольные загрузки глобально в проде; ревьюеры и аудит это ловят
Обфускация заменяет имена классов, методов и полей в релизном бинарнике на короткие бессмысленные токены, поэтому декомпилированное приложение трудно читать. Включается флагами --obfuscate --split-debug-info=<dir>; второй флаг пишет таблицу символов в отдельный файл.
Именно эта таблица делает краш из обфусцированной сборки снова читаемым: потеряете её, и краш навсегда останется набором адресов вроде _kDartIsolateSnapshotInstructions+0x1a2b3c.
Чего она не делает: не шифрует ассеты, не мешает декомпиляции и не прячет секреты; она лишь повышает трудозатраты
Архивируйте символы на каждый релиз: храните приватно, лучше всего в artifact store CI, отдельный набор на версию и платформу
Символизация вручную: flutter symbolize -i stacktrace.txt -d <dir>/app.android-arm64.symbols восстанавливает настоящие имена
Автоматически: заливайте файл символов в Crashlytics или Sentry из CI при каждом релизе
Не логируйте секреты, блокируйте захват экрана на чувствительных экранах и не кладите секреты в буфер обмена. Большинство реальных утечек скучные: токен в crash-логе, PII в событии аналитики, экран с картой в переключателе приложений.
Логи: никогда не выводите через print токены и полные тела запросов в релизе, а в Crashlytics или Sentry задайте правила scrubbing для ключей вроде password, otp, card
Скриншоты: на Android WindowManager.FLAG_SECURE блокирует захват и превью в recents; на iOS аналога нет, поэтому накладывайте blur, когда приложение уходит в фон
Буфер обмена: iOS синхронизирует его с Mac пользователя, а у Clipboard.setData нет срока жизни, поэтому для секрета нужен платформенный канал с UIPasteboard, .localOnly и .expirationDate
Клавиатура: obscureText: true вместе с autocorrect: false не даёт чувствительному вводу попасть в словари ОС
Потому что спрятанная вкладка не является контролем доступа: админский эндпоинт никуда не делся, а атакующий дёргает эндпоинт, а не ваше дерево виджетов. Payload JWT закодирован просто в base64, так что кто угодно может поправить claim с ролью и посмотреть, что сделает UI.
Что клиенту можно: решать, что рисовать, и не отправлять запрос с истёкшим exp; больше ничего, потому что пропатченный бинарник пропустит любую клиентскую проверку
Что обязан сервер: сам проверять каждый запрос (подпись, iss, aud, exp, ключ по kid) и брать личность вызывающего из токена, а не из тела запроса
Авторизация по ресурсу: GET /orders/42 обязан проверить, что заказ 42 принадлежит этому пользователю; userId в пути относится к роутингу, а не к правам (эта дыра называется IDOR)
Чистая схема: логин возвращает права, которые сервер реально исполнит, UI строится по этому списку, а 403 считается обычным состоянием с нормальным экраном
Обе проблемы сводятся к одной и той же ошибке: внешний ввод вклеивается в язык (SQL в одном случае, путь к файлу в другом) вместо передачи как значения. SQL чините связанными параметрами, а загрузку тем, чтобы вообще не брать серверное имя файла для диска.
SQL: связывайте, а не склеивайте: плейсхолдеры ? с whereArgs; драйвер отправляет запрос и значения отдельно, и ни один символ значения не станет синтаксисом
Символы шаблона передавайте в аргументе: where: 'title LIKE ?', whereArgs: ['%$q%'], а не весь шаблон в тексте запроса
Идентификаторы не параметризуются: колонка сортировки или имя таблицы из UI проходят через allowlist-мапу и никогда не попадают в строку
Пути: нормализуйте, потом проверяйте: имя вроде ../../Preferences/x.plist пишет за пределы песочницы; проверяйте склеенный путь через p.isWithin или храните файл под UUID, а отображаемое имя храните отдельной колонкой
Это доказало только, что ОС сказала «да» на этом устройстве, прямо сейчас, в вашем процессе. Результат не доходит до сервера, а пропатченное приложение может перевернуть этот boolean, так что сама по себе это проверка в интерфейсе, а не учётные данные.
Настоящая защита состоит в том, чтобы привязать секрет к биометрии, а не ветвиться по true: биометрия разблокирует то, чему сервер уже доверяет.
Привязывайте данные, а не флаг: на Android это ключ Keystore с setUserAuthenticationRequired(true), который используется через BiometricPrompt с CryptoObject; на iOS это запись Keychain, созданная через SecAccessControlCreateWithFlags с .biometryCurrentSet; без свежего совпадения refresh-токен не расшифровать
Инвалидируйте при смене биометрии: setInvalidatedByBiometricEnrollment(true) и .biometryCurrentSet уничтожают ключ, когда добавляют новое лицо или палец, и чужой отпечаток на разблокированном телефоне не поможет
Обрабатывайте реальные ошибки: нет железа, ничего не зарегистрировано, блокировка после серии неудач, отказ в разрешении; в качестве fallback используются настоящие учётные данные, а не обход
Переспрашивайте: на дорогих действиях и после таймаута в фоне, а не разблокируйте сессию навсегда
WebView выполняет веб-контент с правами вашего процесса, поэтому неправильно настроенный WebView сводит на нет secure storage, pinning и песочницу. Загружайте только свои страницы, давайте странице минимум доступа и никогда не кладите вход в аккаунт внутрь WebView.
Чужие URL: если домен не ваш, открывайте его в системном браузере через url_launcher с LaunchMode.externalApplication
JavaScript-мосты: JavaScriptChannel даёт странице вызывать ваш Dart; считайте его публичным API: проверяйте каждый вход и разрешайте только известные origin
Навигация: блокируйте в NavigationDelegate редиректы на хосты вне allowlist; забытый редирект превращается в фишинговую страницу внутри вашего приложения
Cookies и файлы: WebView-ы одного приложения делят cookies, поэтому чистите их между потоками; оставляйте MIXED_CONTENT_NEVER_ALLOW и setAllowFileAccess(false), ведь file://-URL могут читать песочницу
Аутентификация через системный браузер: ASWebAuthenticationSession / Custom Tabs; Google блокирует OAuth во встроенных WebView (disallowed_useragent), а RFC 8252 это запрещает: приложение-хозяин видит всё, что вводит пользователь
Deep link приходит снаружи с параметрами, которые задаёт атакующий, а у обычной кастомной схемы нет владельца: myapp:// может зарегистрировать любое приложение. Закрепляйте ссылки за собой через verified https-ссылки и считайте всё внутри них враждебным вводом.
Android App Links: android:autoVerify="true" в intent-filter плюс /.well-known/assetlinks.json на вашем домене с именем пакета и SHA-256 подписи; система проверяет это сама, и чужое приложение такую ссылку не заберёт
iOS Universal Links: файл apple-app-site-association на домене с Team ID и bundle ID плюс entitlement Associated Domains
Кастомные схемы: не проверяются, поэтому никогда не возвращайте через них OAuth code или токен сброса пароля; PKCE делает перехваченный code бесполезным
Allowlist маршрутов: сопоставляйте путь с известной таблицей маршрутов, всё остальное отправляйте на экран 404, и никогда не открывайте WebView с URL из ссылки
Ссылка ведёт, а не действует: ?userId=42 лишь выбирает экран; экран назначения перепроверяет сессию, а что показать, решает сервер
Проверка хоста сравнивает строковые префиксы, поэтому https://api.example.com.evil.io/x.png или https://api.example.com@evil.io/x.png её проходят, и любой URL, который выбрал атакующий и который загружается через этот Dio, получает bearer-токен пользователя.
Почему так происходит: startsWith сравнивает символы, а не origin; в первом URL хост равен api.example.com.evil.io, во втором всё до @ относится к user info, а хост равен evil.io
Откуда берутся такие URL: вложения в чате, аватарки, ссылки next для пагинации, payload пушей и deep link; тот, кто управляет URL, собирает токены в своём access-логе
Исправление: один раз распарсить URL и точно сравнить uri.scheme и uri.host с allowlist; для поддоменов проверять host == d || host.endsWith('.$d'), с точкой в начале
Эшелонированная защита: Dio игнорирует baseUrl, если ему передан абсолютный URL, поэтому сторонние URL (аватарки, вложения, ссылки next) загружайте отдельным Dio без auth-интерцептора
Как поймать в следующий раз: юнит-тест, который прогоняет эти враждебные URL через интерцептор и проверяет, что заголовка Authorization нет
Оценивайте пакет по тому, как его поддерживают и что он может сделать внутри вашего приложения, а не по лайкам; для платежей берите официальный SDK вендора. Любой пакет работает с правами вашего приложения, и всё, от чего он зависит, тоже.
Перед добавлением: свежие коммиты, разобранные issue, changelog, verified publisher и flutter pub deps, чтобы посмотреть размер транзитивного дерева и лицензии в нём
Нативный код: папка android/ или ios/ означает Kotlin, Swift или C вне Dart-песочницы плюс зависимости Gradle и CocoaPods, которых в pub-графе не видно
Потом: коммитьте pubspec.lock и собирайте в CI через flutter pub get --enforce-lockfile, пусть Dependabot или Renovate приносят обновления небольшими PR, а в CI гоняйте сканер секретов и проверку лицензий
Частая ошибка: считать dev_dependencies безобидными: кодогенераторы выполняют произвольный код на CI-машине, где лежат ключи подписи
Зашифрованный блоб попал в бэкап и восстановился, а ключ к нему нет: flutter_secure_storage пишет шифротекст в обычный файл, а ключ лежит в Android Keystore и устройство не покидает.
Android: android:allowBackup по умолчанию true, поэтому бэкап или перенос копирует файл на новый телефон без ключа, и первое чтение падает
Лечение: исключите prefs-файл плагина в android:dataExtractionRules и android:fullBackupContent и поставьте AndroidOptions(resetOnError: true) (по умолчанию с версии 10), чтобы сломанная установка очистилась и попросила войти заново
iOS: Keychain живёт дольше приложения: записи переживают удаление, поэтому держите флаг первого запуска в SharedPreferences (этот файл стирается) и вызывайте deleteAll(), когда флага нет
iOS: класс доступности Keychain: дефолт не читается обработчиком пуша при заблокированном экране и выглядит как случайный разлогин; first_unlock_this_device работает после первой разблокировки
Certificate pinning означает, что приложение доверяет только сертификату или публичному ключу вашего сервера, а не любому сертификату, которому доверяет устройство. Даже если атакующий поставит на телефон свой CA, соединение с вашим API просто оборвётся, а не будет прочитано.
Пиньте публичный ключ или выпускающий CA, а не листовой сертификат: лист меняется при каждом перевыпуске, ключ обычно остаётся.
Самый надёжный способ в Dart: собрать HttpClient с SecurityContext(withTrustedRoots: false)..setTrustedCertificatesBytes(...): всё остальное падает на TLS-хендшейке
Платформенные клиенты: <pin-set> в network security config на Android или TrustKit на iOS накрывают нативные стеки вроде OkHttp и URLSession; собственный HttpClient из Dart работает со своим TLS и эти настройки игнорирует
Частая ошибка: сравнивать в badCertificateCallback, который вообще не вызывается для сертификата, которому устройство уже доверяет
Резервный пин: на следующий ключ, иначе первый перевыпуск отключит от сети все установленные приложения; держите набор пинов обновляемым через подписанный remote config
Пиньте только свои хосты: пин на чужой CDN отдаёт им ваш аптайм
Проверяйте отказ: с включённым пиннингом Charles или Proxyman обязаны не работать
Нет. badCertificateCallback вызывается только после того, как обычная проверка провалилась, поэтому сертификат прокси, подписанный доверенным на устройстве CA, проверку проходит и до сравнения пина не доходит: код пиннит только уже отвергнутые сертификаты.
Для чего этот колбэк: решить, принять ли сертификат, который платформа отвергла; оставшийся после отладки => true и есть то, из-за чего релизные сборки начинают доверять всему подряд
Исправление 1, предпочтительное: SecurityContext(withTrustedRoots: false) с вашим CA через setTrustedCertificatesBytes, и любая другая цепочка падает на хендшейке
Исправление 2, как второй слой: IOHttpClientAdapter.validateCertificate в Dio видит сертификаты, прошедшие проверку, но проверяет уже ответ, так что запрос к этому моменту отправлен
Второй баг: хеш от cert.der пиннит весь листовой сертификат, а он меняется при каждом перевыпуске; пиньте публичный ключ или выпускающий CA и держите резервный пин
PKCE позволяет мобильному приложению безопасно пройти OAuth code flow без client secret. Приложение доказывает, что именно оно начало вход, поэтому украденный authorization code никому другому не пригодится.
Мобильное приложение является публичным клиентом: любой зашитый в него секрет можно достать, так что client_secret ничего не доказывает. PKCE заменяет его одноразовым доказательством на каждый вход.
Поток: приложение генерирует случайный code_verifier и отправляет code_challenge = base64url(SHA256(verifier)) с code_challenge_method=S256; после входа оно обменивает полученный code вместе с исходным verifier, и IdP выдаёт токены только при совпадении хеша
От чего защищает: атакующий, перехвативший редирект, получит code, но не verifier, и обмен провалится
Почему обязателен: OAuth 2.1 делает PKCE обязательным и убирает implicit grant; RFC 8252 требует того же для нативных приложений
Системный браузер, не WebView: ASWebAuthenticationSession на iOS, Custom Tabs на Android, а для редиректа используйте universal link, а не кастомную схему
Для запросов используйте короткоживущий access-токен (минуты), а для получения новых нужен более долгий refresh-токен, и при каждом обновлении выдавайте новый refresh-токен. Тогда украденный токен быстро протухает, а повторное использование старого refresh-токена можно заметить.
Где хранить: access-токен в памяти, refresh-токен в flutter_secure_storage; никогда в SharedPreferences или в URL
Ротация: каждый refresh возвращает новую пару, а сервер инвалидирует старый refresh-токен; если кто-то использовал украденный старый токен, настоящее устройство получит 401 на следующем обновлении, что служит сигналом отозвать всю цепочку
На клиенте: одновременно идёт только одно обновление: параллельные 401 ждут один и тот же Future, новая пара сохраняется до повтора исходного запроса; два параллельных refresh сработали бы как reuse detection против вас самих
Если сам запрос обновления вернул 401: логаут, смена пароля или отзыв: очищайте хранилище и отправляйте пользователя на вход
Привязка к устройству: DPoP, mTLS или аттестованный ключ делают украденный refresh-токен бесполезным на другом устройстве
Каждый запрос, получивший 401, запускает собственный refresh. Когда несколько запросов падают одновременно, все они отправляют один и тот же refresh-токен; сервер ротирует его на первом вызове, второй считает повторным использованием потраченного токена и отзывает всю сессию.
Почему так происходит: access-токен истекает сразу для всех запросов, поэтому после паузы в фоне три-четыре параллельных вызова первого экрана вместе получают 401, и каждый onError вызывает tokens.refresh() с refresh-токеном, который всё ещё лежит в хранилище
Почему сервер разлогинивает: при ротации refresh-токен одноразовый; повторное использование выглядит в точности как переигрывание украденного токена, поэтому reuse detection отзывает всё семейство токенов, и защита работает как задумано, но против вашего же клиента
Исправление: держите один Future для текущего refresh; каждый 401 ждёт его и повторяет запрос с новым access-токеном, а поле очищается по завершении, чтобы следующее истечение запустило новый refresh
**Одного QueuedInterceptor мало**: он выполняет вызовы onError по очереди, и повторного использования больше нет, но каждый 401 из очереди снова ротирует пару; сравнивайте токен, с которым ушёл запрос, с текущим и, если он уже сменился, просто повторяйте запрос
Как поймать в следующий раз: тест с фейковым HttpClientAdapter, который отправляет три запроса с истёкшим токеном и проверяет, что /auth/refresh вызван ровно один раз
Локальные проверки (бинарник su, пути Magisk или Cydia, сборка с test-keys, признаки эмулятора) легко написать и так же легко перехватить: Frida переписывает их результат прямо в вашем процессе. Надёжный контроль даёт аппаратная аттестация, которую проверяет ваш сервер: Play Integrity или App Attest.
Play Integrity: замена SafetyNet: сервер выдаёт nonce, приложение запрашивает токен, а расшифровывает и проверяет его сервер, никогда не приложение
Вердикты, которые стоит назвать: appRecognitionVerdict PLAY_RECOGNIZED для неизменённого бинарника, deviceRecognitionVerdict MEETS_BASIC / DEVICE / STRONG_INTEGRITY, appLicensingVerdict показывает, установлено ли из Play
App Attest: DCAppAttestService создаёт ключ в Secure Enclave и аттестует его по challenge с сервера; дальнейшие вызовы несут assertion с растущим счётчиком, что ломает replay
Сигнал риска, а не запрет: усиливайте аутентификацию или снижайте лимиты вместо блокировки; кастомные прошивки, эмуляторы в CI и устройства без сервисов Play проваливают проверку регулярно
Локальные проверки служат телеметрией: проверки путей в духе RootBeer повышают стоимость атаки, но ничего не решают; задача Dart в том, чтобы получить токен и приложить его
Ключ, который может вычислить кто угодно, ключом не является, а фиксированный IV делает одинаковые документы одинаковым шифротекстом; вместе это обфускация, а не шифрование. Нужен случайный ключ в secure storage, свежий случайный nonce на каждое сообщение и режим с аутентификацией.
Ключ: email публичен и почти лишён энтропии; берите 256 случайных бит из CSPRNG или медленную KDF вроде Argon2id от настоящего секрета со своей солью на пользователя
Nonce: при повторе утекает XOR двух открытых текстов, а в AES-GCM атакующий сможет подделывать теги; генерируйте свежие 96 бит на сообщение и храните рядом с шифротекстом, nonce не секрет
Аутентификация: чистые CBC или CTR позволяют переворачивать биты открытого текста; берите AEAD вроде AES-GCM и отбрасывайте сообщение при несовпадении тега до любого разбора
Случайность: Random() работает как быстрый PRNG; до системного CSPRNG добирается только Random.secure()
Где живёт ключ: случайный ключ данных шифрует файлы, а сам лежит в Keychain/Keystore через flutter_secure_storage; локальной базе нужен SQLCipher, а не самодельный контейнер
Шифрование диска защищает только выключенный телефон: после первой разблокировки с момента загрузки ключи остаются в памяти, а всё вне flutter_secure_storage живёт ровно под этим дефолтом. Кеши читались, потому что телефон был включён.
Классы защиты файлов на iOS: по умолчанию у файлов приложения CompleteUntilFirstUserAuthentication; NSFileProtectionComplete привязывает ключ к экрану блокировки, но тогда background fetch и обработчики пушей не прочитают такие файлы при блокировке
Android: File-Based Encryption открывает credential-encrypted storage после первой разблокировки после загрузки; на рутованном устройстве читается всё
Путь через бэкап: android:allowBackup по умолчанию true, поэтому исключайте чувствительные файлы в dataExtractionRules; на iOS держите производные данные в Caches или ставьте флаг исключения из iCloud-бэкапа
Копии, о которых забывают: кеши картинок и HTTP, cookies WebView, выгруженные PDF в tmp, логи в Documents; логаут обязан вычищать всё это, а не только токены
Шифруйте поверх платформы: SQLCipher для базы и ключ данных, обёрнутый Keystore, для файлов; включённый телефон защищает только ключ, привязанный к пользователю
Премиум выдаёт устройство, а на устройстве работает код атакующего. Выдавать право должен ваш сервер по чеку, подписанному стором, а клиент лишь показывает то, что решил сервер.
Поток: покупка через in_app_purchase, затем отправьте PurchaseDetails.verificationData.serverVerificationData на бэкенд вместе с сессией пользователя
Проверка на сервере: App Store Server API или Google Play Developer API проверяют подпись стора, а вы отклоняете чек, уже привязанный к другому аккаунту
Сначала выдать, потом завершить: выдайте право на сервере, верните его клиенту и только потом вызывайте completePurchase; неподтверждённая покупка на Android возвращается через три дня
Чего клиент не должен: считать PurchaseStatus.purchased выдачей или хранить isPremium локально; кешируйте серверное право с коротким TTL
Состояния, о которых забывают: возвраты, отмены, льготный период, восстановление на новом устройстве: подключите серверные уведомления обоих сторов к тому же сервису