Django DRF #2: Authentication/Permissions — Token, JWT, custom permission

8 min read

The PostViewSet you built in #1 is in a state where anyone can write and edit posts. To become a real service, you have to answer two questions.

  • Authentication — “Who are you?”
  • Authorization — “Are you allowed to do this?”

DRF has authentication classes for the first slot and permission classes for the second. They are completely separate concepts but work together.

DRF auth vs Django core auth #

The Django core auth from Intermediate #4 Users/Permissions sits in a different slot from DRF auth.

Django core authDRF auth
Main useServer-rendered pagesJSON API
Storagesession + cookieheader (Token / JWT)
MiddlewareAuthenticationMiddlewareDEFAULT_AUTHENTICATION_CLASSES
Login formLoginView (template)obtain_auth_token / JWT view
request.userFilled by middlewareFilled by auth class
Can be used togetherYes (DRF supports SessionAuth too)

They share the same User model, and Django’s is_authenticated, permissions, etc. are used as-is. Only the auth transport (where credentials come from) differs.

DRF authentication classes #

DRF tries multiple authentication classes in order for one request. The first one to succeed wins.

settings.py — global config
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.SessionAuthentication",
        "rest_framework.authentication.TokenAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}

The default sits at the global level. Overridable per view.

Frequently used auth classes #

ClassWhere it fits
SessionAuthenticationSame-origin browser (Browsable API, sharing with admin)
BasicAuthenticationDebug/test (not for production)
TokenAuthenticationSimple token. Stored in DB’s authtoken_token
JWT (simplejwt)Standard token. Stateless, expiration/refresh
OAuth2External IdP, multi-client (django-oauth-toolkit)

For small services / mobile apps, JWT (simplejwt) is usually the first choice. For very simple internal tools, TokenAuthentication is enough.

Starting with TokenAuthentication #

The simplest token flow. Django’s authtoken app creates a token table, and the obtain_auth_token view issues a token from email/password.

settings.py
INSTALLED_APPS = [
    ...,
    "rest_framework",
    "rest_framework.authtoken",   # add
]

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.TokenAuthentication",
        "rest_framework.authentication.SessionAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}
Migration + signal to auto-create per-user tokens
uv run python manage.py migrate
blog/signals.py — auto-create token on signup
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
from rest_framework.authtoken.models import Token


@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_auth_token(sender, instance=None, created=False, **kwargs):
    if created:
        Token.objects.create(user=instance)

Same pattern as Intermediate #3 Signals. The moment a user is created, a token is created alongside.

Token issuance view #

mysite/urls.py
from rest_framework.authtoken.views import obtain_auth_token

urlpatterns = [
    ...,
    path("api/auth/token/", obtain_auth_token),
]
Request
curl -X POST http://localhost:8000/api/auth/token/ \
  -d "username=alice&password=secret"
Response
{"token": "9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b"}

After that, attach the token in a header on every request.

Authenticated request
curl http://localhost:8000/api/posts/ \
  -H "Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b"

The Token prefix is the key — different from JWT’s Bearer.

Limits of TokenAuthentication #

  • No expiration — tokens are permanent. To revoke, you must delete from the DB.
  • Stateful — DB lookup on every verify.
  • No refresh mechanism.

Enough for small internal tools or prototypes, but JWT fits general user services better.

JWT — djangorestframework-simplejwt #

JWT is stateless, with expiration/refresh as standard — the same JWT concept covered in FastAPI #4 Authentication. The standard library on the DRF side is simplejwt.

Install
uv add djangorestframework-simplejwt
settings.py
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
        "rest_framework.authentication.SessionAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}

from datetime import timedelta

SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),
    "ROTATE_REFRESH_TOKENS": True,
    "BLACKLIST_AFTER_ROTATION": True,
    "AUTH_HEADER_TYPES": ("Bearer",),
}

The ROTATE_REFRESH_TOKENS + BLACKLIST_AFTER_ROTATION combination is recommended — every refresh hands out a new refresh token and the old one goes to the blacklist. Keeps the lifespan of stolen tokens short (for blacklisting, add rest_framework_simplejwt.token_blacklist to INSTALLED_APPS).

