Где хранятся токены в браузере

Обновлено: 05.07.2024

JWT (JSON Web Token) — это замечательный стандарт, основанный на формате JSON, позволяющий создавать токены доступа, обычно используемые для аутентификации в клиент-серверных приложениях. При использовании этих токенов возникает вопрос о том, как безопасно хранить их во фронтенд-части приложения. Этот вопрос нужно решить сразу же после того, как токен сгенерирован на сервере и передан клиентской части приложения.


Материал, перевод которого мы сегодня публикуем, посвящён разбору плюсов и минусов использования локального хранилища браузера ( localStorage ) и куки-файлов для хранения JWT.

Виды токенов

Где именно следует хранить токены на клиенте?

Существует 2 распространённых способа хранения токенов на клиенте: локальное хранилище браузера и куки-файлы. О том, какой способ лучше, много спорят. Большинство людей склоняется в сторону куки-файлов из-за их лучшей защищённости.

Давайте сравним локальное хранилище и куки-файлы. Наше сравнение основано, преимущественно, на этом материале и на комментариях к нему.

Локальное хранилище

▍Преимущества

Основное преимущество локального хранилища заключается в том, что им удобно пользоваться.

  • Работа с локальным хранилищем организована очень удобно, тут используется чистый JavaScript. Если у вашего приложения нет бэкенда, и вы полагаетесь на чужие API, не всегда можно запросить у этих API установку особых куки-файлов для вашего сайта.
  • Используя локальное хранилище, удобно работать с API, которые требуют размещать токен доступа в заголовок запроса. Например — так: Authorization Bearer $ .

▍Недостатки

Главный недостаток локального хранилища — это его уязвимость к XSS-атакам.

  • При выполнении XSS-атаки злоумышленник может запустить свой JavaScript-код на вашем сайте. Это означает, что атакующий может получить доступ к токену доступа, сохранённому в localStorage .
  • Источником XSS-атаки может быть сторонний JavaScript-код, включённый в состав вашего сайта. Это может быть что-то вроде React, Vue, jQuery, скрипта Google Analytics и так далее. В современных условиях почти невозможно разработать сайт, в состав которого не входят библиотеки сторонних разработчиков.

Куки-файлы

▍Преимущества

Главное преимущество куки-файлов заключается в том, что они недоступны из JavaScript. В результате они не так уязвимы к XSS-атакам, как локальное хранилище.

▍Недостатки

В зависимости от конкретных обстоятельств может случиться так, что токены в куки-файлах сохранить не удастся.

  • Размер куки-файлов ограничен 4 Кб. Поэтому, если вы используете большие JWT, хранение их в куки-файлах вам не подойдёт.
  • Существуют сценарии, при реализации которых вы не можете передавать куки своему API-серверу. Возможно и то, что какой-то API требует размещения токена в заголовке Authorization . В таком случае вы не сможете хранить токены в куки-файлах.

XSS-атаки

Куки-файлы и CSRF-атаки

CSRF-атаки — это атаки, в ходе которых пользователя каким-то образом принуждают к выполнению особого запроса. Например, сайт принимает запросы на изменение адреса электронной почты:

Правда, от этой угрозы можно легко защититься, использовав атрибут SameSite в заголовке ответа и анти-CSRF токены.

Промежуточные итоги

Хотя и куки-файлы не отличаются полной неуязвимостью к атакам, для хранения токенов лучше всего, всегда, когда это возможно, выбирать именно их, а не localStorage . Почему?

Использование куки-файлов для хранения токенов OAuth 2.0

Давайте кратко перечислим способы хранения токенов:

Злоумышленник может создать форму, которая обращается к /refresh_token . В ответ на этот запрос возвращается новый токен доступа. Но атакующий не может прочитать ответ в том случае, если он использует HTML-форму. Для того чтобы не дать атакующему успешно выполнять fetch- или AJAX-запросы и читать ответы, нужно, чтобы CORS-политика сервера авторизации была бы настроена правильно, а именно — так, чтобы сервер не реагировал бы на запросы от неавторизованных веб-сайтов.

Как всё это настроить?

Шаг 1: возврат токена доступа и токена обновления при аутентификации пользователя

После того, как пользователь аутентифицируется, сервер аутентификации возвращает access_token (токен доступа) и refresh_token (токен обновления). Токен доступа будет включён в тело ответа, а токен обновления — в куки.

