Більшість зламів JWT і OAuth — це не злам криптографії. HMAC і RSA ніхто не «розкрив». Ламають конфігурацію: сервер, який вірить заголовку токена,
redirect_uri, що перевіряється «приблизно», refresh-токен без ротації. У цій статті я пройшовся по основних атаках за одним шаблоном: що це → чому працює → як закрити → як перевірити. Перевірки — короткі тести на Spring Boot, які очікують401.Базу (структура JWT, claims, підпис) я розібрав у статті «JWT для новачків» — тут її не повторюю.
⚡ Коротко
- ✅ Сервер, а не токен, вирішує, як перевіряти підпис. Дозволений алгоритм і ключі — лише з конфігурації.
- ✅ Валідний підпис ≠ валідний токен. Без перевірки
iss,audіexpчужий токен пройде. - ✅ В OAuth найнебезпечніше — редиректи. Точне співпадіння
redirect_uriі PKCE для всіх клієнтів закривають більшість атак. - ✅ Refresh-токен — найцінніша ціль. Ротація і виявлення повторного використання обов'язкові.
- ✅ ID token — не для API. Це «посвідчення» для клієнта, а не перепустка.
Зміст статті:
- 📌 JWT vs OAuth 2.0 vs OIDC
- 📌 Модель загроз
- 📌 Атаки на сам JWT
- 📌 Атаки на зберігання і передачу токенів
- 📌 Атаки на OAuth-флоу
- 📌 Refresh-токени
- 📌 ID token ≠ access token
- 📌 OAuth 2.1 і RFC 9700
- ⚖️ Коли JWT не потрібен
- 💼 Чекліст для продакшну
- 💼 Як я тестую свій API
- ❓ FAQ
- ✅ Висновки
🧭 JWT vs OAuth 2.0 vs OIDC: що є що
Ці три терміни постійно змішують, хоча вони з різних рівнів. Без цього розрізнення половина атак нижче виглядає плутано.
| JWT | OAuth 2.0 | OpenID Connect (OIDC) | |
|---|---|---|---|
| Що це | Формат токена | Протокол делегування доступу | Шар аутентифікації поверх OAuth 2.0 |
| Відповідає на питання | «Як упакувати й підписати дані?» | «Що цей застосунок може робити від імені користувача?» | «Хто цей користувач?» |
| Що дає на виході | Підписаний рядок header.payload.signature | Access token (+ refresh token) | ID token (завжди JWT) + access token |
| Стандарт | RFC 7519 | RFC 6749 + RFC 9700 | OpenID Connect Core 1.0 |
| Типовий приклад | Токен сесії у власному API | Застосунок отримує доступ до вашого Google Calendar | Кнопка «Увійти через Google» |
💡 Головне: вони незалежні. JWT можна використовувати без OAuth (власний логін), а OAuth — без JWT (access token може бути непрозорим рядком, який API перевіряє запитом до сервера авторизації). Обов'язково JWT лише в одному місці — ID token в OIDC.
🎯 Модель загроз: хто атакує і чого хоче
Майже всі атаки нижче — варіації трьох сценаріїв. Я раджу тримати їх у голові: так легше зрозуміти, від чого саме захищає кожне налаштування.
| Хто | Що може | Чого хоче |
|---|---|---|
| Перехоплювач | Бачить трафік, логи, історію браузера, заголовок Referer |
Скопіювати готовий токен чи authorization code |
| Скрипт на клієнті (XSS) | Виконує JavaScript у вашому домені | Прочитати токен зі сховища або робити запити від імені користувача |
| Зловмисний клієнт / сайт | Створює власні токени, підставляє посилання й редиректи | Підробити токен або змусити сервер відправити код чи токен до себе |
💡 Перший і другий крадуть справжні токени — від них захищають короткий термін життя, безпечне зберігання і прив'язка токенів. Третій намагається підробити або перенаправити — від нього захищає сувора перевірка на сервері.
🧨 Атаки на сам JWT
Усі перевірки в цьому розділі — тести з класу JwtSecurityTest, повну версію якого я наводжу в розділі «Як я тестую свій API». Тестовий API — Spring Boot Resource Server, який приймає лише RS256-токени від https://auth.example.com для аудиторії orders-api.
1. alg: none
Що це: зловмисник ставить у заголовку "alg": "none", видаляє підпис і пише в payload що завгодно.
Чому працює: специфікація JWT дозволяє «незахищені» токени, і бібліотека, яка бере алгоритм із заголовка, може їх прийняти.
Як закрити: явний список дозволених алгоритмів на сервері. none у ньому не буває ніколи.
Перевірка:
@Test
void algNoneIsRejected() throws Exception {
expect401(new PlainJWT(validClaims().build()).serialize());
}
2. Algorithm confusion (RS256 → HS256)
Що це: сервер чекає RS256 і перевіряє підпис публічним ключем. Зловмисник підписує токен алгоритмом HS256, використовуючи як секрет цей самий публічний ключ.
Чому працює: якщо код перевірки робить «візьми alg із заголовка і перевір цим ключем», то для HS256 публічний ключ стає HMAC-секретом. А публічний ключ — на те й публічний.
Як закрити: прив'язати ключ до алгоритму в конфігурації. У Spring NimbusJwtDecoder.withPublicKey(...) за замовчуванням приймає лише RS256 — я все одно вказую алгоритм явно, щоб це було видно в коді.
Перевірка:
@Test
void hs256SignedWithPublicKeyIsRejected() throws Exception {
SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), validClaims().build());
jwt.sign(new MACSigner(PUBLIC_KEY.getEncoded()));
expect401(jwt.serialize());
}
3. Слабкий HS256-секрет і перебір
Що це: зловмисник бере будь-який ваш токен (свій власний, наприклад) і перебирає секрети офлайн, доки підпис не співпаде.
Чому працює: для перебору не потрібен ваш сервер — лише один токен і відеокарта. Секрети на кшталт secret, changeme чи назви проєкту знаходяться за секунди.
Як закрити: випадковий секрет щонайменше 256 біт (наприклад, openssl rand -base64 32), зберігання поза кодом. Якщо токени перевіряють кілька сервісів — краще асиметричний алгоритм.
Перевірка: це питання конфігурації, а не рантайму, тому тесту з 401 тут немає. Корисний факт: бібліотека Nimbus (на ній працює Spring Security) кидає KeyLengthException для HS256-секрету коротшого за 256 біт. Але довжина ≠ випадковість: 32 символи зі словника цю перевірку пройдуть.
4. Ін'єкції через kid і jku
Що це: заголовки kid (ID ключа) і jku (URL набору ключів) приходять від клієнта. Зловмисник вказує в jku свій сервер з власним ключем або підставляє в kid шлях до файлу чи SQL-фрагмент.
Чому працює: сервер, який завантажує ключі за URL із токена, перевіряє підпис ключем зловмисника. А kid, підставлений у запит до БД чи шлях до файлу без фільтрації, — класична ін'єкція.
Як закрити: URL набору ключів (JWKS) — лише з конфігурації. kid використовувати тільки як ключ для пошуку серед уже відомих ключів, ніколи як шлях чи частину запиту.
Перевірка:
@Test
void foreignJkuIsIgnored() throws Exception {
KeyPair attacker = KeyPairGenerator.getInstance("RSA").genKeyPair();
JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256)
.jwkURL(URI.create("https://evil.example/jwks.json"))
.keyID("evil-key")
.build();
SignedJWT jwt = new SignedJWT(header, validClaims().build());
jwt.sign(new RSASSASigner(attacker.getPrivate()));
expect401(jwt.serialize());
}
5. Підстановка токена без перевірки aud і iss
Що це: токен, чесно виданий для іншого сервісу (або іншим сервером авторизації), пред'являють вашому API.
Чому працює: підпис валідний — сервіси довіряють одному провайдеру й одному набору ключів. Якщо API не перевіряє, для кого токен, він прийме будь-який.
Як закрити: перевіряти iss (хто видав) і aud (для кого). У Spring валідатор issuer'а вмикається через JwtValidators.createDefaultWithIssuer(...), а aud треба додати окремо — конфіг у розділі тестування.
Перевірка:
@Test
void foreignAudienceIsRejected() throws Exception {
expect401(rs256(validClaims().audience("billing-api").build()));
}
@Test
void foreignIssuerIsRejected() throws Exception {
expect401(rs256(validClaims().issuer("https://evil.example").build()));
}
✅ Швидкий висновок: усі п'ять атак — це сервер, який вірить токену більше, ніж своїй конфігурації. Алгоритм, ключі, issuer і audience мають бути зафіксовані на сервері.
📦 Атаки на зберігання і передачу токенів
1. XSS + localStorage
Що це: скрипт зловмисника (через XSS або скомпрометовану npm-залежність) читає токен з localStorage і відправляє на свій сервер.
Чому працює: localStorage доступний будь-якому JavaScript у вашому домені.
Як закрити: не тримати токени там, куди дотягується JS. Для SPA найнадійніший варіант — патерн BFF (Backend for Frontend): токени живуть на сервері, браузер отримує лише сесійну HttpOnly-cookie. Плюс Content Security Policy проти самого XSS.
💡 Чесно: HttpOnly не робить XSS безпечним — скрипт усе ще може робити запити від імені користувача, поки той на сторінці. Але він не зможе винести токен і користуватися ним годинами.
2. CSRF при токенах у cookies
Що це: чужий сайт змушує браузер користувача надіслати запит на ваш API — і браузер сам додає cookie.
Чому працює: cookie прикріплюються до запитів автоматично, незалежно від того, хто ініціював запит.
Як закрити: SameSite=Lax або Strict + CSRF-токен для запитів, що змінюють дані. У Spring Security CSRF-захист увімкнений за замовчуванням. Токен у заголовку Authorization CSRF не вразливий — браузер не додає його сам.
3. Токени в URL, логах і Referer
Що це: токен потрапляє в ?access_token=..., а звідти — в access-логи, історію браузера, аналітику і заголовок Referer при переході на інший сайт.
Чому працює: URL логують усі — проксі, балансувальники, CDN. Ніхто не вважає його секретом.
Як закрити: токени тільки в заголовку Authorization; не логувати цей заголовок; Referrer-Policy: strict-origin-when-cross-origin або суворіше. У Spring DefaultBearerTokenResolver за замовчуванням не приймає токен з query-параметра — і я не вмикаю цю опцію.
Перевірка:
@Test
void tokenInQueryParamIsRejected() throws Exception {
mvc.perform(get("/api/me").param("access_token", rs256(validClaims().build())))
.andExpect(status().isUnauthorized());
}
✅ Швидкий висновок: ідеального сховища в браузері немає. Найменше ризиків — коли токенів у браузері немає взагалі (BFF), а якщо є — у
HttpOnly-cookie зSameSiteі CSRF-захистом.
🔀 Атаки на OAuth-флоу
Тут об'єкт перевірки — не API, а сервер авторизації. У прикладах я використовую Spring Authorization Server; для Keycloak чи Auth0 логіка та сама — змінюються лише налаштування.
1. Підміна redirect_uri і open redirect
Що це: зловмисник надсилає користувачу посилання на авторизацію зі своїм redirect_uri. Користувач логіниться — і authorization code летить до зловмисника.
Чому працює: сервер перевіряє redirect_uri за префіксом чи wildcard (https://app.example.com/*). Тоді підходить і https://app.example.com/redirect?to=evil.example — а якщо на сайті є open redirect, код «перетече» далі.
Як закрити: точне посимвольне співпадіння з зареєстрованими URI — цього вимагає RFC 9700. І жодних open redirect у застосунку.
Перевірка: запит на авторизацію з незареєстрованим URI має повернути помилку, а не редирект:
curl -i "https://auth.example.com/oauth2/authorize?response_type=code\
&client_id=web-app&redirect_uri=https://evil.example/cb\
&code_challenge=...&code_challenge_method=S256"
# очікую: 400 Bad Request, без заголовка Location
2. Відсутній state і PKCE: CSRF і code injection
Що це: зловмисник підсовує користувачу свій authorization code. Користувач опиняється залогіненим в акаунт зловмисника (login CSRF) — або вкрадений код обмінюється на токен у чужому клієнті (code injection).
Чому працює: без state клієнт не може довести, що відповідь прийшла на його запит. Без PKCE будь-хто з кодом може обміняти його на токен.
Як закрити: PKCE з методом S256 для всіх клієнтів, навіть конфіденційних. PKCE також закриває CSRF на redirect-ендпоінті; state залишаю як додатковий шар. У Spring Authorization Server: ClientSettings.builder().requireProofKey(true).
Перевірка: той самий запит, але без code_challenge — очікую помилку invalid_request.
3. Чому помер implicit flow
Що це: старий флоу для SPA, у якому access-токен повертався прямо в URL-фрагменті (#access_token=...).
Чому небезпечний: токен у URL — це історія браузера, Referer, розширення, а ще неможливість прив'язати токен до клієнта. Той самий ризик, що й у розділі про URL, тільки вбудований у протокол.
Як закрити: authorization code + PKCE для всіх клієнтів, включно з SPA. Spring Authorization Server implicit flow взагалі не підтримує.
4. Mix-up attack
Що це: клієнт працює з кількома серверами авторизації (наприклад, «Увійти через Google» і «через X»). Зловмисник контролює один із них і змушує клієнт відправити код від чесного сервера на свій token-ендпоінт.
Чому працює: клієнт не перевіряє, який сервер насправді надіслав відповідь.
Як закрити: параметр iss у відповіді авторизації (RFC 9207) або окремий redirect_uri для кожного провайдера. Якщо провайдер один — атака неактуальна, але знати про неї варто.
✅ Швидкий висновок: в OAuth головна поверхня атаки — редиректи. Точний
redirect_uri, PKCE для всіх і відмова від implicit flow закривають більшість реальних проблем.