This tutorial covers usage of djangorestframework-simplejwt library to allow JWT authentication with separate frontend and two-factor authentication via email.

While you can use this tutorial for any Django project, it is recommended that you follow our Django Project Tutorial for beginners

Also, we don't cover docker-compose.yml file and some other stuff which you can find in the Django Project Tutorial so we can focus on implementing JWT Authentication.

Source repository: https://github.com/appliku/djangojwt2fa

In this article:

Install requirements

Make sure you have the following lines in requirements.txt:

Django==3.2.4
djangorestframework==3.12.4
djangorestframework-simplejwt==4.7.1
django-environ==0.4.5
django-storages==1.11.1
django-cors-headers==3.7.0
django-braces==1.14.0
django-extensions==3.1.3
psycopg2-binary==2.8.6
arrow==1.1.0
gunicorn==20.1.0
pytz==2021.1

Note: there can be other packages as well, these are directly required or recommended packages to have.

Django Settings

Add the following code to settings.py of your project:

# REST FRAMEWORK
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    )
}

Add the token_blacklist app to INSTALLED_APPS (or THIRD_PARTY_APPS if you use Djangito project template):

INSTALLED_APPS = (
    'rest_framework_simplejwt.token_blacklist',
}

This configures Django REST Framework to use JWTAuthentication backend.

In the project's urls.py (adjancent to settings.py) add the following imports and urls_patterns:

from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
)

urlpatterns = [
    path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]

Why we need these endpoits:

The token_obtain_pair is needed for "login" process, when user provides username and password and gets access_token and refresh_token. The token_refresh is needed when the access_token expires and with the refresh_token user gets new access_token. Although in our tutorial we'll make refresh_token also refreshed with proper settings.

Django SimpleJWT Settings

The library djangorestframework-simplejwt provides a configuration object, that you should put to project's settings.py. Here is example from their docs:

# Django project settings.py

from datetime import timedelta

...

SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
    'ROTATE_REFRESH_TOKENS': False,
    'BLACKLIST_AFTER_ROTATION': True,
    'UPDATE_LAST_LOGIN': False,

    'ALGORITHM': 'HS256',
    'SIGNING_KEY': settings.SECRET_KEY,
    'VERIFYING_KEY': None,
    'AUDIENCE': None,
    'ISSUER': None,

    'AUTH_HEADER_TYPES': ('Bearer',),
    'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
    'USER_ID_FIELD': 'id',
    'USER_ID_CLAIM': 'user_id',
    'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule',

    'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
    'TOKEN_TYPE_CLAIM': 'token_type',

    'JTI_CLAIM': 'jti',

    'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
    'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
    'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
}

We don't need to have them all in this object, we'll specify only several and the rest will be default.

SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=3),
    'SIGNING_KEY': env('SIMPLE_JWT_SIGNING_KEY', default=None) or SECRET_KEY,
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True
}

With these settings we'll have access_token valid for 30 minutes, refresh_token valid for 3 days. Singing key(the one used to issue tokens) will be specified via the dedicated environment variable or default to SECRET_KEY. Refresh tokens will be rotated when refresh endpoint is accessed. Old refresh token will be blacklisted on refresh.

Now let's apply run our app and apply migrations.

First let's run docker-compose up db -d in order for our db initialize.

Then run docker-compose run web python manage.py migrate to apply migrations.

Expected output of this command should be a lot of "Applying... " lines and no errors.

Let's create a superuser.

If you are not using Djangitos template then run docker-compose run web python manage.py createsuperuser command.

If you are using Djangitos template or followed our tutorials then you can use more convenient command docker-compose run web python manage.py makesuperuser that will generate superuser account and random password without asking any questions.

This will be expected output for this command:

Django Management Command Make Super User with random password non-interactive

Note: you will need this password, another run of the same command will not show password nor it wll create another user!

Now we can run the development server of our project:

docker-compose up

This will spin up all backing services and our application in web container.

These should be last lines of expected output of that command:

Docker-compose up web Django development server

Let's open admin panel of our Django Project in a browser. In order to do that open this URL: http://0.0.0.0:8060/admin/login/?next=/admin/

Type admin@example.com in the Email Address field and password from output of makesuperuser command.

Once you were able to login into the admin panel, let's make sure that we have the Blacklist app installed. On the main page of the admin panel you should see a block called "TOKEN BLACKLIST" and two models Blacklisted tokens and Outstanding tokens.

Django Simple JWT TOKEN BLACKLIST and two models Blacklisted tokens and Outstanding tokens

Testing Django SimpleJWT token_obtain_pair endpoint

Let's use CURL command to issue token pair.

Make sure to put replace the password from example with the password that was generated by makesuperuser command.

curl \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@example.com", "password": "F1i779jMXKus"}' \
  http://0.0.0.0:8060/api/token/

Output for this command should look like this:

