REST API Advanced

JWT and OAuth2 with Django: SimpleJWT, Refresh Rotation, Blacklisting, and Social Auth Done Right

Stop hand-rolling token auth. Production-grade JWT for Django REST Framework: access/refresh tokens with rotation, blacklist-on-logout, secure storage (httpOnly cookies vs localStorage), and OAuth2 social auth with allauth.

DjangoZen Team Apr 25, 2026 20 min read 6 views

Token authentication is where most Django REST APIs ship a foot-gun. The default tutorials show you a 10-line setup that's wrong for production: tokens that never expire, no refresh rotation, no logout, and storage choices that hand attackers your sessions. This guide is the version you actually deploy.

JWT vs sessions — and when JWT is wrong

Django sessions are excellent. They're stateful (server can revoke instantly), CSRF-protected, and battle-tested. If your client is a browser on the same origin as your API, just use sessions.

JWT shines when:

  • Cross-origin clients (mobile apps, third-party integrations).
  • Stateless service-to-service auth where you don't want a session lookup on every request.
  • You need to verify identity in a downstream service that can't reach your DB.

JWT hurts when: you trust localStorage, you skip rotation, or you can't revoke. Don't use it just because it's trendy.

SimpleJWT setup

pip install djangorestframework-simplejwt
# settings.py
from datetime import timedelta

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

SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME":  timedelta(minutes=15),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),
    "ROTATE_REFRESH_TOKENS":  True,   # NEW refresh on every refresh
    "BLACKLIST_AFTER_ROTATION": True, # old refresh token unusable
    "ALGORITHM": "HS256",
    "SIGNING_KEY": SECRET_KEY,
    "AUTH_HEADER_TYPES": ("Bearer",),
    "USER_ID_FIELD": "id",
    "USER_ID_CLAIM": "user_id",
}

INSTALLED_APPS += [
    "rest_framework_simplejwt.token_blacklist",
]

Then run python manage.py migrate to create the blacklist tables.

URLs

from rest_framework_simplejwt.views import (
    TokenObtainPairView, TokenRefreshView, TokenBlacklistView,
)

urlpatterns += [
    path("api/auth/token/",         TokenObtainPairView.as_view(),  name="token_obtain_pair"),
    path("api/auth/token/refresh/", TokenRefreshView.as_view(),     name="token_refresh"),
    path("api/auth/logout/",        TokenBlacklistView.as_view(),   name="token_blacklist"),
]

Refresh rotation — what it actually does

With ROTATE_REFRESH_TOKENS=True + BLACKLIST_AFTER_ROTATION=True:

  1. Client hits /api/auth/token/refresh/ with refresh token R1.
  2. Server returns a new access token and a new refresh token R2.
  3. R1 is added to the blacklist.

If R1 is ever used again (say, an attacker stole it), the request fails. That's how you detect token theft — log the failure as a security event and force re-login.

Where do you store tokens? — the only thing that matters

Two choices, and they have very different threat models:

OptionStopsDoesn't stop
localStorageCSRFXSS — any script on your domain reads the token
httpOnly cookieXSS theftCSRF (mitigate with SameSite=Lax/Strict + CSRF token)

For browser apps: httpOnly + Secure + SameSite=Lax cookies. Set them server-side after login:

response.set_cookie(
    "access_token", str(access),
    httponly=True, secure=True, samesite="Lax",
    max_age=15*60, path="/api/",
)
response.set_cookie(
    "refresh_token", str(refresh),
    httponly=True, secure=True, samesite="Strict",
    max_age=7*24*3600, path="/api/auth/token/refresh/",
)

Subclass JWTAuthentication to read from cookies instead of Authorization. The path scoping on the refresh cookie limits its blast radius.

Social auth with django-allauth + dj-rest-auth

pip install django-allauth dj-rest-auth

For Google sign-in: register an OAuth2 client in Google Cloud Console, store the client_id/secret in .env, and let allauth handle the auth code + PKCE flow. dj-rest-auth wraps allauth in REST endpoints that return SimpleJWT tokens — you get OAuth2 inbound + JWT outbound with ~30 lines of config and zero hand-rolled crypto.

Custom claims, carefully

from rest_framework_simplejwt.serializers import TokenObtainPairSerializer

class MyTokenSerializer(TokenObtainPairSerializer):
    @classmethod
    def get_token(cls, user):
        token = super().get_token(user)
        token["org_id"] = user.org_id
        token["role"] = user.role
        return token

Never put PII or secrets in claims. JWT payloads are base64 — not encrypted. Anyone with the token can read them. Put only what's safe to be public-knowable.

Production checklist

  • HTTPS only. Always. SESSION_COOKIE_SECURE=True + SECURE_SSL_REDIRECT=True.
  • Use RS256 with a key pair if multiple services verify tokens — they only need the public key.
  • Rotate the signing key on a schedule. Keep the old kid verifying for one TTL.
  • Rate-limit /api/auth/token/ aggressively (5/min/IP). Brute-force attempts will find you.
  • Log every token_blacklist hit. If a known-blacklisted token is presented, you've witnessed an attack.

Summary

Use SimpleJWT, turn on rotation + blacklist, store tokens in httpOnly cookies for browsers, and let allauth handle social OAuth2. The point of all of this isn't "JWT good" — it's that you have a story for theft, logout, and key rotation. Without those, JWT is just a worse session.