Хранение мобильных токенов: что держать в Keychain или Keystore
Хранение мобильных токенов быстро становится сложным. Узнайте, что нужно держать в Keychain или Keystore, что лучше не хранить там, и как аккуратно ротировать credentials приложения.

Почему важно локальное хранение токенов
Мобильный session token — это подтверждение для приложения, что вы уже вошли в аккаунт. После первого входа приложение отправляет этот токен с последующими запросами, чтобы сервер не просил пароль каждый раз. Именно это делает приложение удобным. Вы открываете его, нажимаете один раз — и ваш аккаунт уже на месте.
Но это удобство создаёт риск. Если кто-то украдёт токен, ему часто вообще не нужен ваш пароль. Он сможет обращаться к API как будто это вы, читать личные данные, оформлять заказы, менять настройки или поддерживать сессию, пока сервер её не заблокирует. Во многих приложениях украденного токена достаточно, чтобы действовать от имени пользователя часами, днями или даже дольше.
Поэтому мобильное хранение токенов так важно. Токен, лежащий в обычном хранилище приложения, в debug-логах, истории буфера обмена или незащищённой локальной базе, скопировать проще, чем ожидают многие команды. Взломанный телефон, плохой бэкап, общий девайс или другое приложение с лишним доступом могут превратить маленькую ошибку хранения в полный захват аккаунта.
Keychain на iOS и Keystore на Android дают более безопасное место для секретов. Они созданы, чтобы держать чувствительные данные под защитой на уровне ОС, а не в обычных файлах приложения. Обычное хранилище всё ещё подходит для безобидных данных вроде состояния интерфейса, кэша экранов или feature flags. Но это плохое место для всего, что может открыть авторизованную сессию.
Цель простая: держать пользователей в системе, не храня больше секретных данных, чем нужно. Хорошее приложение запоминает достаточно, чтобы безопасно восстановить сессию, но не настолько много, чтобы отдать аккаунт при утечке локальных данных. Обычно это значит относиться к токенам как к наличным в кошельке. Храните только необходимое, хорошо защищайте и заменяйте до того, как это станет долгосрочной проблемой.
Небольшой пример хорошо показывает риск. Если приложение для заметок хранит session token в обычном файле настроек, любой, кто скопирует этот файл, может получить доступ к синхронизации, читать и удалять заметки пользователя. Если же приложение хранит секрет в Keychain или Keystore, украсть его становится намного сложнее.
Что должно храниться в Keychain или Keystore
Используйте Keychain на iPhone и Keystore на Android для секретов, которые должны пережить перезапуск приложения и быть привязаны к устройству. В мобильном хранении токенов это обычно означает небольшие данные, которые могут безопасно открыть сессию, а не все auth-значения, с которыми когда-либо работает приложение.
Refresh token подходит сюда, если приложению нужно сохранять вход пользователя после закрытия. Он небольшой, долго живёт и чувствителен к утечке. Если кто-то его скопирует, он часто сможет выпускать новые access tokens в течение дней или недель. Это делает его гораздо лучшим кандидатом для защищённого хранения, чем обычная база приложения или shared preferences.
Приватные ключи, созданные на устройстве, тоже должны храниться здесь. Если приложение использует клиентские сертификаты, device binding или локально подписывает запросы, создавайте приватный ключ прямо в защищённом хранилище и оставляйте его там. Приложение должно использовать ключ, а не экспортировать его и переносить куда-то ещё.
Ещё один хороший вариант — небольшой секрет, который позволяет расшифровать что-то другое. Например, приложение может хранить на диске зашифрованный blob, а в Keychain или Keystore держать только секрет для его расшифровки. Так защищённое значение остаётся маленьким, а объём данных в secure storage — минимальным.
Храните каждое значение отдельно и привязывайте его к одному аккаунту. Если пользователь может переключаться между рабочим и личным аккаунтом, сохраняйте отдельные записи для каждого. Добавляйте понятные метки и account ID, чтобы приложение удаляло или заменяло правильный секрет при logout, повторном входе или удалении аккаунта.
Работает простое правило:
- Храните refresh tokens, которые должны переживать перезапуск приложения.
- Храните приватные ключи, созданные на устройстве.
- Храните один небольшой wrapping secret, если используете зашифрованные локальные blobs.
- Храните значения отдельно для каждого аккаунта, а не один общий секрет приложения.
Размер важнее, чем многие думают. Keychain и Keystore лучше всего подходят для небольших секретов, а не для больших session objects или кэша API-данных. Если значение компактное, чувствительное и его сложно заменить, скорее всего, ему место именно там.
Один пример: банковское приложение выполняет вход пользователя, держит короткоживущий access token в памяти, сохраняет refresh token в защищённом хранилище и создаёт device key для подтверждения владения устройством. После перезапуска приложение читает refresh token, запрашивает у сервера новый access token и продолжает работу, не раскрывая долгоживущий секрет в обычном локальном хранилище.
Что должно оставаться вне этого хранилища
Keychain и Keystore должны хранить небольшой набор секретов, а не всё, что знает приложение. Относитесь к ним как к запертому кошельку, а не к кладовке. Чем больше лишних данных вы туда кладёте, тем больший ущерб может нанести debug-сборка, утечка бэкапа или общий девайс.
Не храните сырой пароль пользователя после входа. Приложение должно обменять его на session credential, а потом удалить. Если приложению нужно сохранить вход пользователя, эту задачу должен выполнять refresh token. Сохранённый пароль создаёт более серьёзную проблему при компрометации телефона и часто ломает сценарии позже, когда пользователь меняет пароль.
Access tokens тоже не нуждаются в долгосрочном хранении во многих приложениях. Обычно они быстро истекают, поэтому лучше держать их в памяти, а после перезапуска получать новый access token через refresh token. Это уменьшает время, в течение которого украденный токен остаётся полезным.
Полные профили пользователя тоже не должны храниться там. Имена, аватары, настройки, feature flags и большие массивы прав обычно не являются секретами в том же смысле, что и токен. Например, shopping app может кэшировать историю заказов или данные профиля в обычном хранилище приложения и получать более свежие данные после входа. Так secure storage остаётся небольшим и сфокусированным.
Логи, дампы запросов и большие blobs — ещё одна частая ошибка. Разработчики добавляют их для диагностики, а потом забывают, что туда часто попадают заголовки, cookies, email-адреса и фрагменты токенов. Один debug log с Authorization header может перечеркнуть аккуратный план хранения токенов.
Используйте простой фильтр перед тем, как что-то туда сохранить:
- Это секрет или просто данные приложения?
- Нужно ли приложению это после перезапуска?
- Можно ли держать короткоживущую копию в памяти вместо этого?
- Достаточно ли это мало для одного секретного значения?
- Приведёт ли утечка к тому, что кто-то сможет начать новую сессию?
Если на большинство вопросов ответ «нет», не храните это там. Защищённое хранилище лучше всего работает, когда в нём лежат только несколько вещей, подтверждающих личность, и ничего больше.
Простая схема хранения, которая работает
Большинство приложений безопаснее, если по-разному относятся к access token и refresh token. Держите access token короткоживущим и только в памяти. Refresh token храните в Keychain на iOS или в защищённом хранилище на Android, связанном с Keystore.
Такое разделение даёт два полезных эффекта. Оно ограничивает время, в течение которого украденный access token может работать, и держит более долгоживущий credential под защитным слоем телефона. Для мобильного хранения токенов это обычно самый чистый вариант по умолчанию.
Когда пользователь входит в систему, сервер возвращает access token и refresh token. Приложение загружает access token в менеджер сессии и использует его для API-вызовов. В защищённое хранилище приложение записывает только refresh token.
Не сохраняйте access token в shared preferences, локальных файлах, SQLite или логах. Если приложение перезапускается, прочитайте refresh token из защищённого хранилища, запросите у сервера свежий access token и продолжайте работу. Этот дополнительный запрос — разумная цена за меньший риск.
Простой поток выглядит так:
- Пользователь входит и получает оба токена.
- Приложение держит access token в памяти с коротким сроком жизни, часто всего несколько минут.
- Приложение сохраняет refresh token в Keychain или в хранилище на базе Keystore.
- Когда access token истекает, приложение читает refresh token, обращается к endpoint обновления и получает новые данные для входа.
- При logout или переключении аккаунта приложение сразу очищает память и удаляет сохранённый refresh token.
Такой подход также упрощает ротацию. Если сервер выдаёт новый refresh token во время refresh-запроса, сразу перезаписывайте старый. Не храните резервную копию, если только архитектура сервера не требует короткого окна перекрытия.
Небольшое банковское или shopping-приложение может обновлять токены всего несколько раз в день. Это нормально. Не нужно читать refresh token из защищённого хранилища при каждом запросе. Доставайте его только тогда, когда он действительно нужен, и продолжайте работу.
Ещё одна деталь важнее, чем многие думают: относитесь к переключению аккаунта как к logout. Уберите access token из памяти, удалите сохранённый refresh token и только потом начинайте новый вход. Это предотвращает попадание сессии одного пользователя в данные другого.
Как аккуратно ротировать credentials
Ротируйте refresh tokens до того, как они подойдут к сроку истечения слишком близко. Если приложение ждёт до последней минуты, одна плохая связь в поезде или туннеле может превратить обычное обновление в выход из системы. Более безопасный подход прост: пока текущий refresh token ещё жив достаточно долго, приложение запрашивает у сервера новый access token и новый refresh token.
На телефоне сначала запишите новый refresh token в Keychain или Keystore. После этого обновите access token, который приложение использует для API-вызовов. Только потом помечайте старый refresh token как отозванный. Порядок важен. Если приложение сначала удалит старый token, а потом упадёт, пользователь может потерять сессию, хотя с аккаунтом не было ничего плохого.
Сетевые сбои требуют немного терпения. Если refresh-запрос истёк по таймауту, оставьте текущий refresh token и попробуйте ещё раз, когда связь восстановится. Не выкидывайте пользователя из системы после одной неудачной попытки ротации. Дайте приложению небольшой запас на повторную попытку и завершайте сессию только тогда, когда сервер сообщает, что token истёк, был отозван или использован повторно.
Правила на сервере, которые предотвращают хаос
Сервер должен относиться к refresh tokens как к цепочке, а не как к куче случайных секретов.
- Каждый успешный refresh возвращает новую пару токенов.
- После этого сервер отзывается от предыдущего refresh token.
- Если кто-то снова пытается использовать более старый refresh token, сервер считает это повторным использованием.
- Затем сервер отзывает сессию этого устройства и просит заново войти в систему.
Проверка повторного использования особенно важна для безопасности мобильных сессий. Если malware или утёкший бэкап раскроет старый refresh token, detection of reuse даёт вам ещё один шанс остановить сессию до того, как утечка превратится в долгую компрометацию.
Отслеживайте tokens по семействам, по одному семейству на сессию устройства. Пользователь может войти на iPhone, Android-планшете и рабочем телефоне. Если одно устройство выглядит подозрительно, нужно отозвать только его семейство. Остальные устройства могут оставаться в системе.
Такой подход делает мобильное хранение токенов безопаснее и уменьшает количество неожиданных выходов из системы, которые пользователи не любят почти так же сильно, как баги безопасности.
Пример реального приложения
Shopping app часто показывает ту же проблему сессии, на которую жалуются пользователи: они возвращаются через два дня, открывают приложение и ожидают, что всё сразу заработает. Им не важно, что access token истёк ночью. Им важно, чтобы корзина, страница аккаунта и сохранённые адреса открывались без лишней драмы.
Хороший поток прост. Приложение держит короткоживущий access token в памяти, пока запущено. Refresh token оно хранит в Keychain на iPhone или в защищённом хранилище на базе Keystore на Android. Когда пользователь возвращается через два дня, первый API-вызов падает с ошибкой auth, потому что access token устарел.
В этот момент приложение не должно сразу возвращать пользователя на экран входа. Оно должно попробовать одно обновление в фоне:
- Прочитать refresh token из защищённого хранилища.
- Отправить его на сервер через обычный refresh endpoint.
- Получить новый access token и новый refresh token.
- Сразу заменить старый refresh token в защищённом хранилище.
- Повторить исходный запрос с новым access token.
Если всё сработает, пользователь не заметит разрыва в сессии. Откроется главный экран, появится счётчик корзины, и checkout продолжит работать. Вот как на практике выглядит хорошее хранение токенов: тихо, незаметно и надёжно.
Шаг обновления важнее, чем кажется многим командам. Если сервер ротирует refresh tokens, приложение должно сохранить новый токен до того, как продолжит работу. Если по ошибке оно оставит старый токен, следующий refresh может не сработать, хотя предыдущий выглядел нормально. Обычно такой баг проявляется через несколько дней, и его трудно отследить.
Если refresh не удался из-за отсутствия токена, его отзыва или истечения срока жизни, приложение должно перестать пытаться. Уберите access token из памяти, удалите refresh token из защищённого хранилища и отправьте пользователя на вход. Короткого сообщения достаточно: «Срок вашей сессии истёк. Пожалуйста, войдите снова».
Такой подход даёт пользователям плавную сессию, когда refresh работает, и чистый сброс, когда нет.
Ошибки, из-за которых происходят утечки
Самые простые утечки обычно появляются не из-за сложных атак, а из-за обычного кода приложения. Команда спешит, сохраняет токен в SharedPreferences или UserDefaults и планирует исправить это потом. Но «потом» часто так и не наступает. Для мобильного хранения токенов такой обходной путь — один из самых быстрых способов превратить обычную ошибку в захват аккаунта.
Токены также утекают через места, которые разработчики забывают считать чувствительными. Debug-логи, сетевые логи, события аналитики, crash reports и скриншоты для поддержки могут все их захватить. Одного скопированного header в баг-репорте уже достаточно. Если приложение выводит access tokens или refresh tokens где-либо вне защищённого хранилища, считайте, что их увидят.
Долгоживущие refresh tokens создают другой тип утечки. Если один и тот же refresh token остаётся действительным месяцами, украденный токен продолжает работать задолго после того, как пользователь забудет устройство или сменит пароль. Ротация сокращает это окно. Когда сервер выдаёт новый refresh token, приложение должно сразу заменить старый и прекратить его использование.
Использовать один токен на много устройств — ещё одна тихая ошибка. На первый взгляд это просто, но потом становится сложно чистить последствия. Если один телефон потеряется, вам придётся выбирать между тем, чтобы оставить украденную сессию живой, или разлогинить пользователя везде. Refresh tokens, привязанные к устройствам, делают реагирование на инцидент намного проще.
Многие приложения забывают и про самый очевидный момент: выход из системы. Если пользователь нажимает «выйти», приложение должно сразу удалить токены из защищённого хранилища, очистить копии в памяти и стереть кэш авторизации. Иначе следующий человек, который откроет приложение, может снова попасть в активную сессию.
Короткий чек-лист помогает поймать большинство таких проблем:
- Храните токены только в Keychain или в хранилище на базе Keystore
- Убирайте токены из логов, трассировок и crash payloads
- Ротируйте refresh tokens и отклоняйте старые после использования
- Выдавайте отдельные refresh tokens для каждого устройства
- Полностью удаляйте локальные credentials при logout
Небольшой пример показывает, как эти ошибки складываются вместе. Shopping app хранит refresh token в обычном UserDefaults, пишет в логи неудачные API-запросы и использует один и тот же token на телефоне и планшете. Пользователь продаёт старый телефон без полной очистки. Тот, кто его получает, может открыть приложение, обновить сессию и оставаться в системе неделями. Ни одна из этих ошибок по отдельности не выглядит драматично. Вместе — уже достаточно.
Быстрые проверки перед релизом
Ошибки перед релизом часто прячутся в крайних сценариях, а не в happy path. Мобильное приложение может выглядеть нормально на тестах и всё равно раскрыть сессию в первый же раз, когда у пользователя пропадает сеть, приложение перезапускается или он входит во второй аккаунт.
Для мобильного хранения токенов финальная проверка должна смотреть не только на то, где вы сохранили token, но и на поведение. Keychain или Keystore защищают secrets в состоянии покоя, но плохая логика приложения всё ещё может удерживать не того пользователя в системе или отправлять устаревшие credentials часами.
Проверьте всё на реальном устройстве, а не только в симуляторе:
- Перезапустите приложение после входа, после refresh token и после принудительного закрытия. Пользователь должен оставаться в системе там, где это нужно, а приложение не должно дублировать сессии или терять текущий аккаунт.
- Тщательно протестируйте logout и переключение аккаунтов. После выхода приложение должно очистить локальные credentials, остановить фоновое обновление и открываться в чистом состоянии. После смены аккаунта на экране и в кэше не должно оставаться данных предыдущего пользователя.
- Запустите запрос, отключите сеть и дождитесь истечения access token. Когда связь вернётся, приложение должно один раз обновить token, один раз повторить запрос и не зациклиться на нескольких refresh подряд.
- Проверьте логи устройства, crash logs, события аналитики и инструменты отладки. Токены, refresh tokens, cookies и Authorization headers не должны появляться там вообще, даже в development builds.
- Попробуйте rooted или jailbroken устройство, если ваша команда учитывает такой риск. Решите, что должно делать приложение: предупреждать, ограничивать доступ или блокировать вход. Затем протестируйте revoke устройства и убедитесь, что отозванное устройство теряет доступ уже при следующем refresh, а не через несколько дней.
Один небольшой сценарий ловит очень много ошибок: войдите как User A, принудительно закройте приложение, откройте снова, переключитесь на User B, уйдите офлайн, дождитесь истечения token, снова подключитесь и выйдите из системы. Если после переключения на экране где-то появляется data User A или приложение обновляется с неправильным token, исправьте это до релиза.
Эти проверки занимают меньше часа. Но они могут сэкономить недели на исправление утечки или путаницы с аккаунтами.
Что делать дальше
Превратите auth-flow в письменную политику, а не в знания «по памяти» у команды. Одной страницы достаточно, если она отвечает на несколько простых вопросов: какие токены хранит приложение, где лежит каждый из них, как долго он живёт и что происходит при logout, переустановке, сбросе пароля и отзыве устройства.
Обычно лучше работает небольшая таблица, а не длинный документ. Можно решить, что refresh token хранится в Keychain или Keystore, access token остаётся только в памяти, а данные профиля никогда не лежат рядом с credentials. Чёткие правила вроде этих не дают mobile token storage превратиться в набор исключений.
Затем согласуйте между backend и mobile-командами время и поведение. Если сервер ротирует refresh tokens при каждом использовании, приложение должно сначала сохранить новый token и сразу удалить старый. Если сервер умеет отзывать одно устройство, приложение должно считать неудачный refresh настоящим выходом из системы, очищать storage и отправлять пользователя на вход без циклов повторных попыток.
Дальнейшие шаги должны быть конкретными:
- Запишите одно правило хранения для каждого token, secret и session value
- Зафиксируйте срок жизни токенов, правила rotation и поведение revoke в одном общем документе
- Добавьте тесты на вход, refresh, logout, истёкшие сессии, офлайн-повтор и revoke устройства
- Проверьте логи, crash reports и debug builds, чтобы токены никогда не выходили за пределы защищённого хранилища
Если приложение уже в продакшене, прогоните эти проверки на реальном устройстве. Потом повторите их после переустановки приложения и после обновления ОС. Эти два момента часто выявляют auth-баги, которые никогда не появляются в обычных happy-path тестах.
Дополнительный разбор помогает, когда flow уже стал запутанным или команда постоянно чинит крайние сценарии. Oleg Sotnikov может посмотреть на auth-design в практической роли Fractional CTO advisory, с глубоким опытом в мобильной безопасности, backend-архитектуре и production-системах. Такой разбор часто помогает поймать маленькие ошибки до того, как они превращаются в утечки аккаунтов, сломанные сессии или неделю исправлений.