Skip to content

Conversation

@mahdirahimi1999
Copy link
Member

Closes #927

Description

This pull request addresses an issue where refresh tokens remain valid even after a user changes their password, despite CHECK_REVOKE_TOKEN being enabled. This behavior can lead to infinite loops on client applications, where an expired access token prompts a token refresh, which succeeds, but the new access token is still invalid.

Changes

This PR introduces a password validation step into the TokenRefreshSerializer to ensure refresh tokens are properly invalidated when a user's password changes.

The key changes are:

  • The validate method in TokenRefreshSerializer now fetches the user associated with the refresh token.
  • If CHECK_REVOKE_TOKEN is True, it compares the password hash stored in the token's payload with the user's current password hash in the database.
  • If the hashes do not match, an AuthenticationFailed exception is raised with the new error code password_changed.
  • If the validation fails due to a password change, the offending refresh token is also blacklisted to prevent any further use.
  • The logic for handling deleted users within the serializer is also made more robust by catching User.DoesNotExist and raising a consistent AuthenticationFailed error.

Testing

  • Added new tests to verify that a refresh token is rejected after a password change.
  • Added a test to ensure the token is correctly blacklisted upon failure.
  • Confirmed that all existing tests in the suite continue to pass.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This pull request enhances the JWT authentication system to properly revoke refresh tokens when a user's password changes. The implementation adds password validation to the token refresh process to prevent infinite loops in client applications caused by tokens remaining valid after password changes.

  • Adds password hash validation to TokenRefreshSerializer when CHECK_REVOKE_TOKEN is enabled
  • Implements automatic token blacklisting when password changes are detected
  • Improves error handling for deleted users by raising consistent AuthenticationFailed exceptions

Reviewed Changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
rest_framework_simplejwt/serializers.py Adds password validation logic and token blacklisting in TokenRefreshSerializer
tests/test_serializers.py Adds comprehensive tests for password change scenarios and updates existing deleted user test

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

@kiraware
Copy link
Member

@sshishov could you please try using simplejwt by installing from this PR and check that your problem solved correctly?

@mahdirahimi1999
Copy link
Member Author

@sshishov could you please try using simplejwt by installing from this PR and check that your problem solved correctly?

The new automated tests included in this PR already cover the exact scenario reported by @sshishov.

The test_refresh_token_should_fail_after_password_change test specifically confirms that attempting to refresh the token after a password change now correctly fails with an AuthenticationFailed (password_changed) exception.

Furthermore, the test_refresh_token_should_blacklist_after_password_change test ensures the invalid token is also blacklisted as expected when that feature is enabled.

These tests confirm the fix is working as intended.

@kiraware
Copy link
Member

I see error message pattern in this line

default_error_messages = {
"no_active_account": _("No active account found for the given token.")
}
and
except get_user_model().DoesNotExist:
# This handles the case where the user has been deleted.
raise AuthenticationFailed(
self.error_messages["no_active_account"], "no_active_account"
)

I think you should add the error message to the dict and follow existing pattern on this code

raise AuthenticationFailed(
_("The user's password has been changed."),
code="password_changed",
)

@mahdirahimi1999
Copy link
Member Author

mahdirahimi1999 commented Aug 14, 2025

I see error message pattern in this line

default_error_messages = {
"no_active_account": _("No active account found for the given token.")
}

and

except get_user_model().DoesNotExist:
# This handles the case where the user has been deleted.
raise AuthenticationFailed(
self.error_messages["no_active_account"], "no_active_account"
)

I think you should add the error message to the dict and follow existing pattern on this code

raise AuthenticationFailed(
_("The user's password has been changed."),
code="password_changed",
)

I fix it.

@kiraware
Copy link
Member

kiraware commented Aug 14, 2025

I think you should revert the code that you changed in authentication.py because i mean only update the code in serializers.py. Also i only see that pattern in serializers.py, and it will make this PR more specific