Вот что нужно использовать для настройки куки-файлов, предназначенных для хранения токенов обновления:

Шаг 2: сохранение токена доступа в памяти

Хранение токена доступа в памяти означает, что токен, в коде фронтенда, записывают в переменную. Это, конечно, означает, что токен будет утерян в том случае, если пользователь закроет вкладку, на которой открыт сайт, или обновит страницу. Именно поэтому у нас имеется токен обновления.

Шаг 3: получение нового токена доступа с использованием токена обновления

Если токен доступа оказывается утраченным или недействительным, нужно обратиться к конечной точке /refresh_token . При этом токен обновления, который, на шаге 1, был сохранён в куки-файле, будет включён в запрос. После этого вы получите новый токен доступа, который сможете использовать для выполнения запросов к API.

Всё это значит, что JWT могут быть больше 4 Кб, и то, что их можно помещать в заголовок Authorization .

Итоги

То, о чём мы тут рассказали, должно дать вам базовую информацию о хранении JWT на клиенте, и о том, как сделать ваш проект безопаснее.


Многие приложения используют JSON Web Tokens (JWT), чтобы позволить клиенту идентифицировать себя для дальнейшего обмена информацией после аутентификации.

JSON Web Token – это открытый стандарт (RFC 7519), который определяет компактный и автономный способ безопасной передачи информации между сторонами в виде объекта JSON.



Эта информация является проверенной и надежной, потому что она имеет цифровую подпись.
JWT могут быть подписаны с использованием секретного (с помощью алгоритма HMAC) или пары открытого / секретного ключей с использованием RSA или ECDSA.

JSON Web Token используется для передачи информации, касающейся личности и характеристик клиента. Этот «контейнер» подписывается сервером, чтобы клиент не вмешивался в него и не мог изменить, например, идентификационные данные или какие-либо характеристики (например, роль с простого пользователя на администратора или изменить логин клиента).

Соображения по поводу использования JWT

Даже если токен JWT прост в использовании и позволяет предоставлять сервисы (в основном REST) без сохранения состояния (stateless), такое решение подходит не для всех приложений, потому что оно поставляется с некоторыми оговорками, как, например, вопрос хранения токена.

Если приложение не должно быть полностью stateless, то можно рассмотреть возможность использования традиционной системы сессий, предоставляемой всеми веб-платформами. Однако для stateless приложений JWT – это хороший вариант, если он правильно реализован.

Проблемы и атаки, связанные с JWT

Использование алгоритма хеширования NONE

Подобная атака происходит, когда злоумышленник изменяет токен, а также меняет алгоритм хеширования (поле “alg”), чтобы указать через ключевое слово none, что целостность токена уже проверена. Некоторые библиотеки рассматривали токены, подписанные с помощью алгоритма none, как действительный токен с проверенной подписью, поэтому злоумышленник мог изменить полезную нагрузку (payload) токена, и приложение доверяло бы токену.

Для предотвращения атаки необходимо использовать библиотеку JWT, которая не подвержена данной уязвимости. Также во время проверки валидности токена необходимо явно запросить использование ожидаемого алгоритма.

Пример реализации:

Перехват токенов

Атака происходит, когда токен был перехвачен или украден злоумышленником и он применяет его для получения доступа к системе, используя идентификационные данные определенного пользователя.

Защита заключается в добавлении «пользовательского контекста» в токен. Пользовательский контекст будет состоять из следующей информации:

Если во время проверки токена полученный токен не содержит правильного контекста, он должен быть отклонен.
Пример реализации:

Код для создания токена после успешной аутентификации:

Код для проверки валидности токена:

Явное аннулирование токена пользователем

Поскольку токен становится недействительным только после истечения срока его действия, у пользователя нет встроенной функции, позволяющей явно отменить действие токена. Таким образом, в случае кражи пользователь не может сам отозвать токен и затем заблокировать атакующего.

Одним из способов защиты является внедрение черного списка токенов, который будет пригоден для имитации функции «выход из системы», существующей в традиционной системе сеансов.

В черном списке будет храниться сборник (в кодировке SHA-256 в HEX) токена с датой аннулирования, которая должна превышать срок действия выданного токена.

