JWT invalid signature: все причины и способы исправления
Ошибка invalid signature в JWT означает ровно одно: подпись, которую вычислила проверяющая сторона, не равна подписи, лежащей внутри токена. Это всё, что в ней сказано. Она не говорит, что токен просрочен или что пользователю не хватает прав, и уж тем более не значит, что библиотека JWT сломана. Значит, что-то различается между стороной, которая подписывала, и стороной, которая проверяет: либо байты, попадающие в HMAC, либо открытый ключ, переданный в вызов проверки.
В большинстве случаев виноват ключевой материал, а не токен. Дерево ниже помогает выбрать точку входа:
Какой алгоритм указан в заголовке?
├─ HS256 / HS384 / HS512 → почти всегда проблема с секретом
│ ├─ стороны написаны на разных языках? → раздел 3
│ └─ язык один, локально работает, в проде падает? → раздел 4
└─ RS256 / ES256 / PS256 → почти всегда формат ключа или не тот ключ
└─ → раздел 7
Токен шёл через шлюз, прокси или буфер обмена? → раздел 6
Ошибка появляется только спустя часы или на одном хосте? → раздел 8
Каждый раздел ниже заканчивается тем, что можно запустить. Если нужен самый быстрый первый шаг — вставьте токен в декодер JWT и посмотрите поле alg: половина веток выше отпадает в тот момент, когда оно становится известно.
1. Что на самом деле означает «invalid signature»
Разные библиотеки печатают для одного и того же сбоя разные строки. Найдите свою в этом списке — так вы убедитесь, что попали в нужное руководство:
- Node
jsonwebtoken:JsonWebTokenError: invalid signature - Python
PyJWT:InvalidSignatureError: Signature verification failed - Java
jjwt:SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
Все три срабатывают в один и тот же момент на одном и том же участке кода. Библиотека берёт первые два сегмента токена, заново вычисляет подпись переданным ей ключом и побайтово сравнивает результат с третьим сегментом. Не совпало — исключение.
Сравнение точное, и оно не несёт никакой информации о том, насколько два значения различаются. Секрет, отличающийся на один байт, и совершенно чужой ключ дают одинаковое сообщение об ошибке. Именно поэтому остальная часть руководства посвящена сужению пространства входных данных, а не тому, как вчитаться в текст ошибки внимательнее.
К моменту этой ошибки многое ещё не случилось. Проверка claims идёт после проверки подписи, поэтому exp, nbf, aud и iss ещё никто не смотрел. Если проверка подписи JWT провалилась, содержимое токена для диагноза значения не имеет — хотя прочитать его по-прежнему можно, ведь JWT кодируется, а не шифруется. Для декодирования заголовка и полезной нагрузки ключ не нужен вовсе; разбор сегмент за сегментом есть в статье как декодировать JWT.
Два поля заголовка решают, куда идти дальше: alg говорит, что вы ищете — общий секрет или пару ключей, а kid — каким ключом подписывающая сторона считала, что подписывает.
2. Подпись покрывает закодированную строку, а не ваш объект
Эту модель большинство разработчиков представляет себе наоборот. RFC 7515, спецификация JSON Web Signature, определяет JWS Signing Input как строку ASCII:
BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload)
HMAC вычисляется над этой строкой. Не над вашей структурой claims и не над чем угодно ещё, что ваш язык считает структурированными данными. Вот тот signing input, который используется дальше по всей статье; он взят из стандартного примера полезной нагрузки:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
Следствие тяжёлое, и команды напарываются на него постоянно: любой слой, который декодирует полезную нагрузку и кодирует её заново, разрушает подпись. Сериализация JSON не каноническая. Порядок ключей меняется, когда структура проходит туда-обратно через большинство языков. Пробелы появляются и исчезают. Не-ASCII символы один сериализатор экранирует как \uXXXX, а другой выводит буквально. Числа переформатируются, и 1516239022 может вернуться как 1516239022.0. Каждое из этих изменений даёт другую строку base64url, значит другой signing input, значит другую подпись.
Реальные источники такого:
- Шлюз API, который разбирает JWT, чтобы дописать в него идентификатор тенанта, и выпускает токен заново.
- Middleware логирования или трассировки, которое «нормализует» заголовки и переписывает значение Authorization.
- Разработчик, который отформатировал токен для читаемости, а потом вставил обратно уже красивую версию.
Если между подписывающей и проверяющей сторонами есть компонент, способный переписать токен, подозревать нужно в первую очередь его. В пути токен — непрозрачная строка; безопасных операций с ней всего три: сохранить, скопировать, сравнить.
3. Один и тот же секрет, разные байты
Это причина, которую почти не разбирают в руководствах по диагностике, и та самая, что стоит за баг-репортами вида «секрет посимвольно одинаковый, я сравнивал».
HMAC не принимает строку. Он принимает байты. И конфигурационный файл, и менеджер секретов, и переменные окружения хранят строки. Кто-то должен перевести одно в другое, и это преобразование не стандартизировано между библиотеками JWT. Два сервиса могут держать посимвольно одинаковые секреты и всё равно вычислить разные подписи.
Вот доказательство, посчитанное локально над signing input из раздела 2. Строка секрета состоит из 36 символов:
c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm
| Как истолкованы байты | Байт | Чем ключ оказывается на самом деле | Полученная подпись HS256 |
|---|---|---|---|
| Как текст UTF-8 | 36 | те самые 36 видимых символов | tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak |
| Сначала декодирован из base64 | 27 | secret-key-1234567890abcdef | 53ISDuciq-ov8YF1Ezwy6zo6KUO-1tpwz2oW7gVPuIM |
Строка секрета одна и та же, алгоритм тот же, полезная нагрузка та же — а подписи не имеют между собой ничего общего. Та сторона, что истолковала её «неправильно», сообщает invalid signature, и сколько ни сравнивай конфигурационные файлы, ничего не найдётся: файлы-то совпадают.
Если хотите воспроизвести — вот полный токен для прочтения секрета как UTF-8:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak
Вставьте его в декодер JWT вместе с секретом выше — подпись сойдётся. Декодируйте секрет из base64 перед проверкой — не сойдётся.
Как каждая библиотека превращает строку в байты ключа
Держитесь того, что задокументировано. Таблица ниже намеренно короткая, и последний столбец в ней важнее первого.
| Среда / библиотека | Как строка становится байтами | Кто решает |
|---|---|---|
Node jsonwebtoken | байты строки в UTF-8 | библиотека |
Python PyJWT | байты строки в UTF-8 | библиотека |
Java jjwt, устаревшая перегрузка со String | кодек base64 платформы, см. jwtk/jjwt#204 | библиотека |
Go golang-jwt | принимает []byte напрямую | вы, в месте вызова |
| .NET | принимает byte[] напрямую | вы, в месте вызова |
Строка про Java — исторический источник боли на стыке стеков. В старых версиях jjwt метод signWith(SignatureAlgorithm, String) и родственные ему прогоняли String через кодек base64, а не брали её байты как есть, тогда как перегрузки с byte[] использовали байты в том виде, в каком их дали. Поэтому сервис на Node и сервис на Java с одним общим секретом расходились. Этот String API объявлен устаревшим начиная с jjwt 0.10, а современная форма записи явная:
SecretKey key = Keys.hmacShaKeyFor(secretBytes);
Это не «так устроены JWT в Java». Это устаревшая перегрузка одной библиотеки, и в современном коде на jjwt, который передаёт byte[], двусмысленности нет вообще. Зеркальное сообщение со стороны Node — auth0/node-jsonwebtoken#208: токены, подписанные в Java, не проходили проверку в Node. Похожие сообщения есть и по PHP-библиотеке firebase/php-jwt — см. firebase/php-jwt#153, — но её обработку байтов мы сами не проверяли, так что относитесь к этому как к зацепке, а не как к диагнозу.
Go и .NET относятся к другой категории. Ни та, ни другая библиотека не решает за вас: обе отдают вам параметр []byte / byte[] и отходят в сторону. []byte(secret) и Encoding.UTF8.GetBytes(secret) дают UTF-8, а Convert.FromBase64String(secret) даёт декодированные байты. Ошибка, если она случается, живёт в вашем месте вызова, и это хорошая новость: она видна в вашем же диффе.
Секрет JWT в base64 или UTF-8?
В токене нет флага, который бы это подсказал. Рассуждать приходится о самой строке:
- Состоит ли она только из
A–Z a–z 0–9 + / =(или-и_)? Если да, это может быть base64. Секрет с пробелом,!или#внутри — точно нет. - Кратна ли её длина четырём или заканчивается ли она заполнением
=? И то и другое — сильный признак того, что по дороге строку чем-то закодировали в base64. - Даёт ли декодирование из base64 осмысленные байты? Прогоните строку через декодер Base64. Читаемый ASCII или ровно 32 байта, похожих на случайные, говорят в пользу base64. Мусор вместо текста говорит о том, что строку никогда не кодировали.
Секрет вроде c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm проходит все три проверки, и именно поэтому он опасен: он неоднозначен, и оба прочтения правдоподобны. С секретами, внутри которых есть - или _, ещё хуже: они валидны как base64url и невалидны как стандартный base64.
Когда рассуждениями до ответа не добраться, посчитайте оба варианта. Возьмите signing input, прогоните по нему HMAC-SHA256 дважды в генераторе HMAC — один раз с секретом как с текстом, второй раз с декодированными байтами — и сравните каждый результат с третьим сегментом токена. Один из них совпадёт, и это скажет, какая сторона вашей системы права.
Символы — это не байты
Смежная ловушка — считать символы там, где требование задано в байтах. RFC 7518 §3.2 задаёт нижнюю границу для ключа HMAC-SHA в битах, а не в символах, а кодированный текст занимает больше:
| Как записан ключ | Энтропия | Эквивалент в байтах | Для HS256 (нужно ≥256 бит) |
|---|---|---|---|
| 32 символа hex | 128 бит | 16 байт | ❌ ниже границы |
| 32 символа base64 | 192 бит | 24 байта | ❌ ниже границы |
| 32 случайных байта | 256 бит | 32 байта | ✅ проходит (64 символа в hex, 44 в base64 с заполнением) |
«Секрет из 32 символов» может нести от 128 до 256 бит в зависимости от алфавита. Это ортогонально проблеме истолкования байтов выше, но кусает тех же людей: кто меряет секрет символами, тот обычно ни разу не смотрел на байты. За самими правилами — длина, кодировка, ротация — идите в генератор JWT-секрета: в справочных заметках рядом с ним всё это разобрано как следует, и повторять здесь незачем.
4. Сам секрет оказался испорчен
Две стороны согласны в истолковании байтов. Подпись всё равно не сходится. Теперь проверьте, каждая ли сторона загрузила именно тот секрет, который вы записали: окружение умеет незаметно добавить лишний байт.
Перевод строки в конце .env. JWT_SECRET=abc, за которым идёт разрыв строки, некоторые загрузчики читают как abc\n. Один лишний байт — и HMAC выдаёт совершенно посторонний результат. Никакого частичного сходства, которое можно было бы заметить, не остаётся.
Кавычки, прочитанные как данные. JWT_SECRET="abc" для одних загрузчиков означает abc, а для других "abc" — особенно когда файл в одном случае подключается шеллом, а в другом разбирается библиотекой. env_file в Docker Compose и парсер .env могут разойтись на одном и том же файле.
Невидимые символы из буфера обмена. Скопировав секрет из Slack, вики или PDF, легко притащить с собой символ нулевой ширины (U+200B, байты e2 80 8b) или неразрывный пробел (U+00A0, байты c2 a0). Оба невидимы в любом редакторе, и оба меняют HMAC.
Искажения в CI и контейнерах. У секретов, прошедших через подстановку в шелле, раскрывается $ или съедаются обратные слеши. Одни CI-системы обрезают значения, другие нет. Секреты Kubernetes лежат в манифесте в base64, а в контейнере в сыром виде — отдельная ловушка с двойным декодированием.
Лечится это тем, что на секрет перестают смотреть и начинают его измерять. На каждой стороне выведите длину и отпечаток, но никогда само значение:
printf '%s' "$JWT_SECRET" | wc -c
printf '%s' "$JWT_SECRET" | shasum -a 256 | cut -c1-16
Запустите обе команды на подписывающей и на проверяющей стороне и сравните два вывода. Совпали длина и отпечаток — значит, дело не в секрете, возвращайтесь к разделу 3. Длина на единицу больше ожидаемой — это перевод строки в конце. На две больше — это кавычки.
Когда длина не сходится и хочется увидеть, что именно там лежит, сделайте hex-дамп в локальном шелле над секретом для разработки:
printf '%s' "$JWT_SECRET" | xxd
0a в конце — это перевод строки. 22 в начале и в конце — пара кавычек. c2 a0 или e2 80 8b в середине — случай невидимого символа. Не запускайте это над продакшен-секретом на машине, чей вывод терминала куда-нибудь уезжает.
Тот же самый контроль внутри работающего процесса на Node или Python:
const s = process.env.JWT_SECRET ?? '';
console.log(Buffer.byteLength(s, 'utf8'), JSON.stringify(s.slice(-3)));
import os
s = os.environ["JWT_SECRET"]
print(len(s), len(s.encode("utf-8")), repr(s[-3:]))
В Python len(s), оказавшийся меньше len(s.encode("utf-8")), говорит, что в секрете, который должен был быть ASCII, есть не-ASCII символы.
5. Алгоритм и тип ключа не совпадают
Заголовок alg и ключ, который вы передаёте, должны принадлежать одному семейству. HS256 хочет общий секрет, то есть строку байтов. RS256 и ES256 хотят асимметричный ключ, то есть PEM или JWK. Перепутайте провода — и получите сбой, который в зависимости от снисходительности библиотеки выглядит то как внятная ошибка типа, то как обычный invalid signature.
Частые варианты:
- В заголовке
HS256, а проверяющая сторона отдаёт библиотеке открытый ключ в PEM. Некоторые библиотеки считают HMAC от текста PEM и сообщают о несовпадении подписи. - В заголовке
RS256, а проверяющая сторона отдаёт строку HMAC-секрета. - Проверяющая сторона вообще не передаёт список алгоритмов и позволяет библиотеке вывести его из
alg— тогда дрейф конфигурации на подписывающей стороне молча меняет поведение проверки.
Последний пункт — то место, где ошибка конфигурации превращается в проблему безопасности, поэтому фиксируйте алгоритм явно при каждом вызове проверки:
jwt.verify(token, key, { algorithms: ['HS256'] });
jwt.decode(token, key, algorithms=["HS256"])
Фиксация ещё и превращает расплывчатые ошибки подписи в точные. Если приходит токен с alg: RS256, а в вашем allowlist указан HS256, вы получите явную ошибку алгоритма с обоими значениями.
В этом разделе описана неправильная конфигурация: два ваших собственных компонента не сходятся во мнениях, и никакого противника при этом нет. Есть похожий по форме сбой, когда атакующий переписывает alg с RS256 на HS256 и подписывает токен вашим открытым ключом как HMAC-секретом. Это путаница алгоритмов, это атака, а не баг, и разбирается она вместе с остальной моделью угроз в статье лучшие практики безопасности JWT. Защита — явный allowlist — оказывается той же самой, и это хороший довод применять её даже тогда, когда вы всего лишь ловите баг.
6. Токен изменился по дороге
Прежде чем винить ключи, убедитесь, что проверяющая сторона получила ту же строку, которую выпустила подписывающая. JWT хрупок ровно так же, как хрупки строки.
Префикс Bearer. Authorization: Bearer eyJhbGci... — это значение заголовка, а не токен. Разрежете не по тому месту или разрежете один раз и оставите не ту половину — и проверять будете Bearer eyJhbGci... либо пустую строку. Отрезайте префикс осознанно:
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '').trim();
Пробелы и переводы строк. Токены, скопированные из терминала, переносятся. Токены, лежащие в YAML, сворачиваются. Один встроенный \n внутри третьего сегмента даёт несовпадение подписи, а не ошибку разбора, потому что декодеры base64url часто пропускают пробельные символы, а сравнение строк — нет.
Кодирование URL. Токен, проехавший в параметре запроса, может вернуться с . в виде %2E или с - и _, переведёнными слишком усердным кодировщиком. Декодируйте ровно один раз.
Обрезание. Cookie ограничены примерно 4 КБ каждая, а токены RS256 с несколькими claims регулярно этот предел превышают. Обрезанный токен обычно валится на декодировании base64, но если срез пришёлся на границу четырёх символов, вы получите с виду нормальный токен с неправильной подписью.
Вопрос закрывают две команды. В правильно сформированном JWT ровно две точки:
printf '%s' "$TOKEN" | tr -cd '.' | wc -c
А каждый символ должен входить в алфавит base64url, поэтому следующая команда не должна напечатать вообще ничего:
printf '%s' "$TOKEN" | tr -d 'A-Za-z0-9._-' | xxd
Любой вывод второй команды называет вашу проблему: 3d — это лишнее заполнение =, 2b и 2f — это + и / из стандартного base64 там, где base64url ждёт - и _, а 20 — залётный пробел.
7. Специфические сбои RS256 и ES256
Асимметричные алгоритмы меняют проблему с секретом на проблему с управлением ключами. Отказывает здесь другое, поэтому и список отдельный.
PKCS#1 против PKCS#8. Это два контейнерных формата для одного и того же ключа RSA, и на глаз они различаются одним словом в строке заголовка:
-----BEGIN RSA PRIVATE KEY----- ← PKCS#1
-----BEGIN PRIVATE KEY----- ← PKCS#8
Библиотеки принимают их по-разному. Когда одна отвергает формат сразу, вы получаете внятную ошибку; когда она разбирает его наполовину, вы получаете подпись, которая никогда не сойдётся. Не боритесь, а конвертируйте:
openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem
Ключи перепутаны местами. Подпись открытым ключом или проверка закрытым. В теории очевидно, на практике случается постоянно: оба файла лежат в одном каталоге под именами, различающимися на четыре символа. Проверить, где какой:
openssl rsa -in key.pem -noout -text | head -1
Закрытый ключ печатает размер своего модуля как закрытый ключ; открытый выдаст ошибку, если не добавить -pubin.
Дрейф JWKS и kid. С эндпоинтом JWKS проверяющая сторона выбирает ключ, сопоставляя kid из токена с набором ключей. Ломается тут обычно одно из трёх: подписывающая сторона провела ротацию, а закешированный на проверяющей стороне JWKS устарел; в токене нет kid, и проверяющая сторона берёт первый ключ набора; два окружения публикуют пересекающиеся значения kid. Если подозрение пало сюда, запросите JWKS заново и убедитесь, что точное значение kid из заголовка токена в нём есть.
Кодирование подписи ES256. Подпись ECDSA — это пара целых чисел, r и s, и сериализовать их можно двумя способами. Универсальные криптографические стеки часто выдают DER, структуру ASN.1 переменной длины. RFC 7518 §3.4 требует вместо этого форму JOSE: r и s, каждое дополненное до фиксированной длины и соединённое подряд, что для P-256 даёт 64 байта. Подпись в DER, положенная в JWT, неверна, и видно это уже по её длине. Значит, токен ES256, третий сегмент которого декодируется не ровно в 64 байта, собран чем-то, что пропустило преобразование.
Чтобы отделить проблему ключа от проблемы конвейера, подпишите ту же полезную нагрузку независимо в JWT-энкодере и сравните результат с тем, что выдал ваш сервис. Совпали подписи — смотрите на транспорт или на обработку claims. Разошлись — дело в ключе.
8. Ошибки, которые выглядят как сбой подписи, но им не являются
Часть из них библиотеки действительно называют неудачно, и именно поэтому они попадают не в тот баг-репорт.
| Симптом | Что это на самом деле | Куда смотреть |
|---|---|---|
PyJWT ExpiredSignatureError | exp в прошлом. В имени сказано про подпись, а причина в claim. | Расхождение часов между хостами или слишком короткий TTL |
PyJWT ImmatureSignatureError | nbf в будущем | Часы подписывающей стороны ушли вперёд относительно проверяющей |
Node TokenExpiredError | exp в прошлом | То же самое |
| Общий 401 без подробностей | Фреймворк свернул все сбои проверки в один ответ | Включите логирование ошибок на уровне библиотеки |
| Работает несколько минут, потом падает | Истечение срока токена, а не подпись | Сравните iat и exp с часами обоих хостов |
| Падает только для одной audience | Несовпадение aud или iss | Список ожидаемых audience на проверяющей стороне |
Именование в PyJWT — самая заметная ловушка. ExpiredSignatureError содержит слово «signature», но выбрасывается во время проверки claims, уже после того как подпись успешно проверена. Поиск по тексту ошибки ведёт прямиком в материалы про диагностику подписи, и часы уходят не на ту часть проблемы.
Расхождение часов даёт самую сбивающую с толку картину: перемежающиеся сбои, не коррелирующие ни с чем в вашем коде. Если часы одного хоста уходят вперёд, свежевыпущенные токены не проходят проверку nbf или iat по прибытии, и сбои гуляют по мере роста расхождения. Сначала сравните date -u на обеих машинах. Большинство библиотек принимает параметр допуска: это правильное лечение расхождения, которое нельзя устранить, и неправильное лечение для часов, которые действительно сломаны.
Общее правило: если сбой зависит от времени, от хоста или от audience — это не проблема подписи. Сбои подписи детерминированы. Один и тот же токен с одним и тем же ключом падает одинаково всегда.
9. Воспроизводимый порядок диагностики
Выполняйте по порядку. Каждый шаг либо находит баг, либо отсекает ветку, и остановиться раньше времени — это и есть цель.
- Декодируйте заголовок. Вставьте токен в декодер JWT и запишите
algиkid. Это определяет всё дальнейшее и не требует ключа. - Проверьте форму токена. Ровно две точки, только символы base64url, без префикса
Bearer, без пробельных символов. Используйте две команды из раздела 6: этот шаг отсекает порчу при передаче. - Зафиксируйте алгоритм в вызове проверки. Если
algи ваш allowlist расходятся, вы теперь получите явную ошибку с обоими значениями вместо общей. - Снимите отпечаток ключа с обеих сторон. Выведите длину в байтах и укороченный SHA-256 на подписывающей и на проверяющей стороне, как в разделе 4. Разные значения означают, что виновата обвязка, и до шага 5 вы не дойдёте.
- Если стороны написаны на разных языках, определитесь с истолкованием байтов. Сверьтесь с таблицей из раздела 3, решите явно, текст ваш секрет или base64, и добейтесь, чтобы обе стороны заявляли это в коде, а не по умолчанию.
- Подпишите ту же полезную нагрузку независимо. Возьмите JWT-энкодер с ключом, который считаете правильным, и сравните его третий сегмент с третьим сегментом вашего токена. Совпало — подписывающая сторона в порядке, проблема в проверяющей.
- Перепроверьте HMAC вручную. Прогоните signing input через генератор HMAC с обоими истолкованиями байтов. То, которое совпадёт с токеном, и укажет, какую сторону менять.
Если все семь шагов пройдены, а помощь всё ещё нужна, учтите: большинство баг-репортов застревает потому, что в них нет фактов, определяющих ответ. Приложите:
- Значение
algиз заголовка и наличиеkid - Язык, библиотеку и точную версию на обеих сторонах — подписывающей и проверяющей
- Длину секрета в байтах на обеих сторонах и первые 16 шестнадцатеричных символов его SHA-256 (сам секрет — никогда)
- Хранится ли секрет как текст или как base64 и как каждая сторона его преобразует
- Полный signing input. Первые два сегмента не секретны: у кого есть токен, тот их и так прочтёт
- Для RS256 и ES256 — строку заголовка PEM дословно
Этот список превращает безответное «моя подпись JWT не совпадает» в вопрос, который кто-то действительно способен решить, обычно с первого ответа.
FAQ
Почему один и тот же секрет работает в одном языке и не работает в другом?
Потому что библиотеки расходятся в том, как превратить строку секрета в байты ключа. Node jsonwebtoken и Python PyJWT используют UTF-8; устаревшая перегрузка со String в jjwt использовала кодек base64 (jwtk/jjwt#204); Go и .NET оставляют решение вашему месту вызова. Символы те же, байты разные, HMAC разный.
Подпись покрывает декодированную полезную нагрузку или закодированную строку?
Закодированную строку. RFC 7515 определяет signing input как base64url(header) + "." + base64url(payload) в виде литерального ASCII. Любой слой, который десериализует полезную нагрузку и сериализует её заново, меняет порядок ключей, пробелы или формат чисел — получается другая строка и, следовательно, другая подпись.
Мой секрет похож на base64 — декодировать его перед подписью?
Только если так же поступает другая сторона. Правильного ответа в отрыве от контекста нет; требование в том, чтобы обе стороны договорились. Проверьте, состоит ли строка только из символов base64 и кратна ли её длина четырём, а затем закрепите выбор явно в коде на обеих сторонах, вместо того чтобы полагаться на умолчания.
Может ли перевод строки в конце .env действительно сломать подпись?
Да. HMAC потребляет байты, а abc\n — это четыре байта там, где abc — три. Полученная подпись не имеет с правильной ничего общего. Выведите printf '%s' "$JWT_SECRET" | wc -c на обоих хостах: длина на единицу больше ожидаемой почти всегда означает именно это.
Как понять, дело в секрете или в алгоритме?
Сначала прочтите alg из заголовка. Начинается с HS — нужен общий секрет, и PEM не сработает. Начинается с RS, PS или ES — нужна пара ключей, и строка-секрет не сработает. Как только alg и тип ключа окажутся из одного семейства, оставшиеся сбои — это проблемы содержимого ключа.
Почему jwt.io говорит, что подпись верна, а мой сервер её отклоняет?
Потому что онлайн-инструмент и ваш сервер могут по-разному истолковывать секрет: один как текст UTF-8, другой как base64. Инструмент проверяет по тем байтам, которые вывел он, а не по тем, которые вывел ваш сервер. И ещё: никогда не вставляйте продакшен-секреты на сторонний сайт, используйте ключ для разработки.
Бывает ли «invalid signature» вызвана просроченным токеном?
Нет. Проверка подписи идёт до проверки claims, поэтому истечение срока причиной быть не может. Оно проявляется отдельно — как TokenExpiredError в Node или ExpiredSignatureError в PyJWT; имя последнего вводит в заблуждение, ведь подпись проверилась нормально и не прошёл только exp.
Заключение
Несовпадение подписи почти никогда не бывает проблемой криптографии. HMAC-SHA256 работает. RSA работает. Ломается граница, на которой строка становится байтами: кодек base64 с одной стороны и UTF-8 с другой, перевод строки, который сохранил загрузчик конфигурации, полезная нагрузка, которую услужливо пересериализовал шлюз. Каждая причина в этом руководстве — это разногласие по поводу байтов.
Поэтому сделайте байты явными и перестаньте полагаться на умолчания. Зафиксируйте в документации своей команды, хранится ли общий секрет как обычный текст или как base64, и заставьте каждый сервис преобразовывать его объявленным способом, а не наследовать то, что предположила его библиотека. Для систем, растянутых на несколько языков, храните секреты в hex или base64 и декодируйте их явно в каждом месте вызова: по одной строке на сервис — и двусмысленности больше нет. Затем добавьте отпечаток длины в байтах из раздела 4 в health check, чтобы следующее несовпадение всплывало как предупреждение при старте, а не как 401 в проде.