@mahdirahimi1999
Copy link
Member Author

I think you should revert the code that you changed in authentication.py because i mean only update the code in serializers.py. Also i only see that pattern in serializers.py, and it will make this PR more specific

For context: implementing this fix required duplicating the password_changed error message in both JWTAuthentication and TokenRefreshSerializer.

There may be an opportunity here for a more centralized error message pattern to improve consistency.

@kiraware
Copy link
Member

Hmm okay. It looks good to me. But i will leave to @Andrew-Chen-Wang because he is the maintainer

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.

Thanks for the PR!

I believe TokenRefreshSlidingSerializer also requires this logic if I'm not mistaken?

default_error_messages = {
"no_active_account": _("No active account found for the given token.")
"no_active_account": _("No active account found for the given token."),
"password_changed": _("The user's password has been changed."),
Copy link
Member

Choose a reason for hiding this comment

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

re-run the locale generation since this is a new gettext (i.e. the line where the gettext is written has moved)

Copy link
Member Author

Choose a reason for hiding this comment

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

I noticed that the project uses a custom script (scripts/i18n_updater.py) for locale updates, likely to avoid unnecessary changes in translation files. Since this is a multi-language project, I won't run makemessages or compilemessages directly. Please let me know if you prefer me to run the custom script or if locale updates are handled separately by maintainers.

Copy link
Member Author

@mahdirahimi1999 mahdirahimi1999 Aug 14, 2025

Choose a reason for hiding this comment

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

Update: I ran the i18n_updater.py script, but it generated no changes.

This is because the The user's password has been changed. string already exists in the translation catalog from a previous PR (originating from authentication.py:146), so makemessages correctly finds no new strings to add. No further action should be needed.

Comment on lines 121 to 126
try:
user = get_user_model().objects.get(
**{api_settings.USER_ID_FIELD: user_id}
)
except get_user_model().DoesNotExist:
# This handles the case where the user has been deleted.
raise AuthenticationFailed(
self.error_messages["no_active_account"], "no_active_account"
)
Copy link
Member

Choose a reason for hiding this comment

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

raising AuthenticationFailed is a breaking change. What is the problem with DoesNotExist error?

Copy link
Member Author

Choose a reason for hiding this comment

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

Great question! Here's why I think 401 is better than 404:
Security-wise: Using 401 instead of 404 prevents attackers from figuring out if a token exists in our system. If we return 404, they know the token doesn't exist, which is information we shouldn't leak.
Standards-wise: RFC 7235 says authentication errors should be 401, not 404. This keeps us consistent with web standards.
User experience: When users get a 401, they know it's an authentication problem and can try logging in again. A 404 might confuse them into thinking the API endpoint doesn't exist.
Code-wise: DoesNotExist is a database error that shouldn't bubble up to users. AuthenticationFailed is the right error for auth issues.

Copy link
Member

Choose a reason for hiding this comment

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

Whilst I agree that 401 is more secure, that's only in the context of the user not being logged in. This serializer requires accepting a refresh token.

I agree on the rest. Approve of the change. Can you add this to the CHANGELOG.md?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'll apply it right away.

On a related note, I think there might be a pre-existing issue with how AuthenticationFailed is handled here. I'll explain in a follow-up comment.

Copy link
Member Author

@mahdirahimi1999 mahdirahimi1999 Aug 15, 2025

Choose a reason for hiding this comment

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

Following up on my previous comment, I'd like to discuss a pre-existing issue regarding how AuthenticationFailed exceptions are handled, which can lead to an unexpected 403 Forbidden response instead of a 401 Unauthorized.

The Root Cause in Django REST Framework

As per the DRF documentation on authentication responses, the framework distinguishes between 401 and 403 responses for unauthenticated requests. A 401 response must include a WWW-Authenticate header, while a 403 response does not.
The logic that enforces this is in DRF's APIView.handle_exception method:

# In rest_framework.views.APIView

def handle_exception(self, exc):
    if isinstance(exc, (exceptions.NotAuthenticated,
                        exceptions.AuthenticationFailed)):
        # WWW-Authenticate header for 401 responses, else coerce to 403
        auth_header = self.get_authenticate_header(self.request)

        if auth_header:
            exc.auth_header = auth_header
        else:
            exc.status_code = status.HTTP_403_FORBIDDEN
    
    # ...

This code explicitly changes the status code to 403 FORBIDDEN if an AuthenticationFailed exception is caught and get_authenticate_header() returns a "falsy" value (which happens if no Authorization header was provided in the request).

The Impact on simplejwt

When we raise AuthenticationFailed in our serializers (for cases like a deleted user or a password change), we correctly signal an authentication failure. However, if the client makes this request without an Authorization header, DRF's logic will convert our intended 401 into a 403.
This behavior is inconsistent and can be problematic:

  • Security: A 401 response is more appropriate as it does not leak information about the existence of a resource.
  • Standards (RFC 7235): 401 is the standard response for authentication-related failures.
  • User Experience: A 401 clearly tells the client to re-authenticate. A 403 is ambiguous and could be misinterpreted as a permanent permission issue.

Lack of Test Coverage

This specific scenario is not currently covered by the project's test suite. We could add a test like the following to demonstrate this behavior:

# In tests/test_integration.py

def test_it_should_return_403_if_authenticate_header_is_not_set(self):
    """
    Tests that a 403 Forbidden is returned if authentication fails
    and the `authenticate_header` method returns no value.
    """
    # We mock the `authenticate_header` method to return None. This
    # triggers the specific code path in DRF's exception handler
    # that converts a 401 Unauthorized to a 403 Forbidden.
    from unittest.mock import patch
    
    with patch(
        "rest_framework_simplejwt.authentication.JWTAuthentication.authenticate_header",
        return_value=None,
    ):
        self.authenticate_with_token(api_settings.AUTH_HEADER_TYPES[0], "an_invalid_token_string")

        res = self.view_get()

        # We now expect a 403 Forbidden response instead of a 401.
        self.assertEqual(res.status_code, 403)
        self.assertEqual(res.data["detail"].code, "token_not_valid")

Potential Solution

To ensure a consistent 401 response in all authentication failure scenarios, one solution could be to use a custom exception class that bypasses DRF's status code coercion, something like this:

from rest_framework.exceptions import APIException

class CustomNotAuthenticated(APIException):
    status_code = status.HTTP_401_UNAUTHORIZED
    default_detail = _('Authentication credentials were not provided.')
    default_code = 'not_authenticated'

I wanted to bring this to your attention as it seems to be a broader architectural point. I'm happy to discuss this further.

Copy link
Member Author

Choose a reason for hiding this comment

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

Let’s continue the 401 vs 403 discussion in a separate issue.

For this PR, could you give your final feedback? If there’s any problem with the current changes, I’ll update them.

Copy link
Member

Choose a reason for hiding this comment

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

Take a look at these issues #871, #860 and this PR #861. Looks like it is related to this problem

Copy link
Member

Choose a reason for hiding this comment

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

Thanks for linking kiraware and I've just closed all those issues. I've just merged #861 to fix #860 re these comments so we should update this PR to be more narrowly focused on the given problem at hand.

Once mahdirahimi1999 rebases, happy to approve!

Copy link
Member Author

Choose a reason for hiding this comment

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

I’ve done the rebase, this PR is now updated @Andrew-Chen-Wang

Copy link
Member Author

Choose a reason for hiding this comment

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

@Andrew-Chen-Wang
Could you please give me some feedback and share your thoughts on the latest change?

@mahdirahimi1999
Copy link
Member Author

Thanks for the PR!

I believe TokenRefreshSlidingSerializer also requires this logic if I'm not mistaken?

Added user validation logic to TokenRefreshSlidingSerializer.

@kiraware
Copy link
Member

kiraware commented Aug 15, 2025

@mahdirahimi1999 perhaps renaming token_hash to token_password_hash and user_hash to user_password_hash will make the code clearer. Based on the docs here https://django-rest-framework-simplejwt.readthedocs.io/en/latest/settings.html#revoke-token-claim

token_hash = refresh.payload.get(api_settings.REVOKE_TOKEN_CLAIM)
user_hash = get_md5_hash_password(user.password)
if token_hash != user_hash:
and
token_hash = token.payload.get(api_settings.REVOKE_TOKEN_CLAIM)
user_hash = get_md5_hash_password(user.password)
if token_hash != user_hash:

Just a matter of name though

@mahdirahimi1999
Copy link
Member Author

@mahdirahimi1999 perhaps renaming token_hash to token_password_hash and user_hash to user_password_hash will make the code clearer. Based on the docs here https://django-rest-framework-simplejwt.readthedocs.io/en/latest/settings.html#revoke-token-claim

token_hash = refresh.payload.get(api_settings.REVOKE_TOKEN_CLAIM)
user_hash = get_md5_hash_password(user.password)
if token_hash != user_hash:

and

token_hash = token.payload.get(api_settings.REVOKE_TOKEN_CLAIM)
user_hash = get_md5_hash_password(user.password)
if token_hash != user_hash:

Just a matter of name though

Fix it.

@mahdirahimi1999 mahdirahimi1999 requested review from pauloxnet and removed request for ajay09 and pauloxnet August 15, 2025 16:33
@mahdirahimi1999 mahdirahimi1999 force-pushed the fix-refresh-token-revocation branch from 88eff03 to ea764f1 Compare August 18, 2025 08:04
Comment on lines -116 to +131
user = (
get_user_model()
.objects.filter(**{api_settings.USER_ID_FIELD: user_id})
.first()
)
if not user or not api_settings.USER_AUTHENTICATION_RULE(user):
try:
user = get_user_model().objects.get(
**{api_settings.USER_ID_FIELD: user_id}
)
except get_user_model().DoesNotExist:
# This handles the case where the user has been deleted.
raise AuthenticationFailed(
self.error_messages["no_active_account"],
"no_active_account",
self.error_messages["no_active_account"], "no_active_account"
)

data = {"access": str(refresh.access_token)}
if not api_settings.USER_AUTHENTICATION_RULE(user):
raise AuthenticationFailed(
self.error_messages["no_active_account"], "no_active_account"
)
Copy link
Member

@Andrew-Chen-Wang Andrew-Chen-Wang Aug 21, 2025

Choose a reason for hiding this comment

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

I think your changes are the try/except; please ensure it uses the code from #861 to be more DRY

Copy link
Member Author

@mahdirahimi1999 mahdirahimi1999 Aug 21, 2025

Choose a reason for hiding this comment

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

@Andrew-Chen-Wang

Hi Andrew,

Thanks for the feedback. I considered the suggestion to use .filter().first(), but in this case .get() is more appropriate. Since we're querying by primary key, .get() is both the most direct and idiomatic Django approach—it clearly expects a single unique record and raises DoesNotExist if it's missing.

In contrast, .filter().first() builds a queryset, allows multiple matches, and is less explicit. So this isn't about code repetition, but about choosing the most suitable and efficient method for a primary key lookup.

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.

thanks for this and all changes!

@Andrew-Chen-Wang Andrew-Chen-Wang merged commit 4d6e4fd into jazzband:master Aug 21, 2025
34 checks passed
@mahdirahimi1999
Copy link
Member Author

thanks for this and all changes!

You're welcome, glad I could help

@mahdirahimi1999
Copy link
Member Author

@Andrew-Chen-Wang
Could you please also take a look at my PR #918 with localization & i18n fixes and tests.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refresh token should be revoked on password change if CHECK_REVOKE_TOKEN is used

3 participants