Skip to content

Conversation

juanbailon
Copy link
Contributor

Hi,

This PR introduces a system based on the Token Family concept, which enables tracking the lineage of issued refresh tokens(and access tokens if enabled) and detecting improper reuse. If a reused refresh token is detected, the entire associated family is revoked. This approach effectively stops the attack, though it requires the legitimate user to re-authenticate.

In addition, optional caching support has been integrated to optimize the performance of blacklist checks for families and refresh tokens .

The motivation behind this implementation is outlined in this Auth0 blog post, which explains how a token family enhance security in the case of a refresh token reuse detection.

Furthermore, IETF RFC 9700, Section 4.14 highlights that refresh tokens are a valuable target for attackers, as they allow minting access tokens with full scope. Replay attacks using a stolen refresh token can result in unauthorized access. This implementation offers a mechanism to detect and respond to such replay attempts.

Beyond the replay attacks, the token family system also enables session-level tracking and revocation. Since each session corresponds to a unique token family, it's now possible to isolate and invalidate all tokens tied to a compromised session. This approach also touches on concerns raised in Issue #697.

This PR also includes a fix for Issue #911 .

To facilitate adoption and understanding, a preview of the updated documentation, detailing how to enable and configure these features, is available here: link.

Thank you for your time, and I hope you find this contribution useful for the library.

- Added  for handling blacklisted refresh tokens.
- Introduced  to indicate missing token family setup.
- Introduced TOKEN_FAMILY_ENABLED to toggle token family functionality.
- Added TOKEN_FAMILY_LIFETIME to define family expiration duration.
- Added TOKEN_FAMILY_CHECK_ON_ACCESS to include family claims in access tokens and perform validations for the token family on the access token.
- Added TOKEN_FAMILY_BLACKLIST_ON_REUSE to enable blacklisting of the token family if a refresh token reuse is detected.
- Defined TOKEN_FAMILY_CLAIM and TOKEN_FAMILY_EXPIRATION_CLAIM for token structure.
- Set TOKEN_FAMILY_BLACKLIST_SERIALIZER.
- Introduced FamilyMixin to manage token families.
- Made RefreshToken inherit from FamilyMixin to track family claims and support blacklisting entire token families.
- Updated access_token method in RefreshToken to optionally include family claims based on the setting TOKEN_FAMILY_CHECK_ON_ACCESS.
- Modified AccessToken.verify to validate token family status when TOKEN_FAMILY_CHECK_ON_ACCESS is enabled.
- Updated BlacklistMixin.check_blacklist to raise RefreshTokenBlacklistedError instead of TokenError for better error differentiation.
…sh token handling

- Added FamilyMixin checks to TokenVerifySerializer for family expiration and blacklist validation.
- Implemented TokenFamilyBlacklistSerializer to support blacklisting entire token families.
- Refactored TokenRefreshSerializer to use _get_refresh_token, ensuring proper handling of token family blacklisting on reuse.
… and add abstract workaround

- Renamed TokenFamilyBlacklist to BlacklistedTokenFamily to align with the existing naming convention used in the token_blacklist app (BlacklistedToken model).
- Updated all references to TokenFamilyBlacklist in the codebase to reflect the new model name.
- Added 'abstract' attribute to the token_family app models to match the pattern used in the token_blacklist models.
…ers, and models

- Added test_token_family.py with:
  - TestTokenFamilyModels
  - TestTokenFamilyInRefreshTokens
  - TestTokenFamilyInAccessTokens
  - TestFlushExpiredTokenFamiliesCommand
- Added tests for token family behavior in serializers and views
- Updated conftest.py, urls.py, and test_integration.py to support token family tests
- SJWT_CACHE_NAME: selects the Django cache alias
- CACHE_BLACKLISTED_REFRESH_TOKENS and CACHE_BLACKLISTED_FAMILIES: toggle caching
- TTL and key prefix settings added for each blacklist type
…lies

- Added cache check for blacklisted refresh tokens in TokenVerifySerializer
- Enhanced BlacklistMixin and FamilyMixin to support caching for blacklisted tokens and families.
- Added test_cache.py.
- Updated conftest.py and test_integration.py.
- Included token_family app in conf.py
- Added family_app.rst for token family details
- Documented related family settings in settings.rst
- Linked family_app in index.rst toctree
- Added cache_support.rst
- Updated 'index.rst' to include the new page in the toctree
- Documented related cache settings in settings.rst
…eturn values, and TokenVerifySerializer check. resolves jazzband#911
@juanbailon juanbailon force-pushed the feature/token-family-and-cache branch from 1a79ec7 to d691bc6 Compare October 1, 2025 20:49
Copy link
Member

