JavaRush /Курси /Spring Security /Межі JWT: stale cla...

Межі JWT: stale claims і відкликання

Spring Security
Рівень 22 , Лекція 4
Відкрита

1. Payload видимий: JWT не ховає дані

Короткий TTL уже вирішує одну важливу проблему: викрадений токен і застарілі claims не живуть вічно. Але TTL не змінює базову природу JWT. Токен і далі несе читабельний payload і залишається знімком стану на момент видачі.

Важливо пам’ятати це від самого початку: JWT у нашому курсі — підписаний, а не зашифрований токен. Його payload лишається читабельним, бо сенс підпису не в тому, щоб приховати дані, а в тому, щоб захистити їх від непомітної підміни.

Тому правило просте: у токен кладуть лише мінімум, без якого запит не можна коректно обробити просто зараз. Усе, що стосується PII, профілю користувача або стану акаунта, який швидко змінюється, майже завжди краще тримати на сервері. Інакше один і той самий payload одночасно стає і надто видимим, і швидко застарілим.

2. Stale claims: токен живе в минулому

Саме тут з’являється термін stale claims — ситуація, коли всередині токена лежить інформація, яка колись була правильною, але вже не відповідає поточному стану системи.

Найнаочніший приклад у нашому проєкті — зміна ролей і стану акаунта.

Уявіть: користувач був EDITOR, отримав токен і пішов пити каву. А адміністратор тим часом зняв із нього роль EDITOR (наприклад, тому що користувач почав публікувати статті про те, як вимикати Spring Security «на проді, щоб не заважав»). Токен уже видано. І якщо в ньому лежить роль EDITOR, вона там і залишиться, доки токен не закінчиться.

Мікродемо «в лоб»:

import java.util.List;

public class StaleRolesDemo {
    public static void main(String[] args) {
        // Ролі «сфотографовано» в момент видачі токена
        List<String> rolesInToken = List.of("EDITOR");

        // Поточний стан у БД уже інший (роль могли зняти, акаунт могли заблокувати тощо)
        List<String> rolesNowInDb = List.of("USER");

        // Авторизація «за токеном»: якщо довіряємо claims, то доступ є
        boolean tokenAllowsPublish = rolesInToken.contains("EDITOR");

        // Авторизація «за поточним станом»: якщо звіряємося з БД, то доступу вже немає
        boolean currentStateAllowsPublish = rolesNowInDb.contains("EDITOR");

        System.out.println(tokenAllowsPublish);        // true
        System.out.println(currentStateAllowsPublish); // false
    }
}

У чистому вигляді це виглядає страшно, але є два заспокійливі моменти. Перший: TTL токена обмежує вікно ризику. Якщо токен живе 15 хвилин, то максимум 15 хвилин існує ризик, що права не встигнуть за змінами. Другий: ви не зобов’язані зберігати все в токені; для критичних перевірок іноді є сенс звірятися з поточним станом на сервері. Але тоді ви свідомо жертвуєте частиною ідеальної stateless-чистоти заради керованості.

Дуже корисно тримати в голові часову картинку. Її можна подати як простий таймлайн:

flowchart TD
    A["t0: видано JWT (roles=EDITOR)"] --> B["t1: ADMIN зняв роль EDITOR у БД"]
    B --> C["t2: користувач робить запит з тим самим JWT"]
    C --> D["t3: JWT закінчився (exp)"]

Що важливо: у точці t2 сервер ухвалюватиме рішення залежно від вибраної архітектури. Якщо сервер довіряє ролям із токена і більше нікуди не дивиться, то так — у вас є вікно stale claims. Якщо на критичних операціях сервер додатково перевіряє поточний стан, наприклад «акаунт заблоковано?», ризик зменшується, але stateless-модель уже не виглядає цілком stateless за духом.

І тут з’являється ще одна пастка для новачка: «А давайте тоді покладемо в токен усе, що важливо, щоб не ходити в базу!» Це зазвичай дає протилежний ефект: чим більше mutable-даних ви кладете, тим більше stale claims створюєте.

Наприклад, ви поклали displayName і bio у токен, щоб швидше відповідати на /api/me. Користувач оновив профіль, а токен лишився зі старим bio. У результаті /api/me починає повертати дані з минулого, і користувач думає, що система зламалася. А вона не зламалася — вона просто чесно показує те, що лежить усередині токена.

Маленька демонстрація того, чому «знімок профілю в токені» — погана ідея:

import java.util.Map;

public class ProfileSnapshotInTokenDemo {
    public static void main(String[] args) {
        // Те, що лежить у токені: «знімок» на момент входу
        Map<String, Object> profileInToken = Map.of(
                "displayName", "Anna Dev",
                "bio", "Old bio"
        );

        // Те, що реально зараз у системі (наприклад, користувач уже встиг відредагувати профіль)
        Map<String, Object> profileNow = Map.of(
                "displayName", "Anna",
                "bio", "New bio"
        );

        // Токен і далі нестиме старе значення до завершення exp
        System.out.println(profileInToken.get("bio")); // Old bio
        System.out.println(profileNow.get("bio"));     // New bio
    }
}

Якщо ви помітили, тут ще й безпека втручається: профіль може містити особисту інформацію, яку не варто переносити в кожному запиті у складі bearer token.

Головна думка розділу проста і навіть трохи філософська: JWT — це «замороження» частини контексту. Термін життя токена — це вікно, протягом якого ви погоджуєтеся жити з тим, що деякі дані в ньому можуть бути неідеальними. Тому TTL — це не «просто налаштування», а керований ризик stale claims.

3. Відкликання: межі stateless JWT

Тепер найболючіше. У світі session-based ми звикли: можна «вбити» сесію на сервері — і користувач розлогінений. У світі stateless JWT хочеться того самого: «адмін натиснув кнопку “заблокувати” — і все, токен помер». Але JWT так не працює.

Чому? Бо JWT перевіряється локально за підписом і часом життя. Якщо токен валідний за підписом і за exp, то сервер технічно не бачить причин не довіряти йому — якщо ви не додали окремий механізм, який каже: «цей токен або цей користувач уже відкликано».

І тут з’являється майже парадоксальна інженерна дилема: JWT обирають заради stateless-моделі, щоб не зберігати auth-state на сервері, але «миттєве відкликання» потребує зберігати стан, хоч би мінімальний: список відкликаних токенів, заблокованих користувачів або щось подібне.

Давайте уявимо це як схему ухвалення рішення під час запиту. Якщо сильно спростити, «чисто stateless» перевірка виглядає так:

flowchart TD
    A["Прийшов запит з JWT"] --> B["Перевірили підпис"]
    B --> C["Перевірили exp / nbf"]
    C --> D["Вважаємо токен чинним"]

Де тут «відкликання»? Його немає. І це не тому, що Spring «не вміє», а тому, що модель саме така: токен самодостатній.

Що з цього випливає для нашого проєкту? Є кілька практичних висновків, які новачкові особливо важливо проговорити вголос:

По-перше, у JWT-моделі logout у звичному сенсі часто перетворюється на «клієнт перестав використовувати токен». Це не так романтично, як «сервер видалив сесію», але зате чесно. Якщо токен вкрали, то натискання logout не скасовує того, що викрадений токен може продовжувати працювати до exp.

По-друге, якщо продуктові вимоги кажуть «потрібно миттєво відключати доступ» — наприклад, під час блокування користувача, компрометації акаунта, підозри на злам — то одного JWT без додаткового механізму буде мало. У такому разі система зазвичай іде на компроміс: або токени живуть дуже недовго, або сервер усе ж таки зберігає додатковий стан, наприклад про користувача чи конкретний токен. Суть у тому, що без такого стану вже виданий токен не можна миттєво визнати недійсним.

До речі, саме через це дуже небезпечно робити токени безстроковими. Якщо exp стоїть десь у далекому майбутньому, ви фактично підписуєтеся під тим, що revoke-логіка вам не потрібна, «бо в нас усе буде добре». А потім реальність, як завжди, приходить без попередження і без любові до ваших очікувань.

4. Owner-based доступ і JWT

Тепер повернімося до нашої улюбленої прикладної частини курсу: правила «свій / чужий». Ми вже знаємо, що в Secure Content Platform API користувач з роллю USER може читати й змінювати лише свої чернетки. І ось питання: чи може JWT вирішити це одним фактом «у користувача роль USER»? Звичайно, ні.

Бо «свій / чужий» — це правило, яке залежить від конкретного об’єкта. Токен може сказати «хто ви», але він не може заздалегідь сказати, яку саме чернетку ви зараз відкриваєте і кому вона належить. Якби ми спробували покласти в токен список усіх draftId, які належать користувачу, токен роздувся б до непристойних розмірів, застарів би під час першого ж створення або видалення чернетки й перетворився б на переносну проблему. Дуже зручну для перенесення, так.

Owner-based доступ у підсумку все одно зводиться до порівняння ідентифікаторів. У токені (або в поточному principal) ми маємо userId. У базі ми маємо draft.authorId. І далі проста логіка:

public class OwnerCheckDemo {
    public static void main(String[] args) {
        // Хто прийшов (беремо з auth-контексту / principal, а не «вигадуємо» вручну)
        long tokenUserId = 42L;

        // Кому належить чернетка (беремо з доменної моделі / БД)
        long draftOwnerUserId = 99L;

        // Owner-based правило в основі — це порівняння ідентифікаторів
        boolean sameOwner = tokenUserId == draftOwnerUserId;

        System.out.println(sameOwner);   // false
    }
}

Навіть якщо токен несе ролі та список прав, він усе одно не відповідає на питання володіння. Максимум він допомагає сказати: «користувач загалом має право на операції з чернетками як класом». А от «ця конкретна чернетка» — це вже рівень доменних даних.

І тут JWT знову показує свою межу: це не заміна бізнес-авторизації, а лише спосіб пронести мінімальний контекст, який допомагає відновити ідентичність і базовий контекст доступу. Для owner-based правил вам усе одно потрібно порівнювати userId з даними сутності. Це нормально, і це навіть добре: ви не перетворюєте токен на монстра, який намагається вмістити в себе весь світ.

У межах нашого проєкту це означає, що навіть якщо колись токен міститиме userId і roles, рішення на кшталт «чи може користувач видалити /api/drafts/{id}» усе одно мають враховувати, кому належить draft. JWT у цьому сценарії — лише «хто прийшов», а не «що саме йому можна з цим об’єктом».

5. Типові помилки під час роботи з межами JWT

Помилка № 1: вважати JWT «секретним контейнером» і складати всередину особисті дані.
Дуже легко переплутати «підписаний» і «зашифрований» токен. У результаті в payload опиняються e-mail, displayName, а іноді навіть шматки профілю. Потім токен витече в логах або під час налагодження клієнта — і ви самі створите канал витоку PII. У правильній моделі JWT — це переносний формат, а отже, вміст payload треба проєктувати так, ніби його можуть прочитати.

Помилка № 2: перетворювати токен на «знімок користувача», щоб не ходити в базу.
Спокуса зрозуміла: швидше відповідати на /api/me, швидше вирішувати авторизацію, швидше все на світі. Але чим більше mutable-полів ви кладете в токен, тим частіше ловите stale claims і дивні баги на кшталт «чому в мене старі дані». Якщо користувач змінює профіль, а токен живе ще 30 хвилин, ви самі створюєте розсинхронізацію.

Помилка № 3: робити TTL величезним «заради зручності», а потім дивуватися неможливості відкликання.
Довгий TTL створює ілюзію зручності, але на практиці збільшує вікно ризику під час компрометації токена і перетворює блокування або зміну ролей на повільний процес «якось колись само розсмокчеться». У stateless-моделі час життя токена — це частина контролю ризиків, а не просто «налаштування на смак».

Помилка № 4: очікувати, що «зняли роль — отже токен одразу перестав працювати».
Це класична помилка очікувань. JWT не переписується і не «перевидається» автоматично після зміни в базі. Якщо ви поклали роль у токен, то погодилися з тим, що вона житиме там до exp. Будь-який механізм миттєвої реакції потребує додаткового стану на сервері та окремого проєктного рішення.

Помилка № 5: думати, що ролі та права в токені вирішують owner-based доступ.
Правило «свій / чужий» не можна виразити однією роллю. Навіть якщо в токені є USER, усе одно треба порівнювати userId з auth-контексту з authorId у конкретного ContentItem. Інакше ви побудуєте систему, де будь-який USER може читати будь-який DRAFT, а це вже не secure content platform, а «платформа сюрпризів».

1
Опитування
JWT Основи, рівень 22, лекція 4
Недоступний
JWT Основи
Claims, підписи та перевірки
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