mysite/urls.py
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
    TokenVerifyView,
)

urlpatterns = [
    ...,
    path("api/auth/token/", TokenObtainPairView.as_view()),
    path("api/auth/token/refresh/", TokenRefreshView.as_view()),
    path("api/auth/token/verify/", TokenVerifyView.as_view()),
]

Issuance flow #

Login — access + refresh in one shot
curl -X POST http://localhost:8000/api/auth/token/ \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"secret"}'
Response
{
  "access": "eyJhbGciOi...",
  "refresh": "eyJhbGciOi..."
}
API call — Bearer prefix
curl http://localhost:8000/api/posts/ \
  -H "Authorization: Bearer eyJhbGciOi..."

Refresh #

When access expires (after 30 min), get a new access with the refresh token.

refresh
curl -X POST http://localhost:8000/api/auth/token/refresh/ \
  -H "Content-Type: application/json" \
  -d '{"refresh":"eyJhbGciOi..."}'
{"access": "eyJhbGciOi...new token..."}

TokenAuthentication vs JWT #

TokenAuthJWT (simplejwt)
StorageDBClient only
Verify costDB lookupSignature check (memory)
ExpirationNone (manual delete)Automatic (exp)
RefreshNoneYes
RevocationDelete token → immediateUntil expiry, or blacklist
Header prefixTokenBearer
Best fitInternal tools, simpleGeneral user services

Permission classes #

After authentication finishes (request.user is filled), permission checks whether that person is allowed to perform this action.

Built-in permission classes #

ClassMeaning
AllowAnyAnyone (no auth needed)
IsAuthenticatedLogged-in users only
IsAdminUseris_staff=True only
IsAuthenticatedOrReadOnlyAnyone can read, only logged-in users can write
DjangoModelPermissionsDjango’s add_, change_, delete_ permissions (Intermediate #4)
DjangoObjectPermissionsObject-level permissions (with django-guardian)

Per-view override #

blog/views.py
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from .models import Post
from .serializers import PostSerializer


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]

    def perform_create(self, serializer):
        serializer.save(author=self.request.user)

IsAuthenticatedOrReadOnly — anyone can view post list/detail, but only logged-in users can create/update/delete.

Per-action permission — get_permissions #

When the same ViewSet needs different permissions per action.

Per-action permissions
from rest_framework.permissions import IsAuthenticated, AllowAny


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

    def get_permissions(self):
        if self.action in ["list", "retrieve"]:
            return [AllowAny()]
        return [IsAuthenticated()]

get_permissions() returns a list of instances (not classes). Watch the () invocation.

Object-level permission — building IsOwner #

Users should only edit/delete their own posts. Object-level checks go in has_object_permission.

blog/permissions.py
from rest_framework import permissions


class IsOwnerOrReadOnly(permissions.BasePermission):
    """Object owner only for write/delete. Anyone for read."""

    def has_permission(self, request, view):
        # Collection level — only authenticated users can write
        if request.method in permissions.SAFE_METHODS:
            return True
        return request.user and request.user.is_authenticated

    def has_object_permission(self, request, view, obj):
        # Object level — anyone can read
        if request.method in permissions.SAFE_METHODS:
            return True
        # Owner only for write
        return obj.author == request.user
blog/views.py
from .permissions import IsOwnerOrReadOnly


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    permission_classes = [IsOwnerOrReadOnly]
    ...

has_permission vs has_object_permission #

DRF checks in two stages.

  1. has_permission — at view entry. For every request.
  2. has_object_permission — after get_object() is called. For retrieve/update/destroy and similar single-object actions.

The list action has no object, so has_object_permission is never called — to filter “only mine” in list responses, you have to handle it in get_queryset(). That’s also the topic of the next post.

Combining permissions — &, |, ~ #

You can combine multiple permissions with logical operators.

Combination
from rest_framework.permissions import IsAuthenticated, IsAdminUser


class PostViewSet(viewsets.ModelViewSet):
    permission_classes = [IsAuthenticated & (IsOwnerOrReadOnly | IsAdminUser)]

