By AndyPublished
Django REST Framework JWT Authentication with Simple JWT: Setup, Refresh and Logout
djangorestframework-simplejwt. AddJWTAuthentication to DEFAULT_AUTHENTICATION_CLASSES, wire upTokenObtainPairView and TokenRefreshView, and configure lifetimes in aSIMPLE_JWT dict. For logout, install the token_blacklist app, turn on refresh rotation and expose TokenBlacklistView. The older djangorestframework-jwtpackage is unmaintained. Everything here was tested with Django 6.1.1, Django REST Framework 3.18.1, Simple JWT 5.5.1 and PyJWT 2.15.0 using DRF's APITestCase.djangorestframework-jwt vs Simple JWT
Searches for "djangorestframework jwt" still surface djangorestframework-jwt(rest_framework_jwt, JSONWebTokenAuthentication,obtain_jwt_token). Its last PyPI release was 1.11.0 in June 2017, and it predates current Django and DRF versions. Do not start new projects on it. djangorestframework-simplejwt, maintained under the Jazzband organisation, is the package DRF's own documentation points to for JWT, and the one this guide uses. If you are migrating, the concepts map directly, but settings, URLs and the token format differ, so every existing token is invalidated at switchover.
JWT Django Install
pip install djangorestframework djangorestframework-simplejwt # only needed for RS256/ES256 signing or JWKS verification: pip install "djangorestframework-simplejwt[crypto]"
Simple JWT 5.5.1's package metadata lists Django 4.2 to 5.2; the full flow below also passed on Django 6.1.1, but check the changelog before upgrading Django in production.
Configuring settings.py
# settings.py
import os
from datetime import timedelta
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
INSTALLED_APPS = [
# ... django.contrib apps ...
"rest_framework",
"rest_framework_simplejwt.token_blacklist", # for logout and rotation
"api",
]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework_simplejwt.authentication.JWTAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": (
"rest_framework.permissions.IsAuthenticated",
),
}
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
"UPDATE_LAST_LOGIN": True,
"ALGORITHM": "HS256",
"SIGNING_KEY": os.environ["JWT_SIGNING_KEY"],
"ISSUER": "https://api.example.com",
"AUDIENCE": "https://api.example.com",
"LEEWAY": 30,
"AUTH_HEADER_TYPES": ("Bearer",),
}Run python manage.py migrate afterwards to create the OutstandingTokenand BlacklistedToken tables. The settings that matter most:
| SIMPLE_JWT key | Default (5.5.1) | Recommended |
|---|---|---|
| ACCESS_TOKEN_LIFETIME | timedelta(minutes=5) | 5 to 15 minutes |
| REFRESH_TOKEN_LIFETIME | timedelta(days=1) | Days to a few weeks, depending on your risk tolerance |
| ROTATE_REFRESH_TOKENS | False | True: every refresh returns a new refresh token |
| BLACKLIST_AFTER_ROTATION | False | True: the old refresh token stops working (needs token_blacklist) |
| ALGORITHM | HS256 | HS256 for one service; RS256/ES256 if other services verify |
| SIGNING_KEY | settings.SECRET_KEY | A separate key from the environment |
| ISSUER / AUDIENCE | None (not checked) | Set both; they are then added on issue and checked on verify |
| LEEWAY | 0 | 30 seconds of clock skew tolerance |
| CHECK_REVOKE_TOKEN | False | True to invalidate tokens when the user changes password |
SECRET_KEY. That key also protects sessions, password reset links and signed cookies, so a leak of one is a leak of all. A dedicated SIGNING_KEY lets you rotate JWT signing without logging everyone out of the admin. Generate it with 32 or more random bytes; see the JWT secret key generator guide.ISSUER and AUDIENCE default to None, which means neither claim is added or checked. Setting them costs nothing and stops tokens from another environment or service being accepted. The ALGORITHM setting doubles as the verification allow-list: only that algorithm is accepted on decode. Background on both claims is in JWT claims explained.
If other services need to verify Django-issued tokens, switch to an asymmetric algorithm so they only ever hold a public key: set ALGORITHM to RS256 or ES256, put the PEM private key in SIGNING_KEY and the public key in VERIFYING_KEY, and install the [crypto] extra. Going the other way, when Django verifies tokens issued by an external identity provider, JWK_URL points Simple JWT at the provider's JWKS endpoint. The choice between the two families is explained in HS256 vs RS256.
Token URLs: Obtain, Refresh and Blacklist
# urls.py
from django.contrib import admin
from django.urls import path
from rest_framework_simplejwt.views import (
TokenBlacklistView,
TokenObtainPairView,
TokenRefreshView,
)
from api.views import me
urlpatterns = [
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("api/token/blacklist/", TokenBlacklistView.as_view(), name="token_blacklist"),
path("api/me/", me),
]# api/views.py
from rest_framework.decorators import api_view
from rest_framework.response import Response
@api_view(["GET"])
def me(request):
# request.user is a real User loaded from the token's user_id claim
return Response({"id": request.user.id, "username": request.user.username})Getting and Using a Django JWT Token
curl -s -X POST localhost:8000/api/token/ \
-H 'content-type: application/json' \
-d '{"username":"ada","password":"correct horse"}'
# {"refresh":"eyJhbGciOiJIUzI1NiIs...","access":"eyJhbGciOiJIUzI1NiIs..."}
curl -s localhost:8000/api/me/ -H "Authorization: Bearer $ACCESS"
# {"id":1,"username":"ada"}Decoded, the access token payload contains token_type: "access", exp,iat, a jti, user_id (a string, such as"1", in 5.5.1) and the iss and aud values from settings. You can check this by pasting a token into the jwtdecode.app decoder, which runs locally in the browser. The token_type claim matters: presenting a refresh token as a Bearer token is rejected with Token has wrong type.
Failures observed in testing: bad credentials return 401 with No active account found with the given credentials; no header returns 401 Authentication credentials were not provided.with WWW-Authenticate: Bearer realm="api"; a tampered token returns 401 withcode: "token_not_valid". If a token that looks fine is rejected, work through how to debug a JWT.
JWT authentication in Django views and permissions
Because JWTAuthentication is in DEFAULT_AUTHENTICATION_CLASSES andIsAuthenticated is the default permission, every DRF view requires a valid access token unless it says otherwise. The token views themselves set permission_classes = (), so login still works. For public endpoints, set permission_classes = [AllowAny] on the view; for staff-only endpoints, useIsAdminUser. Authorisation is still read from the database: JWTAuthenticationloads the User named by user_id on every request and rejects inactive users, so deactivating an account takes effect immediately even though the token is still valid. If you want to skip that query,JWTStatelessUserAuthentication builds a TokenUser from the claims alone, at the cost of that immediate check. Plain Django views (not DRF) do not run DRF authentication classes at all; protect them with sessions, or move them to DRF.
Adding custom claims
# api/serializers.py
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
class MyTokenObtainPairSerializer(TokenObtainPairSerializer):
@classmethod
def get_token(cls, user):
token = super().get_token(user)
token["username"] = user.username # public data only; the payload is not encrypted
token["is_staff"] = user.is_staff
return token
# settings.py -> SIMPLE_JWT["TOKEN_OBTAIN_SERIALIZER"] = "api.serializers.MyTokenObtainPairSerializer"Refresh Token Rotation
POST /api/token/refresh/ with {"refresh": "..."} returns a new access token. With ROTATE_REFRESH_TOKENS it also returns a new refresh token, and withBLACKLIST_AFTER_ROTATION the old one is blacklisted. In testing, reusing a rotated refresh token returned 401 Token is blacklisted. That limits the value of a stolen refresh token, but note that Simple JWT does not revoke the whole token family on reuse; the refresh token pattern explains reuse detection if you need it.
JWT Logout in Django
Logout means blacklisting the refresh token: the client posts it to TokenBlacklistView, which returns 200 with an empty body. Any later refresh attempt with it fails with Token is blacklisted. The client then discards both tokens.
curl -s -X POST localhost:8000/api/token/blacklist/ \
-H 'content-type: application/json' -d "{\"refresh\":\"$REFRESH\"}"/api/me/ still returned 200 with the old access token after logout. That is how stateless JWTs behave, and it is whyACCESS_TOKEN_LIFETIME should stay short. For immediate revocation you need a server-side check on each request, such as a jti deny-list; the options are compared in JWT logout and revocation.Two more tools help. CHECK_REVOKE_TOKEN: True embeds a claim derived from the user's password hash, so changing the password invalidates every token that user holds. And the outstanding-token table grows with every login, so schedule python manage.py flushexpiredtokens (for example, daily via cron) to delete expired rows.
JWT with Django and React
A React single-page app talking to DRF usually works like this: log in with POST /api/token/, keep the access token in memory (a React context or state store, not localStorage), attach it asAuthorization: Bearer on each request, and on a 401 call the refresh endpoint once and retry. An HTTP client interceptor (Axios or a fetch wrapper) is the natural place for that retry logic.
The refresh token is the harder part. Simple JWT returns it in the JSON body; it does not set cookies for you. To keep it out of JavaScript, subclass the obtain and refresh views so they move the refresh token into an HttpOnly,Secure, SameSite cookie scoped to the refresh path, and read it from there on refresh. If the front end runs on a different origin, you will also need django-cors-headerswith an explicit allowed origin and credentials enabled. The storage trade-offs are covered in JWT storage: localStorage vs cookie.
JWT with Django Ninja
Django Ninja does not ship JWT support, but django-ninja-jwt, a port of Simple JWT built ondjango-ninja-extra, provides the same obtain, refresh and verify endpoints. Tested with django-ninja 1.7.1, django-ninja-extra 0.31.7 and django-ninja-jwt 5.4.5:
# pip install django-ninja-jwt (add "ninja_extra" and "ninja_jwt" to INSTALLED_APPS)
from ninja_extra import NinjaExtraAPI
from ninja_jwt.authentication import JWTAuth
from ninja_jwt.controller import NinjaJWTDefaultController
api = NinjaExtraAPI()
api.register_controllers(NinjaJWTDefaultController) # /token/pair, /token/refresh, /token/verify
@api.get("/me", auth=JWTAuth())
def me(request):
return {"id": request.user.id, "username": request.user.username}Configuration lives in a NINJA_JWT dict with the same key names as SIMPLE_JWT, so set ACCESS_TOKEN_LIFETIME, SIGNING_KEY, ISSUERand AUDIENCE there. A request without a token returned 401 {"detail": "Unauthorized"}. If you would rather not add the dependency, Django Ninja's HttpBearer class lets you write anauthenticate(request, token) method that calls PyJWT directly, as in the FastAPI guide.
Common Django JWT Problems
- ·
no such table: token_blacklist_outstandingtoken: the app is inINSTALLED_APPSbutmigratehas not been run. - ·401 with a token that looks valid: the header prefix does not match
AUTH_HEADER_TYPES(for exampleJWTfrom old djangorestframework-jwt tutorials instead ofBearer), orISSUER/AUDIENCEdiffer between the service that issued the token and the one verifying it. - ·Everyone logged out after a deploy:
SIGNING_KEY(orSECRET_KEY, if you left the default) changed. - ·The Authorization header never reaches Django: some Apache mod_wsgi set-ups drop it unless
WSGIPassAuthorization Onis set. - ·Intermittent expiry errors: clock drift between servers. Keep NTP running and set a small
LEEWAY. More causes are listed in common JWT errors and fixes.
Summary
Django REST Framework JWT authentication today means djangorestframework-simplejwt, not the abandoned djangorestframework-jwt. Add JWTAuthentication, routeTokenObtainPairView, TokenRefreshView andTokenBlacklistView, use a dedicated SIGNING_KEY, setISSUER and AUDIENCE, keep access tokens to 15 minutes or less, and turn on ROTATE_REFRESH_TOKENS with BLACKLIST_AFTER_ROTATION. Logout blacklists the refresh token; the access token lives until exp. For Django Ninja, usedjango-ninja-jwt. For general hardening, see JWT security best practices.