Статья по JSON Web Token для Java
Введение
Многие приложения используют JSON Web Token (JWT), чтобы после аутентификации клиент мог подтверждать свою личность при дальнейшем обмене данными.
Из JWT.IO:
JSON Web Token (JWT) — открытый стандарт (RFC 7519), задающий компактный и самодостаточный способ безопасной передачи информации между сторонами в виде JSON-объекта. Её можно проверить и ей можно доверять, потому что данные цифровой подписью. JWT могут подписываться секретом (алгоритм HMAC) или парой ключей RSA.
JWT переносят сведения об идентичности и свойствах (claims) клиента. Сервер подписывает эти данные, чтобы после отправки клиенту их нельзя было подменить. Это мешает злоумышленнику изменить идентичность или атрибуты — например, сменить роль с обычного пользователя на администратора или подменить логин.
Токен создаётся при аутентификации (выдаётся после успешного входа) и проверяется сервером до обработки запроса. Приложение использует токен как «удостоверение личности» клиента; сервер может надёжно проверить его целостность и подлинность. Подход без состояния и переносим между технологиями клиента и сервера и по разным каналам — чаще всего по HTTP.
Структура токена
Пример структуры токена с JWT.IO:
[Base64(HEADER)].[Base64(PAYLOAD)].[Base64(SIGNATURE)]
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.
TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQФрагмент 1: заголовок
{
"alg": "HS256",
"typ": "JWT"
}Фрагмент 2: полезная нагрузка
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}Фрагмент 3: подпись
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), KEY )Цель
Эта статья помогает избежать типичных проблем безопасности при использовании JWT в Java.
Советы ниже взяты из Java-проекта, демонстрирующего корректное создание и проверку JWT.
Исходный проект: poc-jwt; используется официальная библиотека JWT.
Далее слово токен означает именно JSON Web Token (JWT).
Стоит ли использовать JWT
JWT удобны и позволяют отдавать сервисы (часто REST) без состояния на сервере, но это не универсальное решение: есть вопросы хранения токена (ниже в статье) и другие ограничения.
Если приложению не нужна полная безсостоятельность, можно использовать классические сессии веб-фреймворка и следовать статье по управлению сессиями. Для безсостоятельных приложений при грамотной реализации JWT — хороший кандидат.
Проблемы
Алгоритм хеширования «none»
Симптом
Атака, описанная здесь: злоумышленник меняет токен и подменяет алгоритм в заголовке на none, якобы целостность уже проверена. Как указано в статье, некоторые библиотеки считали токены с алгоритмом none валидными с проверенной подписью, поэтому можно менять claims, и приложение всё равно доверяет токену.
Как предотвратить
Сначала используйте JWT-библиотеку, не подверженную этой уязвимости.
Затем при проверке токена явно требуйте ожидаемый алгоритм.
Пример реализации
// Ключ HMAC — не сериализовать и не хранить как String в памяти JVM
private transient byte[] keyHMAC = ...;
...
// Контекст проверки: явно требуем HMAC-256
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(keyHMAC)).build();
// Проверка токена; при ошибке будет исключение
DecodedJWT decodedToken = verifier.verify(token);Перехват токена (sidejacking)
Симптом
Токен перехвачен или украден; злоумышленник использует его для доступа к системе от имени пользователя.
Как предотвратить
Один из способов — добавить в токен «контекст пользователя»:
- Случайная строка, сгенерированная при аутентификации. Отправьте её клиенту в усиленной cookie с флагами HttpOnly и Secure, SameSite, Max-Age и префиксами cookie. Не задавайте заголовок expires, чтобы cookie очищалась при закрытии браузера. Max-Age должен быть не больше срока жизни JWT — никогда не больше.
- В токен кладите SHA256-хеш этой строки (а не сырое значение), чтобы при XSS злоумышленник не прочитал случайную строку и не выставил ожидаемую cookie.
Не используйте IP-адрес как часть контекста: IP может меняться в одной сессии по легитимным причинам (например, смена сети на мобильном). Кроме того, привязка к IP может конфликтовать с требованиями GDPR в ЕС.
При проверке токена, если контекст неверный (например, повторное использование украденного токена), токен нужно отклонить.
Пример реализации
Код создания токена после успешной аутентификации.
// Ключ HMAC — не сериализовать и не хранить как String в памяти JVM
private transient byte[] keyHMAC = ...;
// Генератор случайных данных
private SecureRandom secureRandom = new SecureRandom();
...
// Случайная строка — отпечаток пользователя
byte[] randomFgp = new byte[50];
secureRandom.nextBytes(randomFgp);
String userFingerprint = DatatypeConverter.printHexBinary(randomFgp);
// Отпечаток в усиленной cookie — вручную, т.к. javax.servlet.http.Cookie
// не поддерживает атрибут SameSite
String fingerprintCookie = "__Secure-Fgp=" + userFingerprint
+ "; SameSite=Strict; HttpOnly; Secure";
response.addHeader("Set-Cookie", fingerprintCookie);
// SHA256 отпечатка для claim в токене (не сырое значение), чтобы XSS
// не мог прочитать отпечаток и выставить ожидаемую cookie
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] userFingerprintDigest = digest.digest(userFingerprint.getBytes("utf-8"));
String userFingerprintHash = DatatypeConverter.printHexBinary(userFingerprintDigest);
// Токен на 15 минут с контекстом клиента (отпечаток)
Calendar c = Calendar.getInstance();
Date now = c.getTime();
c.add(Calendar.MINUTE, 15);
Date expirationDate = c.getTime();
Map<String, Object> headerClaims = new HashMap<>();
headerClaims.put("typ", "JWT");
String token = JWT.create().withSubject(login)
.withExpiresAt(expirationDate)
.withIssuer(this.issuerID)
.withIssuedAt(now)
.withNotBefore(now)
.withClaim("userFingerprint", userFingerprintHash)
.withHeader(headerClaims)
.sign(Algorithm.HMAC256(this.keyHMAC));Код проверки токена.
// Ключ HMAC — не сериализовать и не хранить как String в памяти JVM
private transient byte[] keyHMAC = ...;
...
// Отпечаток из выделенной cookie
String userFingerprint = null;
if (request.getCookies() != null && request.getCookies().length > 0) {
List<Cookie> cookies = Arrays.stream(request.getCookies()).collect(Collectors.toList());
Optional<Cookie> cookie = cookies.stream().filter(c -> "__Secure-Fgp"
.equals(c.getName())).findFirst();
if (cookie.isPresent()) {
userFingerprint = cookie.get().getValue();
}
}
// SHA256 отпечатка из cookie для сравнения с хешем в токене
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] userFingerprintDigest = digest.digest(userFingerprint.getBytes("utf-8"));
String userFingerprintHash = DatatypeConverter.printHexBinary(userFingerprintDigest);
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(keyHMAC))
.withIssuer(issuerID)
.withClaim("userFingerprint", userFingerprintHash)
.build();
// Проверка токена; при ошибке будет исключение
DecodedJWT decodedToken = verifier.verify(token);Нет встроенного отзыва токена пользователем
Симптом
Свойство самих JWT: токен недействителен только после истечения срока. Пользователь не может явно «отозвать» токен; если его украли, заблокировать злоумышленника, просто инвалидировав токен, нельзя.
Как предотвратить
JWT без состояния: на серверах нет сессии для инвалидации. Грамотная защита от sidejacking (выше) снижает потребность в серверном списке отзыва: усиленная cookie в схеме sidejacking по уровню сравнима с ID сессии, и пока не перехвачены и cookie, и JWT, токеном нельзя воспользоваться. «Выход» можно имитировать, очистив JWT из sessionStorage. При закрытии браузера и cookie, и sessionStorage обычно очищаются.
Другой вариант — список отозванных токенов (denylist), имитирующий выход как в классической сессии.
В списке хранится дайджест токена (SHA-256 в HEX) и дата отзыва; запись должна жить минимум до истечения срока токена.
При «выходе» вызывается сервис, добавляющий токен пользователя в список — дальнейшее использование токена в приложении блокируется.
Пример реализации
Хранилище блок-листа
Таблица в БД как центральное хранилище отозванных токенов.
create table if not exists revoked_token(jwt_token_digest varchar(255) primary key,
revocation_date timestamp default now());Управление отзывом токена
Код добавления токена в список отзыва и проверки, отозван ли токен.
/**
* Обработка отзыва токена (выход).
* БД позволяет нескольким инстансам проверять отзыв и централизованно чистить записи.
*/
public class TokenRevoker {
/** Подключение к БД */
@Resource("jdbc/storeDS")
private DataSource storeDS;
/**
* Проверка: есть ли в таблице отзыва дайджест (HEX) зашифрованного токена
*
* @param jwtInHex Токен в HEX
* @return true, если токен отозван
* @throws Exception Ошибка при работе с БД
*/
public boolean isTokenRevoked(String jwtInHex) throws Exception {
boolean tokenIsPresent = false;
if (jwtInHex != null && !jwtInHex.trim().isEmpty()) {
byte[] cipheredToken = DatatypeConverter.parseHexBinary(jwtInHex);
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] cipheredTokenDigest = digest.digest(cipheredToken);
String jwtTokenDigestInHex = DatatypeConverter.printHexBinary(cipheredTokenDigest);
try (Connection con = this.storeDS.getConnection()) {
String query = "select jwt_token_digest from revoked_token where jwt_token_digest = ?";
try (PreparedStatement pStatement = con.prepareStatement(query)) {
pStatement.setString(1, jwtTokenDigestInHex);
try (ResultSet rSet = pStatement.executeQuery()) {
tokenIsPresent = rSet.next();
}
}
}
}
return tokenIsPresent;
}
/**
* Добавить дайджест (HEX) зашифрованного токена в таблицу отзыва
*
* @param jwtInHex Токен в HEX
* @throws Exception Ошибка при работе с БД
*/
public void revokeToken(String jwtInHex) throws Exception {
if (jwtInHex != null && !jwtInHex.trim().isEmpty()) {
byte[] cipheredToken = DatatypeConverter.parseHexBinary(jwtInHex);
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] cipheredTokenDigest = digest.digest(cipheredToken);
String jwtTokenDigestInHex = DatatypeConverter.printHexBinary(cipheredTokenDigest);
if (!this.isTokenRevoked(jwtInHex)) {
try (Connection con = this.storeDS.getConnection()) {
String query = "insert into revoked_token(jwt_token_digest) values(?)";
int insertedRecordCount;
try (PreparedStatement pStatement = con.prepareStatement(query)) {
pStatement.setString(1, jwtTokenDigestInHex);
insertedRecordCount = pStatement.executeUpdate();
}
if (insertedRecordCount != 1) {
throw new IllegalStateException("Number of inserted record is invalid,"
+ " 1 expected but is " + insertedRecordCount);
}
}
}
}
}
}Утечка информации из токена
Симптом
Злоумышленник имеет токен (или набор), декодирует полезную нагрузку (Base64, не шифрование по умолчанию) и извлекает сведения о системе — роли, формат логина и т.д.
Как предотвратить
Шифровать токен, например симметричным алгоритмом.
Важно защитить шифртекст от атак вроде padding oracle и других методов криптоанализа.
Для этого подходит AES-GCM — Authenticated Encryption with Associated Data.
Подробнее в документации Tink:
AEAD primitive (Authenticated Encryption with Associated Data) provides functionality of symmetric
authenticated encryption.
Implementations of this primitive are secure against adaptive chosen ciphertext attacks.
When encrypting a plaintext one can optionally provide associated data that should be authenticated
but not encrypted.
That is, the encryption with associated data ensures authenticity (ie. who the sender is) and
integrity (ie. data has not been tampered with) of that data, but not its secrecy.
See RFC5116: https://tools.ietf.org/html/rfc5116Примечание:
Шифрование здесь в основном скрывает внутренние данные, но главная защита от подмены JWT — подпись. Подпись и её проверка обязательны.
Пример реализации
Шифрование токена
Класс для шифрования/расшифрования. Для операций используется Google Tink.
/**
* Шифрование и расшифрование токена (AES-GCM).
*
* @see "https://github.com/google/tink/blob/master/docs/JAVA-HOWTO.md"
*/
public class TokenCipher {
/**
* Конструктор — регистрация конфигурации AEAD
*
* @throws Exception Ошибка регистрации AEAD
*/
public TokenCipher() throws Exception {
AeadConfig.register();
}
/**
* Зашифровать JWT
*
* @param jwt Исходный токен
* @param keysetHandle Набор ключей Tink
* @return Зашифрованный токен в HEX
* @throws Exception Ошибка шифрования
*/
public String cipherToken(String jwt, KeysetHandle keysetHandle) throws Exception {
if (jwt == null || jwt.isEmpty() || keysetHandle == null) {
throw new IllegalArgumentException("Both parameters must be specified!");
}
Aead aead = AeadFactory.getPrimitive(keysetHandle);
byte[] cipheredToken = aead.encrypt(jwt.getBytes(), null);
return DatatypeConverter.printHexBinary(cipheredToken);
}
/**
* Расшифровать JWT
*
* @param jwtInHex Токен в HEX
* @param keysetHandle Набор ключей Tink
* @return Токен в открытом виде
* @throws Exception Ошибка расшифрования
*/
public String decipherToken(String jwtInHex, KeysetHandle keysetHandle) throws Exception {
if (jwtInHex == null || jwtInHex.isEmpty() || keysetHandle == null) {
throw new IllegalArgumentException("Both parameters must be specified !");
}
byte[] cipheredToken = DatatypeConverter.parseHexBinary(jwtInHex);
Aead aead = AeadFactory.getPrimitive(keysetHandle);
byte[] decipheredToken = aead.decrypt(cipheredToken, null);
return new String(decipheredToken);
}
}Создание и проверка токена
Используйте шифрование при создании и проверке токена.
Загрузите ключи (ключ шифрования сгенерирован и сохранён через Google Tink) и инициализируйте шифрование.
// Ключи из конфигурационных файлов, чтобы не держать секреты как String в JVM
private transient byte[] keyHMAC = Files.readAllBytes(Paths.get("src", "main", "conf", "key-hmac.txt"));
private transient KeysetHandle keyCiphering = CleartextKeysetHandle.read(JsonKeysetReader.withFile(
Paths.get("src", "main", "conf", "key-ciphering.json").toFile()));
...
TokenCipher tokenCipher = new TokenCipher();Создание токена.
// Создание JWT через API...
// Шифрование токена (строка JSON)
String cipheredToken = tokenCipher.cipherToken(token, this.keyCiphering);
// Отправка зашифрованного токена в HEX клиенту в ответе HTTP...Проверка токена.
// Получение зашифрованного токена в HEX из запроса HTTP...
String token = tokenCipher.decipherToken(cipheredToken, this.keyCiphering);
// Проверка JWT через API...
// Проверка доступа...Хранение токена на стороне клиента
Симптом
Приложение хранит токен так, что:
- браузер отправляет его автоматически (Cookie);
- он доступен после перезапуска браузера (localStorage);
- его можно прочитать при XSS (cookie, доступная для JavaScript, или токен в local/sessionStorage).
Как предотвратить
- Храните токен в sessionStorage браузера или в замыканиях JavaScript с приватными переменными.
- Передавайте его заголовком HTTP Authorization: Bearer при вызовах API из JavaScript.
- Добавьте в токен отпечаток (контекст), как описано выше.
Хранение в sessionStorage оставляет риск кражи через XSS; отпечаток мешает использовать украденный токен на машине злоумышленника. Сузьте поверхность атаки: задайте Content Security Policy в браузере.
sessionStorage не всегда удобен из‑за области «на вкладку»; балансируйте безопасность и удобство.
localStorage удобнее для сохранения между перезапусками и вкладками, но нужны жёсткие меры:
- короткое время жизни токена (например, простой 15–30 мин, абсолютный максимум ~8 ч);
- ротация токенов и refresh-токены.
Если нужна связность между вкладками и при этом sessionStorage, рассмотрите BroadcastChannel API или SSO для повторной аутентификации в новых вкладках.
Альтернатива session/localStorage — модуль на замыканиях: все запросы идут через JS-модуль с приватной переменной для токена.
Примечание:
- Остаётся сценарий, когда злоумышленник использует контекст браузера жертвы как прокси; CSP ограничивает обмен с неожиданными доменами.
- Можно выдавать токен в усиленной cookie; тогда нужна защита от CSRF.
Пример реализации
JavaScript: сохранение токена после аутентификации.
/* Запрос JWT и запись в sessionStorage */
function authenticate() {
const login = $("#login").val();
const postData = "login=" + encodeURIComponent(login) + "&password=test";
$.post("/services/authenticate", postData, function (data) {
if (data.status == "Authentication successful!") {
...
sessionStorage.setItem("token", data.token);
}
else {
...
sessionStorage.removeItem("token");
}
})
.fail(function (jqXHR, textStatus, error) {
...
sessionStorage.removeItem("token");
});
}JavaScript: заголовок Bearer при вызове сервиса (пример — проверка токена).
/* Проверка JWT */
function validateToken() {
var token = sessionStorage.getItem("token");
if (token == undefined || token == "") {
$("#infoZone").removeClass();
$("#infoZone").addClass("alert alert-warning");
$("#infoZone").text("Obtain a JWT token first :)");
return;
}
$.ajax({
url: "/services/validate",
type: "POST",
beforeSend: function (xhr) {
xhr.setRequestHeader("Authorization", "bearer " + token);
},
success: function (data) {
...
},
error: function (jqXHR, textStatus, error) {
...
},
});
}JavaScript: замыкание с приватной переменной для токена.
function myFetchModule() {
// Сохраняем оригинальный fetch от перезаписи через XSS
const fetch = window.fetch;
const authOrigins = ["https://yourorigin", "http://localhost"];
let token = '';
this.setToken = (value) => {
token = value
}
this.fetch = (resource, options) => {
let req = new Request(resource, options);
destOrigin = new URL(req.url).origin;
if (token && authOrigins.includes(destOrigin)) {
req.headers.set('Authorization', token);
}
return fetch(req)
}
}
...
// использование:
const myFetch = new myFetchModule()
function login() {
fetch("/api/login")
.then((res) => {
if (res.status == 200) {
return res.json()
} else {
throw Error(res.statusText)
}
})
.then(data => {
myFetch.setToken(data.token)
console.log("Token received and stored.")
})
.catch(console.error)
}
...
// после входа:
function makeRequest() {
myFetch.fetch("/api/hello", {headers: {"MyHeader": "foobar"}})
.then((res) => {
if (res.status == 200) {
return res.text()
} else {
throw Error(res.statusText)
}
}).then(responseText => console.log("helloResponse", responseText))
.catch(console.error)
}Слабый секрет токена
Симптом
При HMAC вся безопасность зависит от силы секрета. Получив валидный JWT, злоумышленник может офлайн подбирать секрет (John the Ripper, Hashcat).
При успехе можно менять claims и переподписывать токен — эскалация привилегий, компрометация чужих аккаунтов и т.д. Подробнее в руководствах.
Как предотвратить
Секрет для подписи JWT должен быть длинным, уникальным и не вводиться человеком вручную — не меньше 64 символов, из криптографически стойкого источника случайности.
Альтернатива — подпись RSA вместо HMAC и общего секрета.
Дополнительно
- {JWT}.{Attack}.Playbook — известные атаки и типичные ошибки конфигурации JWT.
- JWT Best Practices (Internet-Draft)
© Перевод на русский язык. Оригинальные материалы: OWASP Cheat Sheet Series.
Этот проект использует материалы OWASP, распространяемые по лицензии CC BY-SA 4.0.