@Andrew-Chen-Wang Andrew-Chen-Wang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a huge PR, and I wish the blacklist cache was split into two PRs so it could be more easily reviewable.

Please also generate the i18n locale. There is a script.

Comment on lines +13 to +14
old_name="TokenFamilyBlacklist",
new_name="BlacklistedTokenFamily",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this migration file be combined with the 0001

@@ -0,0 +1,4 @@
from django import VERSION

if VERSION < (3, 2):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't support <3.2

from tests.utils import override_api_settings


class TestBlacklistCache(TestCase):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BlacklistCache should be read-through such that, if there's a cache miss (e.g. due to eviction or a dev adds this, then they won't be confused), you should check the database, and if there's a hit on the database, then you can update the cache. We already know that the JWTs are validated in the serializer before all the I/O bound checks commence, so I believe the cache miss and database combo should be minimal.



# Singleton instance for centralized cache management
blacklist_cache = BlacklistCache()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the user wants to use their own BlacklistCache implementation? I don't think monkeypatching this file directly would work because it's already been imported in our library's files everywhere. Can we have a setting for the creation of the cache and use the simplejwt settings as a way to grab this singleton instance?

- Assigns a family expiration timestamp if `TOKEN_FAMILY_LIFETIME` is set.
- Saves the token family information in the database.
"""
token = super().for_user(user) # type: ignore
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are there so many type: ignore?

raise RefreshTokenBlacklistedError(_("Token is blacklisted"))

def blacklist(self) -> BlacklistedToken:
def blacklist(self) -> tuple[BlacklistedToken, bool]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see anywhere in this PR where this signature change is needed. This would technically be a breaking change.


return data

def _get_refresh_token(self, token_str: str) -> RefreshToken:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a breaking behavior change. We should avoid raising errors on instantiation for blacklist to be compatible with previous behavior. Why would this not happen in the token class itself?

return {}


class TokenFamilyBlacklistSerializer(serializers.Serializer):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we be able to combine this with TokenBlacklistSerializer

if not family_id:
user_id = token.get(api_settings.USER_ID_CLAIM)
logger.warning(
f"Token of user:{user_id} does not have a family_id. Skipping family blacklist check."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
f"Token of user:{user_id} does not have a family_id. Skipping family blacklist check."
f"Token of user: {user_id} does not have a family_id. Skipping family blacklist check."

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this log helpful? Shouldn't new tokens if a dev includes the token family app be migrating the tokens forward?

@Andrew-Chen-Wang
Copy link
Member

Andrew-Chen-Wang commented Oct 2, 2025

Attaching this PR from Ory that could be helpful as a reference: ory/hydra#2022 and ory/hydra#2383

Copy link
Member

@Andrew-Chen-Wang Andrew-Chen-Wang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm noticing how complicated this is becoming. Is the reason to create a second app instead of combining the token blacklist app with the family blacklist app because of the token blacklist tables?

So long as the user has the token blacklist app not installed and the setting is turned off, it should be fine to just migrate with those tables and not touch them since they take near 0 space in a database anyways. I feel like the functionalities are pretty similar, and having so many installed apps seems weird? I'd rather use settings to determine if the blacklist app is enabled and check a configuration if someone wants to use the token blacklist app rather than having 4 conditions, each app doing 2 conditions for checking installed apps and if the settings are enabled.

I'm also not noticing E2E tests of when both the token blacklist app and the token family app are both utilized. How does the blacklisting work when they're both in use?

Family app
===========

The **Token Family** system provides a way to group refresh and access tokens into logical families,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A reference to the original Auth0 post could be helpful

:doc:`/blacklist_app`.

By organizing tokens into families, you gain finer control over user sessions and potential compromises.
For example, when the settings ``BLACKLIST_AFTER_ROTATION`` and ``TOKEN_FAMILY_BLACKLIST_ON_REUSE`` are
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if you don't have the token blacklist app on?

which will delete any families from the token family list and family blacklist that have
expired. The command will not affect families that have ``None`` as their expiration.
You should set up a cron job on your server or hosting platform which
runs this command daily.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the recommended pattern for migrating to this new family token system? I noticed that we silently allow tokens without the family, but is the recommended approach to force invalidate all tokens without family?

I understand the code silently allows the tokens without the family claim, but it should be noted somewhere here in the docs

urlpatterns = [
...
path('api/token/family/blacklist/', TokenFamilyBlacklistView.as_view(), name='token_family_blacklist'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would we want to use a different view for token family blacklist? Shouldn't this simply replace the need for the Blacklist url itself?

@@ -1,11 +1,15 @@
from datetime import timedelta
from importlib import reload
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dangling import

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants