company_banner

Локальное хранилище или куки? Безопасное хранение JWT на клиенте

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



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

Виды токенов


  • Токены доступа (access tokens) обычно представляют собой короткоживущие JWT, подписанные сервером. Они включаются в каждый HTTP-запрос, выполняемый клиентом к серверу. Токены используются для авторизации запросов.
  • Токены обновления (refresh tokens) обычно представлены долгоживущими токенами, хранящимися в базе данных и используемыми для получения нового токена доступа при истечении срока действия предыдущего токена.

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


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

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

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


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


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

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

▍Недостатки


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

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

Куки-файлы


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


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

  • Если вы используете флаг HttpOnly и защищённые куки-файлы, это означает, что из JavaScript нельзя получить доступ к этим файлам. То есть, если даже атакующий сможет запустить свой код на вашей странице, ему не удастся прочитать токен доступа из куки-файла.
  • Куки автоматически отправляются в каждом HTTP-запросе к серверу.

▍Недостатки


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

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

XSS-атаки


Локальное хранилище уязвимо к XSS-атакам из-за того, что с ним очень легко работать, используя JavaScript. Поэтому злоумышленник может получить доступ к токену и воспользоваться им в своих интересах. Однако, хотя HttpOnly-куки и недостижимы из JavaScript, это не означает, что вы, используя куки, защищены от XSS-атак, направленных на кражу токена доступа.

Если атакующий может запускать свой JS-код в вашем приложении, это значит, что он может просто отправить вашему серверу запрос, а токен будет включён в этот запрос автоматически. Такая схема работы просто не так удобна для атакующего, так как он не может прочитать содержимое токена. Но подобное нужно атакующим нечасто. Кроме того, при такой схеме работы злоумышленнику может быть выгоднее атаковать сервер, пользуясь компьютером жертвы, а не собственным.

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


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

POST /email/change HTTP/1.1
Host: site.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 50
Cookie: session=abcdefghijklmnopqrstu

email=myemail.example.com

В такой ситуации атакующий может создать форму со скрытым полем для ввода адреса электронной почты, которая отправляет POST-запрос на https://site.com/email/change. При этом сессионные куки автоматически будут включены в такой запрос.

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

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


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

  • И локальное хранилище, и куки уязвимы к XSS-атакам, но злоумышленнику будет сложнее совершить атаку в том случае, если используются HttpOnly-куки.
  • Куки уязвимы к CSRF-атакам, но риск таких атак можно смягчить, используя атрибут SameSite и анти-CSRF токены.

Куки-файлами можно пользоваться даже в тех случаях, когда надо применять заголовок Authorization: Bearer, или когда JWT больше 4 Кб. Это, кроме того, согласуется с рекомендациями OWASP: «Не храните идентификаторы сессий в локальном хранилище, так как соответствующие данные всегда доступны из JavaScript. Куки-файлы могут помочь снизить риск благодаря HttpOnly».

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


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

  • Способ 1: хранение токенов в локальном хранилище. Этот способ подвержен XSS-атакам.
  • Способ 2: хранение токенов в HttpOnly-куки. Этот способ подвержен CSRF-атакам, но риск подобных атак может быть смягчён. От XSS-атак этот вариант хранения токенов защищён немного лучше первого.
  • Способ 3: хранение токенов обновления в HttpOnly-куки, а токенов доступа — в памяти. Этот способ хранения токенов безопаснее в плане CSRF-атак и немного лучше защищён от XSS-атак.

Ниже мы подробнее рассмотрим третий способ хранения токенов, так как он, из трёх перечисленных, выглядит самым интересным.

Почему хранение токена обновления в HttpOnly-куки безопаснее с точки зрения CSRF-атак?


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

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

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


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

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

  • Флаг HttpOnly — чтобы не дать прочесть токен из JavaScript.
  • Флаг secure=true, что приведёт к тому, что данные будут передаваться только по HTTPS.
  • Флаг SameSite=strict нужно использовать всегда, когда это возможно, что позволит защититься от CSRF-атак. Этот подход может использоваться только в том случае, если сервер авторизации относится к тому же сайту, что и фронтенд системы. Если это не так, тогда сервер авторизации должен устанавливать CORS-заголовки на бэкенде, или использовать другие методы для того чтобы убедиться в том, что запрос с токеном обновления может быть выполнен только авторизованным веб-сайтом.

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


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

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


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

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

Итоги


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

Как вы храните JWT на клиенте?

RUVDS.com
VDS/VPS-хостинг. Скидка 10% по коду HABR

Комментарии 20

    +3

    Нет, ну серьезно?


    Используя локальное хранилище, удобно работать с API, которые требуют размещать токен доступа в заголовок запроса. Например — так: Authorization Bearer ${access_token}.

    Если у вас API требует access_token, то это не "удобно" или "не удобно", это единственный вариант.


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

    Куки, в общем случае, прекрасно доступны для JavaScript. Недоступны они в двух случаях — если стоит флаг httpOnly или они принадлежат чужому домену.


    Если вы используете флаг HttpOnly и защищённые куки-файлы.

    Это какие такие защищенные куки? Наверное, имеются ввиду Secure флаг, который запрещает передачу куков при http запросе. Но secure флаг никак не защищает куки от доступа к ним с помощью JS.


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

    Никто никого не принуждает. СSRF это подделка/эмуляция запроса от имени пользователя. Если речь идет о принуждении, то уже никакие техники защиты не помогут.


    Возможно Вам стоит найти человека, который разбирается в этой теме?

      +1
      Если у вас API требует access_token, то это не "удобно" или "не удобно", это единственный вариант.

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


      Наверное, имеются ввиду Secure флаг

      Именно так. В оригинале secure выделено как ключевое слово, наравне с httpOnly. Переводчик не осилил.


      Но secure флаг никак не защищает куки от доступа к ним с помощью JS.

      А тут уже претензия не к переводу, а к автору оригинала. Зачем он приплел сюда secure — непонятно.


      пользователя каким-то образом принуждают

      Очередная кривая формулировка автора оригинала. Кто в курсе, тот поймет (но ему это объяснение и не нужно), а кто не курсе, того такое описание только запутает.

        +1

        Итоги: И статья такая себе, и переводчик "не помогает". И зачем?

        0
        Это какие такие защищенные куки? Наверное, имеются ввиду Secure флаг, который запрещает передачу куков при http запросе. Но secure флаг никак не защищает куки от доступа к ним с помощью JS.

        Полагаю, имеется в виду как раз флаг HttpOnly.
          +2
          Я бы вообще начал с вопроса:
          «А зачем нужен JWT если раньше обходились session id / токен?»

          И если кратко то JWT, это шифрованный user profile, который передают через клиента, и нужен он для:
          (а) кластеризации за лоад-балансером, и (б) авторизации, без обращения к база/сервису на каждый запрос пользователя.
          +4

          Статья не то чтобы не раскрывает тему, она больше вводит в заблуждение...


          Существуют сценарии, при реализации которых вы не можете передавать куки своему API-серверу.

          А потом складываем refresh token в куки… выходит от недостатка не избавились...


          Из моего опыта хранить токены нужно только в оперативной памяти и отправлять в хедерах.


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


          Причем нигде не должно быть возни с CORS, клиент всегда шлет на родной домен запросы, которые проверяются одним api gateway'ем, а потом раскидываются по сервисам.


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


          Я верю, что мой коммент будет полезен тем, кто начинает читать статьи с комментариев =)

            0
            Это те же яйца вид сбоку. Токен от сервиса авторизации в итоге нужно положить либо в куку, либо в local storage и огрести то, о чем в статье пишут.
              0

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


              Мне на самом деле интересны проблемы, которые тут можно огрести, и которые я пока не вижу,… но я их пока не вижу =)

              0
              Безусловно полезен.
              Я просто пришел к аналогичному выводу, и вы его подтвердили. Совершенно непонятно, какой смысл хранить оба токена в одной куке, и, получается передавать их с запросами. Смысл рефреша теряется, он все равно ходит парой с токеном доступа. Важным аспектом то было то, чтобы отправлять рефреш только когда нужен рефреш… В общем, идея с отдельным доменом эту проблему как раз и решает. Отнимаем доступ у приложения к кукам с помощью httponly и получаем отдельно куку для доступа и отдельно куку для рефреша.
              –1

              ктонибудь организовывал через токены доступ к видео? чтобы их с помощью софта не скачивали

                0

                И софту будет достаточно проставить тем же образом токены в заголовках. Смысл? Разве что защита от тупого "вставить ссылку в поле браузера".

                0
                Размер куки-файлов ограничен 4 Кб. Поэтому, если вы используете большие JWT, хранение их в куки-файлах вам не подойдёт.

                Сейчас бы отправлять 4кб в каждом запросе.

                  0

                  Вы опоздали лет на 10 с таким заявлением. Сейчас запросов по количеству явно больше, и в каждом вполне себе будет миллион utm- меток, которые с лёгкостью нагребут на больше чем 4кб запроса ..

                  0
                  Локальное хранилище уязвимо к XSS-атакам

                  Почему всегда доступ из js в localStorage объявляют как минус? Ведь если на сайт УЖЕ прошла xss атака, т.е внедрили чужой js… то уже как бы поздно. Какая уже разница где хранятся токены. Наворотить уже можно что угодно по идее.
                  Т.е. в контексте вопроса в статье, переживать необходимо про xss, а не о том где бы безопаснее токены хранить.
                    0

                    Про это и упомянули:


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

                    Действительно, если ты нашел XSS, то тебя ничего не остановит.

                      +1
                      Тут идёт речь про усложнение атаки. Даже если нашли XSS, чтобы с ним что-то сделать недостаточно будет просто слить куки. Не стоит строить безопасность с одним «фронтом».
                      0
                      расскажите про gRPC
                        +2
                        Cookie и localStorage в контексте заголовка отличаются лишь степенью доступности.
                        Если куки прочитать нельзя (выставив флаг HttpOnly), то зачем их сравнивать?

                        Содержимое статьи можно уложить в три предложения — храним сессию в куках с флагами secure, HttpOnly и SameSite. Выставляем заголовки X-Content-Type-Options, Strict-Transport-Security, X-XSS-Protection, X-Frame-Options, Referrer-Policy, Content-Security-Policy, Expect-CT, Feature-Policy и Cache-Control. Ну и не забываем про токен.
                          0

                          Так то refresh token’ы нельзя выдавать в браузер. В случае использования OpenID Connect была возможность так называемого «silent renew», но судя по блогу разработчиков вебкита, она уже канула в лету (для Safari — в последних Technology Preview, для Chrome — в 2022) https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/
                          В драфтах OAuth 2.1 implicit flow выпилили, остались только только authorization code с pkce для public/confidential клиентов и client credentials flow. Соответственно единственный вариант для больших SPA, которые разрабатываются сейчас, с заделом на долгую и счастливую поддержку, которым от провайдера нужно чуть побольше, чем просто аутентификация — это code + pkce и httponly secure куки, к которым имеет доступ только BFF.

                            0
                            Исчерпывающее объяснение работы с JWT:
                            https://gist.github.com/zmts/802dc9c3510d79fd40f9dc38a12bccfc

                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                            Самое читаемое