Когда пользователь хочет «выйти», он вызывает специальную службу, которая добавляет предоставленный токен пользователя в черный список, что приводит к немедленному аннулированию токена для дальнейшего использования в приложении.

Пример реализации:

Хранилище черного списка:
Для централизованного хранения черного списка будет использоваться база данных со следующей структурой:


Управление аннулированиями токенов:

Раскрытие информации о токене

Эта атака происходит, когда злоумышленник получает доступ к токену (или к набору токенов) и извлекает сохраненную в нем информацию (информация о токене JWT кодируется с помощью base64) для получения информации о системе. Информация может быть, например, такой как, роли безопасности, формат входа в систему и т.д.

Способ защиты достаточно очевиден и заключается в шифровании токена. Также важно защитить зашифрованные данные от атак с использованием криптоанализа. Для достижения всех этих целей используется алгоритм AES-GCM, который обеспечивает аутентифицированное шифрование с ассоциированными данными (Authenticated Encryption with Associated Data – AEAD). Примитив AEAD обеспечивает функциональность симметричного аутентифицированного шифрования. Реализации этого примитива защищены от адаптивных атак на основе подобранного шифртекста. При шифровании открытого текста можно дополнительно указать связанные данные, которые должны быть аутентифицированы, но не зашифрованы.

То есть шифрование с соответствующими данными обеспечивает подлинность и целостность данных, но не их секретность.

Однако необходимо отметить, что шифрование добавляется в основном для сокрытия внутренней информации, но очень важно помнить, что первоначальной защитой от подделки токена JWT является подпись, поэтому подпись токена и ее проверка должны быть всегда использованы.

Хранение токенов на стороне клиента

Если приложение хранит токен так, что возникает одна или несколько из следующих ситуаций:

  • токен автоматически отправляется браузером (сookie storage);
  • токен получается, даже если браузер перезапущен (использование контейнера localStorage браузера);
  • токен получается в случае атаки XSS (сookie, доступный для кода JavaScript или токен, который хранится в localStorage или sessionStorage).

  1. Хранить токен в браузере, используя контейнер sessionStorage.
  2. Добавить его в заголовок Authorization, используя схему Bearer. Заголовок должен выглядеть следующим образом:

Остается случай, когда злоумышленник использует контекст просмотра пользователя в качестве прокси-сервера, чтобы использовать целевое приложение через легитимного пользователя, но Content Security Policy может предотвратить связь с непредвиденными доменами.

Также возможно реализовать службу аутентификации таким образом, чтобы токен выдавался внутри защищенного файла cookie, но в этом случае должна быть реализована защита от CSRF.

Использование слабого ключа при создании токена

Если секрет, используемый в случае алгоритма HMAC-SHA256, необходимый для подписи токена, является слабым, то он может быть взломан (подобран c помощью атаки грубой силы). В результате злоумышленник может подделать произвольный действительный токен с точки зрения подписи.

Для предотвращения этой проблемы надо использовать сложный секретный ключ: буквенно-цифровой (смешанный регистр) + специальные символы.

Поскольку ключ необходим только для компьютерных вычислений, размер секретного ключа может превышать 50 позиций.


Для оценки сложности секретного ключа, используемого для вашей подписи токена, вы можете применить атаку по словарю паролей к токену в сочетании с JWT API.


В очередной раз встал вопрос о том где и как хранить токен авторизации. Первое что приходит в голову это cookie. Итак, давайте сделаем простенький сайт со странице авторизации и использованием cookie для определения пользователя, а затем попробуем его поломать. Использовать мы будем CSRF атаку. Об этих атаках написано уже немало статей, небольшой список будет в конце. В данном посте хочется добавить практики в эти объяснения на пальцах.

За исключением отсутствия проверки пароля это вполне обычная ситуация для сайтов, когда мы просто записываем введённый email в сессию пользователя. Сессия же связывается с браузером с помощью cookie.

Теперь, что бы запустить проект используем docker-compose со следующим конфигом:

Конечно, первые два файла кладутся в папку my, которая и подключается к контейнеру. Стартуем приложение через

Вот такой примитивной страничкой мы можем рассылать спам от имени несчастного пользователя. Добавим в docker-compose описание зловредного сайта:

Но такой запрос не выполнится, потому что браузер сделав запрос проверит в нём наличие заголовков Access-Control. И если сайту злоумышленника доступ не разрешён, то ответ не вернётся в javascript. Более того, если мы посмотрим на логи нашего сервера, то увидим следующее:

Запрос поступил, но вернул 401 ошибку. Погодите, но ведь в браузере есть связанная с сессией cookie! Для того чтобы браузер передал ещё и cookie, необходимо добавить следующую строчку в скрипт:

Но к таким запросам предъявляется ещё больше требований по заголовкам. Итак, наш сайт кажется вполне защищённым от CSRF-атак. Но подождите, скажете вы, ведь если у злоумышленника не получилось даже запросить страницу с сайта по cookie, а у нас, например, очень умный фронтенд, а сервер предоставляет JSON REST API, так может нам эти свистопляски с CSRF токенами не нужны?

Такие дела. Есть способы от такого защититься такие, как, например, всегда проверять Content-Type, требовать наличия заголовка X-Requested-With или X-CSRF-Token. Так или иначе все эти способы сводятся к тому, чтобы убедиться, что запрос сделан именно через xhr, а не обычной формой.


В целях аутентификации JWT выступает в р оли токена, выдаваемого сервером. Этот токен содержит относящееся к конкретному пользователю информационное наполнение в формате JSON. Клиенты могут использовать токен при взаимодействии с API (отправляя его как HTML-заголовок), чтобы API могли идентифицировать пользователя и выполнить соответствующее ему действие.

Но разве не может клиент просто создать случайное информационное наполнение и выдать себя за пользователя?

Хороший вопрос! Именно поэтому JWT также содержит подпись, которая создаётся сервером, выдавшим токен (предположим, конечной точкой вашей авторизации в системе). Любой другой сервер, получающий этот токен, может независимо проверить подпись, чтобы убедиться в подлинности информационного наполнения JSON и в том, что это наполнение было создано уполномоченным источником.

Но что если у меня есть действительный и подписанный JWT, а кто-то украдёт его из клиента? Смогут ли они постоянно использовать мой JWT?

Да. Если JWT будет украден, то вор сможет продолжать его использовать. API, принимающий JWT, выполняет самостоятельную проверку, не зависящую от его источника, следовательно сервер API никак не может знать о том, что этот токен был украден. Именно поэтому у JWT есть значение срока годности, которое намеренно создается коротким, и довольно часто его длительность определяется всего в 15 минут. Но при этом также нужно следить за тем, чтобы не произошло утечки JWT.

Из этих двух фактов формируются практически все особенности управления JWT. Т.е. мы стараемся избежать кражи, а если она всё же происходит, то нас спасает короткое время действия токенов.

Именно поэтому очень важно не хранить JWT на клиенте, например в куки или локальном хранилище. Поступая так, вы делаете своё приложение уязвимым для атак CSRF и XSS, которые при помощи вредоносных форм или скриптов могут использовать или украсть ваш токен, благоприятно размещённый в куки или локальном хранилище.

Есть ли у JWT какая-то конкретная структура?

В сериализованном виде JWT выглядит примерно так:

Если вы раскодируете этот base64, то получите JSON в виде 3 важных частей: заголовка, информационного наполнения и подписи.


Сериализованная форма будет иметь следующий формат:

JWT не зашифрован. Он закодирован в base64 и подписан. Поэтому любой может декодировать токен и использовать его данные. Подпись JWT используется для проверки легитимности источника этих данных.

Вот упрощённая схема, демонстрирующая, как JWT выдаётся ( /login ), а затем используется для совершения вызова API к другому сервису ( /api ):


Выглядит сложновато. Почему бы мне не придерживаться старых добрых токенов сессии?

Это уже наболевшая тема интернет-форумов. Мы коротко и категорично отвечаем, что бэкенд-разработчикам нравится использовать JWT по следующим причинам:

b) отсутствие необходимости в централизованной базе данных токенов.

В настройках микросервисов каждый из них может независимо проверять действительность полученного от клиента токена. Далее он может декодировать токен и извлечь связанную с ним информацию, не нуждаясь в доступе к централизованной базе данных токенов.

Именно поэтому разработчики API ценят JWT, а мы (с клиентской стороны) должны разобраться, как его использовать. Как бы то ни было, если вы можете обойтись токеном сессии, выданным вашим любимым монолитным фреймворком, то всё у вас в порядке и нет нужды в JWT.

