JWT і OAuth 2.0: ризики безпеки

Оновлено:
JWT і OAuth 2.0: ризики безпеки

Більшість зламів 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: що є що

Ці три терміни постійно змішують, хоча вони з різних рівнів. Без цього розрізнення половина атак нижче виглядає плутано.

JWTOAuth 2.0OpenID Connect (OIDC)
Що цеФормат токенаПротокол делегування доступуШар аутентифікації поверх OAuth 2.0
Відповідає на питання«Як упакувати й підписати дані?»«Що цей застосунок може робити від імені користувача?»«Хто цей користувач?»
Що дає на виходіПідписаний рядок header.payload.signatureAccess token (+ refresh token)ID token (завжди JWT) + access token
СтандартRFC 7519RFC 6749 + RFC 9700OpenID 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 закривають більшість реальних проблем.

🔄 Refresh-токени: крадіжка, ротація, reuse detection

Що це: refresh-токен живе днями чи тижнями і дає право отримувати нові access-токени. Вкрадений refresh-токен — це доступ до акаунту на весь його термін.

Чому небезпечно: короткий exp access-токена нічого не дає, якщо зловмисник може безкінечно отримувати нові.

Як закрити:

  • Ротація: кожен обмін видає новий refresh-токен, а старий стає недійсним. У Spring Authorization Server: TokenSettings.builder().reuseRefreshTokens(false).
  • Reuse detection: якщо хтось пред'являє вже використаний refresh-токен — це сигнал крадіжки. Сервер відкликає всю сім'ю токенів, і легітимному користувачу доведеться залогінитися знову. Перевірте, чи ваш провайдер це вміє; якщо пишете свій auth-сервер — реалізуйте самі.
  • Прив'язка до клієнта (sender-constrained tokens): DPoP (RFC 9449) або mTLS — токен без ключа клієнта нічого не вартий. Складніше, але найнадійніше.
Ротація refresh-токенів і виявлення повторного використання Access-токен прострочений, API відповідає 401. Клієнт обмінює refresh-токен RT1 на нову пару токенів: сервер авторизації позначає RT1 використаним і видає access-токен та RT2. Якщо зловмисник пізніше пред'являє вкрадений RT1, сервер бачить повторне використання і відкликає всю сім'ю токенів, включно з RT2. Клієнт API Auth-сервер 1. Звичайне оновлення запит з access-токеном (прострочений) 401 Unauthorized POST /token grant_type=refresh_token + RT1 RT1 валідний → позначити як використаний новий access-токен + новий RT2 повтор запиту з новим access 200 OK 2. Хтось пред'являє старий RT1 зловмисник (вкрав RT1 раніше) POST /token + RT1 RT1 вже використаний! відкликати всю сім'ю токенів — RT2 теж стає недійсним 400 invalid_grant
Ротація refresh-токенів: повторне використання старого токена відкликає всю сім'ю

💡 Чому reuse detection працює: після крадіжки токен є у двох — у вас і в зловмисника. Хтось із вас скористається ним другим, і сервер це помітить. Він не знає, хто саме зловмисник, тому відкликає все — це і є захист.

✅ Швидкий висновок: refresh-токен без ротації — це довгоживучий пароль. Ротація + reuse detection перетворюють крадіжку на одноразову і помітну подію.

🪪 ID token ≠ access token: найпоширеніша плутанина в OIDC

Обидва часто є JWT, обидва приходять з одного сервера — і їх постійно плутають.

ID tokenAccess token
Для когоДля клієнта (aud = client_id)Для API (aud = ваш сервіс)
НавіщоСказати клієнту, хто залогінивсяДати доступ до ресурсів
Хто читаєКлієнтЛише API. Клієнт не повинен його розбирати — він може бути й непрозорим рядком
СтандартOpenID Connect CoreOAuth 2.0

Типові помилки:

  • Фронтенд надсилає в API ID token замість access token — «бо там же є email».
  • API приймає будь-який JWT від провайдера з валідним підписом, не перевіряючи aud.
  • Клієнт будує логіку прав на вмісті access-токена, який провайдер може будь-коли змінити.

Як закрити: API перевіряє aud — і ID token, у якому aud дорівнює client_id, відсіюється автоматично.

Перевірка:

@Test
void idTokenIsRejectedByApi() throws Exception {
    expect401(rs256(validClaims().audience("web-app").build())); // aud = client_id
}

📜 OAuth 2.1 і RFC 9700: що змінилось і що це означає для вас

RFC 9700 (січень 2025) — офіційний Best Current Practice з безпеки OAuth 2.0. OAuth 2.1 досі має статус чернетки, але він не додає нового — він збирає OAuth 2.0 і ці рекомендації в один документ. Тобто правила вже діють, незалежно від статусу 2.1.

ЩоБуло в OAuth 2.0Тепер (RFC 9700 / OAuth 2.1)
PKCEДля мобільних і публічних клієнтівРекомендований для всіх (RFC 9700), обов'язковий в OAuth 2.1; метод S256
redirect_uriЧасто за префіксом чи wildcardТочне співпадіння
Implicit flowРекомендований для SPAНе рекомендований (RFC 9700), прибраний в OAuth 2.1
Password grantДозволенийЗаборонений (RFC 9700), прибраний в OAuth 2.1
Refresh для публічних клієнтівБез вимогРотація або прив'язка до клієнта
Bearer-токен у query stringДозволений як варіант (RFC 6750)Прибраний в OAuth 2.1

💡 Що робити практично: якщо у вас є implicit flow або password grant — це план міграції на найближчий час. Якщо PKCE увімкнений лише для мобільних — увімкніть для всіх. Решта — перевірка налаштувань провайдера.

⚖️ Коли JWT не потрібен і краще звичайна сесія

Найнадійніший спосіб уникнути атак на JWT — не використовувати JWT там, де він не дає переваг. Після всіх атак вище я чесно кажу: у багатьох проєктах server-side сесія простіша і безпечніша.

Обирайте сесію, якщо:

  • ☑️ Один бекенд і браузерний клієнт — класичний сайт, адмінка, моноліт із серверним рендерингом. Головна перевага JWT (перевірка без спільного сховища) тут просто не потрібна.
  • ☑️ Потрібне миттєве відкликання — банкінг, адмінпанелі, «вийти на всіх пристроях», блокування користувача. Видалити сесію — один рядок; з JWT — чорні списки або версії токенів.
  • ☑️ Права часто змінюються. Сесія завжди читає актуальні дані, а JWT несе ролі, які могли застаріти.
  • ☑️ Немає зовнішніх клієнтів — мобільних застосунків, партнерських API, SSO.

JWT виправданий, якщо:

  • ☑️ токени перевіряють кілька незалежних сервісів;
  • ☑️ є мобільні чи сторонні клієнти;
  • ☑️ аутентифікацію робить зовнішній провайдер (Keycloak, Auth0, Google) через OAuth/OIDC.

💡 Простий тест: якщо ви все одно ходите в базу на кожен запит — перевірити чорний список, версію токена чи актуальні ролі, — ви отримали сесію з зайвими кроками і новими поверхнями атаки. У Spring Boot звичайна сесія — це поведінка Spring Security за замовчуванням, а для кількох інстансів достатньо Spring Session з Redis.

✅ Чекліст безпеки JWT + OAuth для продакшну

API (resource server):

  • ☑️ Дозволені алгоритми зафіксовані в конфігурації
  • ☑️ Ключі та URL JWKS — лише з конфігурації, заголовки jku/x5u ігноруються
  • ☑️ Перевіряються iss, aud, exp, nbf
  • ☑️ Токен приймається лише з заголовка Authorization
  • ☑️ Заголовок Authorization не потрапляє в логи

Сервер авторизації:

  • ☑️ Точне співпадіння redirect_uri
  • ☑️ PKCE (S256) обов'язковий для всіх клієнтів
  • ☑️ Implicit і password grant вимкнені
  • ☑️ Access-токен живе хвилини
  • ☑️ Ротація refresh-токенів + reuse detection
  • ☑️ Секрети HS256 ≥ 256 біт або асиметричні ключі; є план ротації ключів

Клієнт:

  • ☑️ Токени не в localStorage; для SPA розглянуто BFF
  • ☑️ Cookie: HttpOnly, Secure, SameSite + CSRF-захист
  • ☑️ CSP і Referrer-Policy налаштовані
  • ☑️ В API надсилається access token, а не ID token
  • ☑️ Жодних open redirect

🧪 Як я тестую свій API: мінімальний набір перевірок (Spring Boot)

Ідея проста: на кожну атаку з цієї статті — тест, який очікує 401. Якщо хтось колись «спростить» конфіг безпеки, тести впадуть раніше, ніж це помітить зловмисник.

Конфігурація декодера

@Bean
JwtDecoder jwtDecoder(@Value("${jwt.public-key}") RSAPublicKey publicKey) {
    NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey)
            .signatureAlgorithm(SignatureAlgorithm.RS256)   // лише RS256
            .build();

    decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
            JwtValidators.createDefaultWithIssuer("https://auth.example.com"), // exp, nbf, iss
            new JwtClaimValidator<List<String>>("aud",
                    aud -> aud != null && aud.contains("orders-api"))      // aud
    ));
    return decoder;
}

💡 createDefaultWithIssuer перевіряє exp, nbf (з допуском 60 секунд на розбіжність годинників) та iss. aud додаю окремим валідатором — саме його найчастіше забувають.

Тести

Тестова пара ключів лежить у src/test/resources, публічний ключ підставляється через властивість jwt.public-key. Токени створюю бібліотекою Nimbus JOSE + JWT — вона вже є в залежностях Resource Server.

@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = "jwt.public-key=classpath:test-key.pub")
class JwtSecurityTest {

    static final RSAPrivateKey PRIVATE_KEY = RsaKeyConverters.pkcs8()
            .convert(JwtSecurityTest.class.getResourceAsStream("/test-key.pem"));
    static final RSAPublicKey PUBLIC_KEY = RsaKeyConverters.x509()
            .convert(JwtSecurityTest.class.getResourceAsStream("/test-key.pub"));

    @Autowired MockMvc mvc;

    JWTClaimsSet.Builder validClaims() {
        return new JWTClaimsSet.Builder()
                .issuer("https://auth.example.com")
                .audience("orders-api")
                .subject("42")
                .expirationTime(Date.from(Instant.now().plusSeconds(300)));
    }

    String rs256(JWTClaimsSet claims) throws Exception {
        SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), claims);
        jwt.sign(new RSASSASigner(PRIVATE_KEY));
        return jwt.serialize();
    }

    void expect401(String token) throws Exception {
        mvc.perform(get("/api/me").header("Authorization", "Bearer " + token))
           .andExpect(status().isUnauthorized());
    }

    @Test // контрольний: валідний токен проходить
    void validTokenIsAccepted() throws Exception {
        mvc.perform(get("/api/me").header("Authorization", "Bearer " + rs256(validClaims().build())))
           .andExpect(status().isOk());
    }

    @Test
    void expiredTokenIsRejected() throws Exception {
        expect401(rs256(validClaims()
                .expirationTime(Date.from(Instant.now().minusSeconds(120))).build()));
    }

    // + тести з розділів вище: algNoneIsRejected, hs256SignedWithPublicKeyIsRejected,
    //   foreignJkuIsIgnored, foreignAudienceIsRejected, foreignIssuerIsRejected,
    //   tokenInQueryParamIsRejected, idTokenIsRejectedByApi
}

💡 Чому контрольний тест обов'язковий: без нього всі «очікую 401» можуть проходити з неправильної причини — наприклад, через зламаний ключ у тестах. Якщо валідний токен проходить, а підроблені ні — тести справді щось доводять.

💡 Протермінований токен я роблю на 120 секунд у минулому, а не на 1 секунду: інакше спрацює допуск у 60 секунд і тест буде нестабільним.

❓ Часті питання (FAQ)

🔍 Чи достатньо використати перевірену бібліотеку?

Ні. Бібліотека правильно перевіряє те, що ви їй сказали перевіряти. aud, дозволені алгоритми і джерело ключів — ваша конфігурація.

🔍 HS256 чи RS256?

HS256 — якщо токени створює і перевіряє один сервіс. RS256/ES256 — якщо перевіряють кілька сервісів або сторонні клієнти. Безпечніший той, що відповідає вашій архітектурі.

🔍 Чи потрібен state, якщо є PKCE?

RFC 9700 дозволяє покладатися на PKCE для захисту від CSRF, якщо сервер авторизації його підтримує. Я залишаю state як додатковий шар — він майже нічого не коштує.

🔍 Де зберігати токени у SPA?

Найкраще — ніде: патерн BFF тримає токени на сервері, а браузер має лише HttpOnly-cookie. Якщо BFF неможливий — HttpOnly-cookie з SameSite і CSRF-захистом, але не localStorage.

🔍 Чи можна перевіряти права за ID token?

Ні. ID token призначений клієнту, щоб дізнатися, хто залогінився. Для доступу до API — тільки access token з правильним aud.

✅ Висновки

  • 🎯 Криптографію в JWT не ламають — ламають довіру до токена. Сервер сам вирішує алгоритм, ключі, issuer і audience.
  • 🎯 OAuth ламають через редиректи. Точний redirect_uri + PKCE для всіх.
  • 🎯 Refresh-токен — головна ціль. Ротація і reuse detection обов'язкові.
  • 💡 Безпека, яку не перевірено тестом, — це припущення. Десяток тестів з очікуваним 401 захищають конфіг від «тимчасових спрощень».

Джерела: RFC 9700 — OAuth 2.0 Security BCP, RFC 8725 — JWT BCP, OAuth 2.1 (draft), RFC 7636 — PKCE, RFC 9207 — iss у відповіді авторизації, Spring Security — JWT Resource Server, PortSwigger — JWT attacks, OWASP JWT Cheat Sheet.