Вопрос «где хранить токен в браузере» задают как вопрос о месте, а ответ на него определяется не местом, а тем, от какой угрозы вы защищаетесь. Два популярных варианта защищают от разных вещей, и спор о том, какой лучше, без указания угрозы не имеет решения.
Две угрозы, а не одна
Чужой скрипт на вашей странице. Код, попавший на страницу через уязвимость или через зависимость, выполняется с теми же правами, что и ваш. Он может прочитать всё, к чему у страницы есть доступ.
Чужой сайт, действующий от имени пользователя. Страница на другом домене отправляет запрос к вашему серверу, и браузер прикладывает к нему сохранённые данные автоматически. Пользователь ничего не нажимал осознанно.
Эти угрозы защищаются противоположными свойствами. От первой спасает недоступность данных для скрипта. От второй — контроль над тем, когда данные прикладываются к запросу. Одно хранилище не даёт обоих свойств бесплатно.
Что даёт каждое место
Хранилище страницы. Данные доступны скрипту и не отправляются никуда сами. Значит, защищены от второй угрозы полностью и от первой — никак: любой выполнившийся на странице код прочитает токен и отправит куда угодно.
Cookie, недоступная скрипту. Помечена так, что код страницы её не видит. Защищена от первой угрозы: чужой скрипт не может её прочитать. Но браузер прикладывает её к запросам автоматически, включая запросы, инициированные другим сайтом, — значит, вторая угроза остаётся и закрывается отдельно.
Отсюда вывод, который обычно и является ответом: cookie, недоступная скрипту, плюс явная защита от запросов со стороны. Это единственная комбинация, закрывающая обе угрозы, и она требует работы на сервере, а не только выбора места.
Защита от запросов со стороны сегодня складывается из двух вещей. Первая — признак, ограничивающий отправку cookie при переходах с чужих сайтов; он закрывает большую часть случаев и настраивается одним значением. Вторая — проверка происхождения запроса на сервере: заголовок, сообщающий, откуда пришёл запрос, сверяется со списком разрешённых. Ни одна из двух не заменяет другую полностью, поэтому ставят обе.
Тупик: спрятать токен получше
Первое, что пробуют, — усложнить доступ: зашифровать значение перед сохранением, разложить по частям, положить в переменную, а не в хранилище.
Это не помогает, потому что не меняет модель угрозы. Скрипт, выполняющийся на странице, имеет доступ ко всему, к чему имеет доступ страница, включая ключ шифрования и код, который собирает токен из частей. Он может просто дождаться момента, когда токен соберут, и прочитать готовое.
Разновидность того же — хранить токен в памяти и не сохранять никуда. Это действительно снижает риск: токен исчезает при перезагрузке страницы, и окно возможностей у чужого кода короче. Но пока страница открыта, он доступен так же, как и раньше, — а именно тогда чужой скрипт и работает.
Общая ошибка: попытка защититься от кода, выполняющегося в том же контексте, средствами того же контекста.
Что действительно меняет картину
Короткий срок жизни и обновление. Токен, живущий минуты, ограничивает ущерб от кражи: украденное быстро протухает. Обновление делается отдельным долгоживущим значением, которое хранится строже и используется реже.
Привязка к признакам сеанса. Токен, действительный только вместе с определённым признаком — устройством, отпечатком соединения, — становится бесполезен, будучи украденным и использованным в другом месте. Полностью проблему не решает, но повышает цену атаки.
Ограничение области действия. Токен, дающий доступ ко всему, и токен, дающий доступ к одной операции, стоят по-разному при краже. Разделение прав — это работа не в браузере, а в проектировании интерфейса сервера.
Возможность отозвать. Токен, который нельзя отозвать до истечения срока, превращает любой инцидент в ожидание. Список отозванных или короткий срок с обновлением — выбор между двумя видами расходов, но что-то из этого должно быть.
Здесь стоит назвать неочевидную цену самодостаточных токенов, которые проверяются без обращения к хранилищу. Их удобство — в том, что сервер не ходит в базу при каждом запросе. Их цена — в том, что отозвать такой токен нечем: он действителен, пока не истёк, и смена пароля пользователем его не отменяет. Либо вы заводите список отозванных и теряете исходное преимущество, либо соглашаетесь, что окно после инцидента равно сроку жизни токена. Это решение принимают один раз и осознанно, а не обнаруживают в день происшествия.
Чего не отменяет выбор хранилища
Ни одно место хранения не спасает, если чужой скрипт вообще попал на страницу. Поэтому работа начинается не с выбора хранилища, а с того, чтобы чужого кода на странице не было: экранирование данных при выводе, политика источников содержимого, контроль над зависимостями.
Хранилище — это то, что уменьшает ущерб, когда предыдущие меры не сработали. Начинать с него — значит готовиться к последствиям, не занимаясь причиной.
Проверить это соотношение просто. Спросите себя, что произойдёт, если на страницу попадёт чужой скрипт. Если ответ «он украдёт токен» — вы выбираете хранилище. Если ответ «он сделает от имени пользователя всё что угодно, даже не читая токен» — хранилище здесь ни при чём, и работать надо выше.
Отдельно: токен в адресной строке недопустим при любом хранилище. Адрес попадает в журналы сервера, в историю браузера и в заголовок перехода на чужой сайт.
И ещё одно, о чём вспоминают поздно: выход из системы должен что-то делать на сервере, а не только очищать хранилище в браузере. Удаление токена на стороне клиента не отменяет его действительность — украденная до выхода копия продолжит работать. Если отзыв невозможен по устройству токена, короткий срок жизни остаётся единственным, что ограничивает последствия.
Позиция редакции
Мы считаем, что для веб-приложения ответ по умолчанию — cookie, недоступная скрипту, с явной защитой от запросов со стороны и коротким сроком жизни. Практический вывод: если у вас токен лежит в хранилище страницы и это выбрано «потому что проще работать с запросами», вы приняли решение по удобству, а не по угрозе, и стоит это хотя бы записать осознанно.
Мы неправы, если у приложения нет собственного домена и cookie использовать нельзя — например, это виджет, встраиваемый на чужие сайты. Там выбор ограничен, и правильный ответ строится вокруг короткого срока жизни и ограниченных прав, а не вокруг места хранения.