Теперь, когда у нас есть базовое понимание JWT, давайте создадим простой процесс входа в систему и извлечём JWT. Вот что у нас должно получиться:


С чего же начать?

Процесс входа в систему принципиально не отличается от того, с которым вы сталкиваетесь регулярно. Например, вот форма авторизации, которая отправляет имя пользователя/пароль в конечную точку аутентификации и получает в ответ JWT-токен. Это может быть авторизация с помощью внешнего провайдера, шаг OAuth или OAuth2. Главное, чтобы в ответ на завершающий шаг входа в систему клиент получил JWT-токен.

Сначала мы создадим простую форму авторизации для отправки имени пользователя и пароля серверу авторизации. Сервер выдаст JWT-токен, и мы будет хранить его в памяти. В текущем руководстве мы не будем фокусироваться на сервере аутентификации в бэкенд, но вы запросто можете проверить его в примере репозитория для этой статьи.

Вот как может выглядеть обработчик handleSubmit для кнопки входа в систему:

API login возвращает токен и затем передаёт его в функцию login из /utils/auth , где мы можем решить, что с ним делать.

Итак, мы получили токен, где же нам теперь его хранить?

Нам необходимо где-то сохранить полученный JWT-токен, чтобы иметь возможность перенаправлять его нашему API в качестве заголовка. Вы можете соблазниться мыслью поместить его в локальное хранилище (localstorage). Не стоит этого делать, т.к. оно уязвимо для XSS-атак.

Может сохранить его в куки?

Обратите внимание, что новая спецификация SameSite Cookie, которая получает повышенную поддержку в большинстве браузеров, будет создавать куки на основе подходов, защищённых от CSRF-атак. Это решение может не сработать, если ваши серверы Auth и API размещены на разных доменах, но в противном случае такой вариант должен вполне подойти.

Где же тогда нам его хранить?

На данный момент мы будем хранить токен в памяти (и перейдём к постоянным сеансам в следующем разделе).

Как вы можете видеть, здесь мы храним токен в памяти. Да, он будет обнулён, когда пользователь переключит вкладки, но мы решим этот вопрос позже. Я также объясню, почему установил флаги noRedirect и jwt_token_expiry .

Теперь, когда у нас есть токен, что нам с ним делать?

  • Использовать его в нашем клиенте API для передачи в качестве заголовка каждого вызова API.
  • Проверять авторизован ли пользователь, посмотрев, определена ли переменная JWT.
  • По желанию мы даже можем декодировать JWT на клиенте, чтобы обратиться к данным информационного наполнения. Предположим, нам нужен id или имя пользователя на клиенте, которые мы можем извлечь из JWT.

Как нам проверить, авторизован ли пользователь?

Мы проверяем в utils/auth , установлена ли переменная токена и если нет — перенаправляем на страницу авторизации.

Теперь пришло время настроить наш клиент GraphQL. Замысел в том, чтобы получить токен из установленной нами переменной и, если он там, передавать его нашему клиенту GraphQL.


Вот как выглядит настройка с клиентом Apollo GraphQL, использующим мидлвар ApolloLink .

Как видно из этого кода, если есть токен, то он передаётся каждому запросу в качестве заголовка.

Но что случится, если токена не будет?

Всё зависит от потока выполнения в вашем приложении. К примеру, вы можете перенаправлять пользователя назад на страницу авторизации:

А если токен истекает в процессе его использования?

Предположим, наш токен действителен только 15 минут. В этом случае при его истечении мы, вероятно, получим ошибку от API, отрицающего наш запрос (например, 401: Unauthorized ). Помните, что каждый сервис, умеющий использовать JWT, может независимо верифицировать его и проверить, истёк ли срок действия.

Давайте добавим в наше приложение обработку ошибок, чтобы разобраться с подобными случаями. Мы напишем код, который будет выполняться для каждого ответа API, проверяя его на ошибку. Когда мы получим от API ошибку об истечении/недействительности токена, мы запустим процесс выхода из системы или перенаправимся к процессу входа в неё.

Вот как выглядит код, если мы используем клиент Apollo:

Вы можете заметить, что такой подход приводит к поистине печальному пользовательскому опыту. Пользователь будет вынужден повторять аутентификацию при каждом истечении токена. Именно поэтому приложения реализуют фоновый процесс обновления JWT, подробнее о котором мы расскажем ниже.

