Na szybko ze dwie rzeczy przychodzą mi do głowy, które pewnie można by fajnie połączyć razem:
- Wprowadź jakiś identyfikator tokenów (globalnie unikatowy, jakiś UUID czy coś) do tego co wysyłasz w JWT. A potem lokalnie na każdym serwerze trzymaj listę unieważnionych UUIDów (cache unieważnionych tokenów). Przy restarcie serwera pobieraj listę unieważnionych UUIDów z serwera bazy danych. Do tego co jakiś czas (np. co minutę) możesz ten lokalny cache aktualizować (pull) / ew. jeśli masz możliwość, wprowadzić jakiś mechanizm typu push z bazy danych do wszystkich serwerów / ew. mix obu (best effort na push + aktualizacja co jakiś czas).
- Wprowadź maksymalny czas życia tokenom, np. na 30 minut. Jeśli token jest starszy niż te 30 minut, to robisz request sprawdzający do bazy, i jeśli wszystko jest OK, to odnawiasz tokenowi czas życia na kolejne 30 minut. Dzięki temu wprowadzasz dość małą liczbę dodatkowych requestów kosztem 30-minutowego okna w którym token mógł zostać unieważniony, ale nadal będzie działać.
A najlepiej połącz oba - to daje stosunkowo szybki czas unieważnienia sesji (minuta lub mniej), a dodatkowo daje możliwość czyszczenia starych wpisów na liście "unieważnionych tokenów" po 30 minutach od ich wrzucenia tam (ofc można to robić np. raz dziennie, zostawiając tylko wpisy unieważnionych tokenów młodsze niż 30 minut).
Konkretne czasy podane wyżej powinieneś dostosować do obciążenia jakie przewidujesz. Np. może czas życia tokenu powinien wynosić 60 minut, a czas aktualizacji lokalnego cache (pull) 5 minut, bo masz mało replik bazy danych i dużo żądań na sekundę.
Ach, i ważne: jeśli robisz listę unieważnionych tokenów, to koniecznie wprowadź UUID; jeśli po prostu będziesz tokeny albo hashe tokenów wrzucać na listę unieważnionych to to da się obejść w JWT z uwagi na pewne detale co do tego jak base64 działa.