{"refresh":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTYyMjg5NDY5NiwianRpIjoiYjEwOTdkMWI0ZDJlNDAxODg3MzViMDgxNjg1YzM4NjkiLCJ1c2VyX2lkIjoiYWIxNTI3MmQtMmRmNC00ODZhLTk0ODUtMWEwODk2OGM0MTVhIn0.1syTNRfrqqwdQlQjr9aBT35ZFeV1UkAWWIug309ubZc","access":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNjIyODA4NTk2LCJqdGkiOiJiNWY1MzNmOTBhMTI0NThlOGMwMTgzODUxOTJjZDZjZiIsInVzZXJfaWQiOiJhYjE1MjcyZC0yZGY0LTQ4NmEtOTQ4NS0xYTA4OTY4YzQxNWEifQ.3TbxEthvwqHuaFcgem3hCXvyhFeIgu2OI62a3Hsqjxk"}

As you can see the response from server contains the "refresh", with refresh token and "access" with access token.

Now let's try to refresh our tokens.

Replace refresh token with the one you got from the last command.

curl \
-X POST -H "Content-Type: application/json" \
-d '{"refresh": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTYyMjg5NDY5NiwianRpIjoiYjEwOTdkMWI0ZDJlNDAxODg3MzViMDgxNjg1YzM4NjkiLCJ1c2VyX2lkIjoiYWIxNTI3MmQtMmRmNC00ODZhLTk0ODUtMWEwODk2OGM0MTVhIn0.1syTNRfrqqwdQlQjr9aBT35ZFeV1UkAWWIug309ubZc"}' \
http://0.0.0.0:8060/api/token/refresh/

The output should be similar to previous one, again an object with "access" and "refresh" keys.

{"access":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNjIyODEwOTIxLCJqdGkiOiIyMDcyMTc5NmVmMjc0MjgxYTc3ZjI0MTM1ZWY3YjcyNiIsInVzZXJfaWQiOiJhYjE1MjcyZC0yZGY0LTQ4NmEtOTQ4NS0xYTA4OTY4YzQxNWEifQ.SjMVjCeLMhFV_QI20tCjo3gvJcpSkswAlrAUSJlJZFE","refresh":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTYyMzA2ODMyMSwianRpIjoiODczYTFlMjg5NmI3NDlhOGExNDYxYTk2MjQwYmQzMzAiLCJ1c2VyX2lkIjoiYWIxNTI3MmQtMmRmNC00ODZhLTk0ODUtMWEwODk2OGM0MTVhIn0.eNfz8oR2-beQ4SouGJUtRGlRxbAhUL38IYsuQiveowE"}

Now run the same command again and verify that old refresh token doesn't work anymore:

curl \
-X POST -H "Content-Type: application/json" \
-d '{"refresh": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTYyMjg5NDY5NiwianRpIjoiYjEwOTdkMWI0ZDJlNDAxODg3MzViMDgxNjg1YzM4NjkiLCJ1c2VyX2lkIjoiYWIxNTI3MmQtMmRmNC00ODZhLTk0ODUtMWEwODk2OGM0MTVhIn0.1syTNRfrqqwdQlQjr9aBT35ZFeV1UkAWWIug309ubZc"}' \
http://0.0.0.0:8060/api/token/refresh/
{"detail":"Token is blacklisted","code":"token_not_valid"}

It means that both blacklist app is installed and settings are in place to rotate and blacklist old refresh tokens.

You can go to the Django Admin and verify that refresh token is blacklisted.

Django Admin Blacklisted refresh token JWT Authentication

Django JWT Authentication Logout

Up until this point we putted all code in place in order to perform login scenario.

But now we should implement logout functionality.

Let's talk about it for a second so you can have better understanding of what should be done here.

With JWT authentication access_token is issued for a very short period of time and it is always valid until it expires.

To perform authentication the access_token is used, not refresh_token.

When access_token expires, client should use the refresh endpoint in order to get a fresh access_token. We also issue a new refresh_token so the authenticated "session" is extended. In other words, if user keeps using our app on a regular basis, they should always have a valid refresh_token and there be no need for login flow.

After new access_token is received it should be used for all API calls that require authentication.

Next access of refresh endpoint should use the latest refresh_token, because all the previous ones will be invalid.

Now what happens if a user wants to log out?

The client can forget both tokens. For example on frontend we could remove both access and refresh tokens from localStorage.

But if the refresh_token was stolen, then another client can keep using that token Indefinitely.

In order to prevent that let's make a logout API endpoint, which client will call to invlidate current refresh_token or all refresh_tokens assigned for current user.

Create Django Logout JWT APIView

If you are using Djangitos project template or followed our tutorial, then you should have an app usermodel. Let's put our logout view in this app. If you don't have this app, create one by running docker-compose run web python manage.py startapp usermodel and add it to INSTALLED_APPS in settings.py.

Open usermodel/views.py and add the following code:

from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.token_blacklist.models import OutstandingToken, BlacklistedToken
from rest_framework_simplejwt.tokens import RefreshToken


class APILogoutView(APIView):
    permission_classes = (IsAuthenticated,)

    def post(self, request, *args, **kwargs):
        if self.request.data.get('all'):
            token: OutstandingToken
            for token in OutstandingToken.objects.filter(user=request.user):
                _, _ = BlacklistedToken.objects.get_or_create(token=token)
            return Response({"status": "OK, goodbye, all refresh tokens blacklisted"})
        refresh_token = self.request.data.get('refresh_token')
        token = RefreshToken(token=refresh_token)
        token.blacklist()
        return Response({"status": "OK, goodbye"})

In our root urls.py (adjacent to settings.py) add a line for this view and an import, so the whole file look something like this:

from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
)

from usermodel.views import APILogoutView

urlpatterns = [
    path('sns/', include('ses_sns.urls')),
    path('admin/', admin.site.urls),
    path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('logout_token/', APILogoutView.as_view(), name='logout_token'),
]

Let's test it.

First let's go through login flow and obtain our tokens as we did before.

curl \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@example.com", "password": "F1i779jMXKus"}' \
  http://0.0.0.0:8060/api/token/

We'll get our tokens.

{"refresh":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTYyMzA3MDk3NywianRpIjoiZDA3MmEyMmQ3NjRlNDVmMGJlOTZlYWU0NGZlYjkyMTUiLCJ1c2VyX2lkIjoiYWIxNTI3MmQtMmRmNC00ODZhLTk0ODUtMWEwODk2OGM0MTVhIn0.j5GABClzwwikOZ3g1UzHx4vCCOGsGcqMPnXjkqrB7zc","access":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNjIyODEzNTc3LCJqdGkiOiI3N2M2MGFjZjkzMTY0YmEwYWVlOTk2OGFiNWQ4YjZlYyIsInVzZXJfaWQiOiJhYjE1MjcyZC0yZGY0LTQ4NmEtOTQ4NS0xYTA4OTY4YzQxNWEifQ._DR7jnevGgUYQ_gdoaflywCma2Z67h1qj_RzTiPnSc8"}

Let's invalidate our refresh_token with our new APILogoutView:

curl \
-X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNjIyODEzNTc3LCJqdGkiOiI3N2M2MGFjZjkzMTY0YmEwYWVlOTk2OGFiNWQ4YjZlYyIsInVzZXJfaWQiOiJhYjE1MjcyZC0yZGY0LTQ4NmEtOTQ4NS0xYTA4OTY4YzQxNWEifQ._DR7jnevGgUYQ_gdoaflywCma2Z67h1qj_RzTiPnSc8" \
-d '{"refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTYyMzA3MDk3NywianRpIjoiZDA3MmEyMmQ3NjRlNDVmMGJlOTZlYWU0NGZlYjkyMTUiLCJ1c2VyX2lkIjoiYWIxNTI3MmQtMmRmNC00ODZhLTk0ODUtMWEwODk2OGM0MTVhIn0.j5GABClzwwikOZ3g1UzHx4vCCOGsGcqMPnXjkqrB7zc"}' \
http://0.0.0.0:8060/logout_token/

Since this view requires authentication we should send the Authorization header which value should consist of "Bearer " and our access token. As post data we should send a JSON object with refresh_token.

In response we'll get {"status":"OK, goodbye"}.

Let's go to Django Admin and see that we have a blacklisted token http://0.0.0.0:8060/admin/token_blacklist/blacklistedtoken/

Pick the most recent record, click on it and click the edit button near the token dropdown. A pop-up window will open where you can see the actual token. Compare it to your refresh token, they should match.

Django Admin SimpleJWT Blacklisted refresh token

Django SimpleJWT blacklist all refresh_tokens for the current user

In your frontend interface you might want to give your users a way to logout all other devices/browsers that logged in with the same account.

In order to do that, instead of sending the refresh_token in the POST data, we can send a different object with the all key, like this:

curl \
-X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNjIyODEzNTc3LCJqdGkiOiI3N2M2MGFjZjkzMTY0YmEwYWVlOTk2OGFiNWQ4YjZlYyIsInVzZXJfaWQiOiJhYjE1MjcyZC0yZGY0LTQ4NmEtOTQ4NS0xYTA4OTY4YzQxNWEifQ._DR7jnevGgUYQ_gdoaflywCma2Z67h1qj_RzTiPnSc8" \
-d '{"all": "1"}' \
http://0.0.0.0:8060/logout_token/

The response will be

{"status":"OK, goodbye, all refresh tokens blacklisted"}

Since refresh tokens in SimpleJWT have the user foreign key we can find all keys that were issued for this user and blacklist them.

Pro tip: Similarly you might want to blacklist all refresh tokens in the password reset flow.