При использовании JWT “logout” просто стирает токен на стороне клиента, после чего он уже не может быть использован в последующих вызовах API.


Значит вызова API /logout не существует?

Конечная точка logout , по сути, не требуется, т.к. любой микросервиc, принимающий ваш JWT, будет продолжать его принимать. Если ваш сервер аутентификации удаляет JWT, то это не будет иметь значения, поскольку другие сервисы будут продолжать его принимать (поскольку весь смысл JWT в отсутствии централизованной координации).

Что делать, если мне нужно обеспечить невозможность продолжения использования токена?

Поэтому важно определять краткосрочные значения срока действия JWT. По этой же причине нам ещё более важно обеспечить таким образом защиту JWT от кражи. Токен действителен (даже после его удаления на клиенте), но только в течение короткого промежутка времени, что снижает вероятность его использования злоумышленниками.

Дополнительно вы можете добавить процесс обработки чёрных списков. В данном случае можно создать вызов API /logout , и ваш сервер аутентификации будет помещать токены в “недействительный список”. Как бы то ни было, все API сервисы, использующие JWT, теперь должны добавить дополнительный шаг к их верификации, чтобы проверять централизованный “чёрный список”. Это снова вводит центральное состояние и возвращает нас к тому, что мы имели до использования JWT.

Разве наличие чёрного списка не исключает преимущество JWT, гласящее о необязательности центрального хранилища?

В некотором смысле так и есть. Это опциональная предосторожность, к которой вы можете прибегнуть, если беспокоитесь, что ваш токен могут украсть. Но при этом она также повышает число необходимых верификаций.

Что произойдёт, если я авторизован на нескольких вкладках?

Один из способов разрешения этой ситуации заключается во введении в локальном хранилище глобального слушателя событий. При каждом обновлении этого ключа logout в локальном хранилище для одной вкладки, слушатель сработает и на других вкладках, также запустив ‘logout’ и перенаправив пользователей на экран входа в систему.

Теперь при выходе нам нужно совершить два действия:

  1. обнулить токен;
  2. установить в локальном хранилище элемент logout .

В этом случае при каждом выходе на одной вкладке, слушатель событий будет срабатывать во всех других, перенаправляя их на экран авторизации.

Это работает для вкладок, но как мне принудительно закрыть сессии на разных устройствах?

Эту тему мы рассмотрим в одном из следующих разделов.

Существует две главных проблемы, с которыми могут столкнуться пользователи нашего JWT-приложения:

  1. Учитывая короткий срок действия JWT, пользователь будет повторно авторизовываться каждые 15 минут, что было бы ужасно неудобно. Ему наверняка было бы комфортнее оставаться в системе длительное время.
  2. Если пользователь закрывает своё приложение и вновь его открывает, то ему придётся авторизовываться повторно. Его сессия не постоянна, т.к. мы не сохраняем токен на клиенте.

Для решения этих проблем большинство JWT-провайдеров предоставляют токен обновления, который имеет два свойства:

  1. Он может использоваться для совершения вызова API (например, /refresh_token ) с запросом нового JWT-токена до истечения срока действия текущего.
  2. Он может быть безопасно сохранён на клиенте в течение нескольких сессий.

Как работает обновляемый токен?

Этот токен выдаётся в процессе аутентификации вместе с JWT. Сервер аутентификации сохраняет его и ассоциирует с конкретным пользователем в своей базе данных, чтобы иметь возможность обрабатывать логику обновления JWT.

На клиенте до момента истечения текущего токена мы подключаем наше приложение, чтобы сделать конечную точку refresh_oken и получить новый JWT.

Как осуществляется безопасное хранение токена обновления на клиенте?

Такой подход защищён от CSRF-атак, т.к. даже несмотря на то, что атака на подписание формы может выполнить API-вызов /refresh_token , атакующий не сможет получить новое возвращаемое значение токена.

Какой же из способов сохранения JWT-сессии в итоге мы считаем лучшим?

Как же выглядит новый процесс “авторизации”?

Ничего особо не меняется, за исключением того, что токен обновления отправляется вместе с JWT. Давайте ещё раз взглянем на диаграмму процесса авторизации, но теперь уже с функциональностью refresh_token :

Читайте также: