Разработка
Today

Что не так с JWT (грабли)

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

Статья про CDN и content policy немного подогрела обсуждение (кстати, спасибо). И да, я о JWT высказываюсь не совсем в оптимистическом ключе.

У него, как у подхода и метода, есть плюсы — иначе бы он не появился, но есть и минусы, и минусы неприятные. Это не просто «Витя старый хейтер и опять ворчит». Вот давайте по ним, по минусам и деталям, пробежимся.


JWT, json web token

Что такое JWT: это подписанный кусок контента. Внутри него есть три куска данных, по стандарту. В одном из них, в поле payload, лежит ваш набор данных. У набора данных (payload) есть набор «стандартных полей», но вы можете туда напихать свои, с произвольными ключами.

Общая структура:

— header определяет формат содержимого
— payload это ваши данные
— signature криптографическая часть, подпись

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

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30

— header = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9,
{"alg": "HS256","typ": "JWT"}

— payload = eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0,
{"sub": "1234567890","name": "John Doe","admin": true,"iat": 1516239022}

— signature = KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30,
это подпись с секретом a-string-secret-at-least-256-bits-long, HMAC_SHA256(secret, base64urlEncoding (header) + '.' +base64urlEncoding (payload))

По узнаваемым «ey» и трем точкам итоговая структура запросто распознаётся «на глаз». Ну и читается чем попало, доступных библиотек сотни.


Содержимое, payload

Основной смысл структуры — передать содержимое (payload). Стандартные поля могут присутствовать, но не обязаны; единственное ограничение состоит в том, что стандартные имена полей рекомендуется использовать по назначению из стандарта, не подменять значения чем-то другим. Если хочется запихать своё, добавьте свои собственные ключи полей, стандарт это никак не ограничивает.

— iss: кто выдавал токен. Issuer по стандарту должен быть читаемым названием, или вообще урлом, но если токены используются только внутри вашей инфры, вы можете использовать там примерно что угодно: например, ID вашей системы или числовой ключ.

— sub: кому выдан. Subject это актор/принципал, которому лично выдан токен: чаще всего это либо лично пользователь (и его ID), или конкретная внешняя система (если актором выступает она, для server to server взаимодействий). Иногда и то, и другое.

— aud: система, для использования которой предназначался токен. Если Audience задан, то он обязан ставиться/проверяться, и при выдаче токена, и при использовании. Если не задан, то такой токен выдан «кому попало», на предъявителя.

— exp: до какого времени (unix timestamp) токен считается валидным. Всё что позже — отбрасывается, тк считается протухшим.

— nbf: с какого времени токен валидный (unix timestamp). Обратное предыдущему, до какого времени токен использовать еще нельзя. Это полезно, когда в обороте есть несколько токенов, старый еще-рабочий, и новый еще-не-начавшийся.

— iat: когда выписан этот конкретный

— jti: идентификатор именно этого токена. Полезно при ведении базы активных токенов и при проверках (пере-проверках).

— kid: какой ключ использовался для генерации подписи этого токена и, следовательно, как получателю проверять его подпись (и чем).

— alg: что за механизм подписи использовался. Вы удивитесь, но это важно, и об этом поле часто забывают (ниже расскажу).

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


Подпись, signature

Подпись никак не защищает данные от их читаемости. В 99% случаев вы столкнетесь с открытыми токенами, хотя формально есть варианты им payload зашифровать. Но это и не требуется.

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

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

Симметричные алгоритмы хешей (HS256, HMAC_SHA256 из примера) не самый безопасный вариант в этом смысле. Потому что для создания подписи хеша, и для его проверки используется один и тот же secret, который должен в этом случае присутствовать и на системе-источнике, и на системе-приемнике. Поэтому для вариантов, которые предполагают хоть какую-то безопасность токена, сразу смотрим на семейства RS и ES.

— RS256, RS384, RS512 варианты асимметричной подписи с RSASSA-PKCS-v1_5
— ES256, ES384, ES512 варианты асимметричной подписи на ECDSA, на эллиптических кривых

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

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

Целевая система получает токен, смотрит что там за ключ использовался, есть ли у нее открытая часть, и проверяет подпись. Если открытого ключа KID нет, значит надо пойти (безопасным образом) к источнику, и забрать открытую часть, провести проверку. Это мы уже на минималочках реализуем public key infrastructure.


Нахера это всё?

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

Без необходимости куда-то ходить. Универсальный паспорт, и JWT исторически используется именно в таком контексте.

Что здесь хорошо, то есть плюсы:

— все необходимые данные для чтения и проверки уже есть на целевой системе. Не надо бегать куда-то там в сеть, и проверять, что откуда взялось. В распределенной среде, в нагруженных системах итп, просто так в сеть куда-то там на каждый чих, на каждого юзера — не набегаешься. А здесь весь набор данных либо берется из токена, либо уже есть на целевой системе (ключ проверки).

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

— внутрь токена в payload можно безопасным образом вшить результат каких-то «тяжелых» проверок: список ролей, возможностей, ключевых открытых данных юзера (user ID, screen name), внутренние идентификаторы. Подтвержденным, секурным образом. Если конечно их вообще можно передавать открытым текстом (помним, токен читается)

— куча готовых механизмов, типовых и проверенных, которые скорее всего доступны на целевых системах. Для популярного софта, библиотек, реализаций. Третьи системы чаще всего не будут гореть желанием реализовывать под ваши задачи какой-то лютый кастом. А сама идея реализовывать industrial grade криптографические защиты руками обычно несёт много граблей by design, лучше взять готовые механизмы.

Плюсы значительные, механизм неплохой.
Но есть и минусы.


Давайте сразу 2.5 простых, статических минуса тут обозначу:

— объем. JWT это примерно полкило данных. На каждый запрос везде у вас начинает летать +0.5Кб, что может быть на высоконагруженных системах неприятно, хотя и не смертельно. (0.5кБ х 10000 rq/sec x 86400 сек) = 412 Гб трафика в сутки просто ради того, чтобы токены гонять. Хотя, если бы вы их не гоняли, еще неизвестно, существовал бы способ лучше.

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

— ограничение размера. Впихнуть в JWT более 2 Кб обычно не удается, потому что растет объем передачи, и стандартные заголовки http и прочих транспортных протоколов уже начинают упираться в ограничения

Чаще всего это неизбежное зло, но тем не менее.

Далее проблемы интереснее.
Структурные, логические.


Проблема раз: украденный токен

Токен в такой схеме отчуждаемый. Мы его сгенерили на системе-источнике, куда-то выдали, в широкий мир кому-то там. Дальше он = вещь самостоятельная и автономная. Система-приемник ему верит; основным плюсом является то, что для проверок «никуда бегать не надо», как следствие — никто к источнику истины уже и не придет, пока токен не стухнет.

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

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

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

Спереть токен, который летает по сети и куда-то шлется устройством пользователя — схема ломается элементарно, если сломать это самое устройство.

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

Как решать: ну, как минимум, добавлять в токен дополнительные поля, которые что-то техническое про пользователя также содержат. Сетевые параметры, как вариант: IP, подсети, автономную систему. Географию, данные устройства. Какой-то более сложный фингерпринтинг.

Чтобы целевая система могла уже после чтения и получения токена, проверки подписи, иметь возможность определить — нет, этот токен выдавался юзеру из московской подсети и вот такого устройства, а пришли к нам из Зимбабве и фингерпринт не тот; иди-ка ты, человек, принеси нормальный токен.


Проблема два: обновление токенов, refresh

Выданный валидный токен рано или поздно устареет. Это неплохо.
То, что он устареет — означает, что нужно будет где-то брать новый.
Вот тут возможны вариантики.

Можно конечно просто ничего не делать. Но. Система-приемник поняла, что токен ей не оч, юзера шлет нафиг. Юзер сам идет в систему-источник, проходит там заново все проверки. И это очень сильно рвёт цепочку взаимодействий, это «авторизация истухла посреди чего-то там», in a middle of something.

Поэтому часто прикручивают другой механизм: механизм рефрешей.

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

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

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

JWT это секурно, даа. А рефреш рядом, который текстовая строка — забирай, отсылай, выписывай себе новый JWT, щедрая душа.

Или «не себе», как в злонамеренном варианте происходит.

Как решать: операции с перевыпуском JWT, как по рефрешам так и по любому другому поводу, должны быть обвешаны проверками, логами, мониторингом и (желательно) не меньшим фингерпринтингом, как описано в «проблеме 1». Не меньшим, а возможно даже большим.

  • Знаю реализации, например, когда рефреш выдает токен с заведомо меньшими правами (хочешь нормальный, используй полную схему).
  • Знаю реализации, где используется «цепочка рефрешей», когда нельзя использовать один рефреш более одного раза, и таким образом ситуация «спёрли» уже явно у кого-то не сработает, и вызовет вопросы.
  • Знаю реализации, когда использование рефрешей ограничено не только per user, но еще и для целевой системы (одна система не может дергать рефреш одному юзеру чаще чем X).

Еще рефреш не должен «жить вечно», иначе мы получаем бесконечный источник валидных токенов. Рефрешнулся, получил JWT, получил новый рефреш, снова рефрешнулся… кстати, как вы собираетесь это проверять?

Почему нельзя «просто передай старый JWT, мы дадим тебе новый».

Потому что если его сперли и вам обратно передали, нет вообще никакой дополнительной проверки владением, хоть чем-то, нужен дополнительный кусок паззла — в виде рефреша, а лучше чего-то большего.

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


Проблема три: отзыв токена per user

Ситуация: юзер получил JWT, пошел с ним в систему-приемник.
Система его приняла.

Дальше мы больше не хотим, чтобы этот юзер по этому токену как-то взаимодействовал. Для простоты — ну, тупо разлогинился.
Как вы собираетесь это делать?

Проблема вытекает из автономности и отчуждаемости токенов. Пока токен есть, система-приемник ему верит. Если верить надо перестать, или «можно верить», но надо перестать использовать — без дополнительных телодвижений как она об этом должна узнать? Коллизия.

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

Можно предусмотреть на системе-приемнике выборочные проверки раз в rand (1, X), или проверки раз в квант времени. Токен живет сутки, но раз в 5 минут per user мы все равно будем переспрашивать источник, всё ли там норм вот конкретно с ним. Если на системе-приемнике есть кеш валидных JWT (а чаще всего он есть, так проще), то вот как раз по протуханию локального хеша мы а) перепроверяем криптографию подписи и б) переспрашиваем источник о статусе и об отзыве токена.

На источнике для этих целей неплохо бы иметь revocation list. Список юзеров, авторизации которых мы больше не верим, по любой причине. Или обратный список, позволяющий узнать, а кому все-таки верить можно.

Если решать совсем втупую, то per user на системе-источнике можно записать timestamp, с какого момента токены можно считать валидными.

На ситуации «разлогин» пишем туда текущее время, и всё, что нам принесут с временем выдачи токена раньше указанной проставленной — сразу невалидное, сразу в топку.

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

Можно без него, если у вас JWT живут 1-5 минут, может и пофиг.
А может и не пофиг, лишние 5 минут жизни уже заведомо скомпрометированного токена и учетки — вполне позволяют успеть «наворотить дел».


Проблема 4: отзыв токенов в принципе

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

  • Если мы вдруг хотим перестать доверять юзеру, уже рассмотрели выше. Но у него может быть NN токенов, все валидные, а злоумышленник мог еще каких-то даже наполучать.
  • Возможно, мы хотим перестать доверять авторизации не per user, а per audience, сделать невалидными все токены, которые выдавались на какую-то внешнюю конкретную систему. Audience конкретный, юзеров может быть произвольное количество.
  • Возможно, у нас тупо спёрли ключ подписи. Или мы нашли проблему в базовом механизме авторизации у себя, на системе-источнике. Следовательно, всем юзерам с каким-то признаком, и их токенам, мы больше доверять не можем; а в сторонних системах должны оперативно перестать это делать.

Как решать. Ну, мы можем для объема токенов поменять ключ и подпись. Всё, что не пройдет проверку, сразу невалидное. Система-приемник об этом рискует не узнать (у нее закеширован старый ключ подписи), но если систем-приемников ограниченное известное количество, можно пойти и обновить руками. В вашей инфре.

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

Для более точного контроля можно использовать KID. Для конкретного токена указано, чем его проверять. Если у системы-приемника нет нужного открытого ключа, надо сходить секурно на master.host/keys/KID и забрать там нужный — а затем проверить. В принципе, вариант. Но вам еще надо указывать не только то, какие ключи бывают валидными, но и явно те, которые стали невалидными, иначе система-приемник будет иметь у себя закешированные, включая старый, и вы ничего так не достигнете.

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

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

Даже если придет юзер с валидным токеном, который был выдан ранее — всё, неа, вот конкретному юзеру надо свежее чем X, значит иди-ка ты получай новый.

Это неплохой механизм, тк достаточно несложный в реализации, а позволяет многое. Хотя и не идеальный.

Можно не писать timestamp, а просто взять какое-то инкрементное возрастающее значение, знать его на системе-источнике per user, и писать внутрь токена. Технически почти ничем не отличается; всё, что получено системой-приемником со значением X, автоматически делает (для нее) невалидным всё полученное/получаемое ранее/позднее, что меньше X.

  • резкий рост этого значения для одного юзера означает, что у нас идет цепочка разлогинов, и этого юзера (возможно) прямо сейчас ломают или даже сломали, у них race condition по токенам между передаваемыми значениями
  • резкий рост этого значения для пачки юзеров означает, что-либо одна из систем херово работает с авторизацией (и отправляет всех на перелогин), либо откуда-то утекла пачка валидных токенов

И то и другое = алярма и повод идти смотреть.


Проблема 5: кривая реализация

Для JWT есть ряд библиотек — это неплохо. Плохо то, что реализованы многие из них через жопу. Сторонняя реализация понятия не имеет о конкретно вашем применении и о схеме использования, а «по дефолту» многие штуки реализованы как попало, и это надо проверять. Прямыми руками. И не один раз.

Самая популярная грабля — проверьте, что будет, если я руками соберу токен с заданным payload (скопировав его из валидного), и в поле ALG напишу NONE.

Да, вот так вот просто. Это же валидный кейс, и библиотека о нем в курсе. И она его примет. Ахаха, то есть мяу.

Варианты реализации «на хешах» (HS) просто использовать не надо (а надо не забыть отключить). Переключить ALG на хеши и поймать коллизию не то чтоб сильно невозможно, RS-подпись тоже подписывает хеш, чтоб попроще было, а коллизия на двух хешах ловится. Короче, ненужное отключаем.

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

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

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

Выданные JWT и рефреши к ним, в системе-источнике, придется и хранить и логировать. Чтобы понимать, что у вас на периметре происходит, чего там за сессии у юзера, куда он ходил, где у него актуальные права итп. Мониторингом этого всего обвешать, тк если у вас у юзера вдруг вместо 1 JWT стала 1000, значит кого-то хакнули, или хотя бы на полпути.

В общем, проверять придется много.

Еще момент. Убедитесь, что у вас системные административные средства для работы с JWT не полагаются исключительно на этот самый JWT, как на механизм авторизации… понимаете шутку юмора, да?

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

Или наоборот, вам что-то поломали касательно JWT — и управление поломанным, чтобы найти концы, также сразу скомпрометировано, потому что больше никаких проверок там не было.


Блок про сравнение пока не знаю, хочу дописать сюда или нет.


В общем, есть список вопросов, которые надо решать, если вы его используете для проверок доступа. Никто не говорил, что удобно.

  • полностью stateless jwt = размеры, сложности с обновлением (неуправляемый кеш)
  • stateful jwt = все равно ходить в какие-то другие системы, перепроверять данные
  • механизм отзыва надо придумывать
  • механизмы обновлений надо придумывать, а типовые (refresh) имеют слабости
  • все равно должен использоваться совместно с чем-то еще

Не надо требовать от технологии того, чего в ней вообще-то не обещали.
Просто транспортный механизм для payload, с подписями, не более.

Пока что так,
Hope that helps.