If you set BLACKLIST_AFTER_ROTATION = True and blacklisting does not work, this is the whole fix:

# settings.py
INSTALLED_APPS = [
    # ...
    "rest_framework_simplejwt.token_blacklist",
]
python manage.py migrate token_blacklist

Both steps are needed. Adding the app creates no tables on its own, and the tables are where the blacklist lives.

Everything below is tested against djangorestframework-simplejwt 5.5.1 on Django 5, with ROTATE_REFRESH_TOKENS and BLACKLIST_AFTER_ROTATION both set to True.

The error you get when you forget the migration

You added the app but you did not run the migration. Now login fails, not only refresh, with a 500 and this traceback ending:

django.db.utils.OperationalError: no such table: token_blacklist_outstandingtoken

On PostgreSQL the same problem reads:

django.db.utils.ProgrammingError: relation "token_blacklist_outstandingtoken" does not exist

The fix is the migration:

python manage.py migrate token_blacklist

The app ships two tables. token_blacklist_outstandingtoken holds every refresh token you gave out, and token_blacklist_blacklistedtoken holds the ones that are dead.

It surprises people that TokenObtainPairView breaks as well. Once the app is installed, RefreshToken.for_user() writes a row to the outstanding table for every token pair it creates, so the very first login hits the missing table. If you added the app and your login endpoint started returning 500, this is why, and the setting you changed is not at fault. The migration is.

The failure with no error message at all

This one is worse, because nothing in your logs tells you about it.

If rest_framework_simplejwt.token_blacklist is not in INSTALLED_APPS, your refresh endpoint keeps answering 200 OK. Rotation still gives out a new refresh token. But the old refresh token is never blacklisted, so it stays valid until it expires.

Here is the test, run three times with only the settings changed:

Configuration Login Old refresh token sent again
App not in INSTALLED_APPS 200 Accepted, 200. No error, no log line.
App added, migration not run 500 OperationalError never gets that far
App added and migrated 200 Rejected with 401 token_not_valid

The reason is in the source. In tokens.py the blacklist methods only exist when the app is installed:

class BlacklistMixin(Generic[T]):
    if "rest_framework_simplejwt.token_blacklist" in settings.INSTALLED_APPS:

        def verify(self, *args, **kwargs) -> None:
            self.check_blacklist()
            ...

And in serializers.py the call is wrapped in a bare except:

if api_settings.ROTATE_REFRESH_TOKENS:
    if api_settings.BLACKLIST_AFTER_ROTATION:
        try:
            refresh.blacklist()
        except AttributeError:
            # If blacklist app not installed, `blacklist` method will
            # not be present
            pass

So a missing app is not an error for SimpleJWT. It is a silent no-op. If you believe that logout kills a refresh token, and the app is not installed, then logout does nothing and every token you ever issued still works. Check this before you trust your logout endpoint.

Do you need rest_framework_simplejwt in INSTALLED_APPS?

No, and this is the part that confuses people, because the two names look almost the same.

  • rest_framework_simplejwt does not belong in INSTALLED_APPS. It has no models and no templates. You only put it in REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] and in your URLs.
  • rest_framework_simplejwt.token_blacklist does belong in INSTALLED_APPS, but only if you use blacklisting or logout.

A working minimum looks like this:

INSTALLED_APPS = [
    # ...
    "rest_framework",
    "rest_framework_simplejwt.token_blacklist",  # only for blacklist and logout
]

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": (
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ),
}

SIMPLE_JWT = {
    "ROTATE_REFRESH_TOKENS": True,
    "BLACKLIST_AFTER_ROTATION": True,
}

Adding the base package to INSTALLED_APPS does no damage, it simply does nothing.

What the official documentation says

The SimpleJWT settings documentation states the requirement in one line:

You need to add 'rest_framework_simplejwt.token_blacklist', to your INSTALLED_APPS in the settings file to use this setting.

Note what the sentence does not say. There is no check and no warning. In version 5.5.1 the string "requires" appears nowhere in the package for this setting, so do not wait for an error message to tell you the configuration is wrong.

UPDATE_LAST_LOGIN

A related setting people look for in the same file:

SIMPLE_JWT = {
    "UPDATE_LAST_LOGIN": True,
}

With True, the last_login column of your user table is written on every successful call to TokenObtainPairView. The default is False.

Two things to know before you turn it on. It costs one extra UPDATE per login, which matters on a busy login endpoint. And it only fires on TokenObtainPairView, not on token refresh, so last_login records the real login and not the token renewal.

How to be sure it works

Do not trust the settings file, test the behaviour. Get a token pair, refresh it once, then send the first refresh token a second time:

# 1. log in
curl -s -X POST http://127.0.0.1:8000/api/token/ \
  -H "Content-Type: application/json" \
  -d '{"username": "bob", "password": "secret"}'

# 2. rotate: send the refresh token from step 1
curl -s -X POST http://127.0.0.1:8000/api/token/refresh/ \
  -H "Content-Type: application/json" \
  -d '{"refresh": "<refresh-from-step-1>"}'

# 3. send the SAME refresh token from step 1 again
curl -s -X POST http://127.0.0.1:8000/api/token/refresh/ \
  -H "Content-Type: application/json" \
  -d '{"refresh": "<refresh-from-step-1>"}'

Step 3 must fail with a 401 and this body:

{"detail": "Token is blacklisted", "code": "token_not_valid"}

If step 3 returns a new access token, blacklisting is off, whatever your settings file says.

Deploying the change without breaking your users

The settings change is one line, so it is easy to forget that it needs a migration on every environment. Three things to plan.

Run the migration in the release step, not by hand. The refresh endpoint starts writing to the two new tables the moment the new code serves traffic. If the code is live and the tables are not there, every refresh returns a 500. Put python manage.py migrate in the release command so it runs before the new containers take traffic. On Appliku the release command runs at exactly that point in the deploy, so this ordering is the default. See the Django deploy guide.

Tokens you already gave out stay valid. Turning on blacklisting does not invalidate anything retroactively. Every refresh token in the wild keeps working until it expires or until it is rotated once. If you turned this on because you think a token leaked, you must clear the tokens yourself, because the switch alone changes nothing for tokens already issued.

The tables grow forever unless you clean them. Every refresh token you issue adds a row to token_blacklist_outstandingtoken, and that row stays after the token expires. On a mobile app that refreshes often, this is the table that quietly becomes the biggest one in your database. SimpleJWT ships a command for it:

python manage.py flushexpiredtokens

Run it on a schedule, once a day is plenty. On Appliku that is a cron job on the same app, which is part of the Growth plan and above. See pricing for what each plan includes.