“Authenticated AND (owner OR admin)” — natural expression.

Common patterns — IsAdminOrReadOnly, role-based #

Extending blog/permissions.py
class IsAdminOrReadOnly(permissions.BasePermission):
    """Admin only for write. Anyone for read."""

    def has_permission(self, request, view):
        if request.method in permissions.SAFE_METHODS:
            return True
        return request.user and request.user.is_staff


class HasRole(permissions.BasePermission):
    """Based on user.profile.role."""

    required_role: str = ""

    def has_permission(self, request, view):
        return (
            request.user
            and request.user.is_authenticated
            and getattr(request.user.profile, "role", None) == self.required_role
        )


def has_role(role: str):
    """Factory — creates a class that produces a `HasRole` instance."""
    return type(f"HasRole_{role}", (HasRole,), {"required_role": role})


class PostViewSet(viewsets.ModelViewSet):
    permission_classes = [has_role("editor")]

A pattern similar to FastAPI #4’s require_role — a function returns a class.

Throttling — bonus, the slot that comes with auth #

Separately from permissions, request rate limiting typically lives in the same place.

settings.py
REST_FRAMEWORK = {
    ...,
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {
        "anon": "100/hour",
        "user": "1000/hour",
    },
}

Anonymous users get 100/hour, authenticated users 1000/hour. A cache backend is required (CACHES setting — see Advanced #4 Caching).

Common pitfalls #

1) Auth class order #

🚫 Session first forces CSRF
"DEFAULT_AUTHENTICATION_CLASSES": [
    "rest_framework.authentication.SessionAuthentication",   # first
    "rest_framework_simplejwt.authentication.JWTAuthentication",
],

Mobile/SPA clients using JWT only usually don’t send a CSRF token. If SessionAuth comes first, the CSRF check trips it. Put JWT first or remove SessionAuth.

2) Empty DEFAULT_PERMISSION_CLASSES means anyone #

🚫 Empty equals AllowAny
"DEFAULT_PERMISSION_CLASSES": [],

Set IsAuthenticated as the explicit global default and override per-view with [AllowAny] for public endpoints — deny by default is safer.

3) has_object_permission alone is not enough #

🚫 Missing has_permission
class IsOwner(permissions.BasePermission):
    def has_object_permission(self, request, view, obj):
        return obj.author == request.user

It’s not called on the list action, so without a global permission check alongside, unauthenticated users can see the list. Use has_permission to check authentication first.

4) JWT secret = Django SECRET_KEY as-is #

simplejwt uses SECRET_KEY by default. If exposed, tokens can be forged. Manage SECRET_KEY itself strongly via environment variables, or set SIGNING_KEY separately.

settings.py
SIMPLE_JWT["SIGNING_KEY"] = os.environ["JWT_SIGNING_KEY"]

Recap #

What this post nailed down:

  • DRF auth vs Django core auth — same User over different transports
  • Auth classes — SessionAuth, TokenAuth, JWT (simplejwt), OAuth2
  • DEFAULT_AUTHENTICATION_CLASSES, DEFAULT_PERMISSION_CLASSES global config
  • TokenAuthenticationauthtoken app, signal for auto token, Token <key> header
  • JWT (simplejwt)TokenObtainPairView, TokenRefreshView, refresh rotation, Bearer header
  • Permission classes — AllowAny, IsAuthenticated, IsAdminUser, IsAuthenticatedOrReadOnly
  • Per-view permission_classes override, get_permissions() per-action branching
  • Object-level permission — two stages: has_permission + has_object_permission
  • IsOwnerOrReadOnly custom pattern
  • Permission combinations &, |, ~
  • Throttling for request rate limiting
  • Pitfalls — auth order, empty perms, object perms not applied to list, JWT secret

The Depends(get_current_user) pattern in FastAPI #4 maps to permission_classes + request.user in DRF. FastAPI is stronger on the richness of dependency injection; Django/DRF is stronger on the depth of the built-in permission system.

The next post (#3 Filtering / Ordering / Pagination) covers the standard tools for handling large data — filtering, ordering, pagination.

X