Django DRF #2: Authentication/Permissions — Token, JWT, custom permission
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 auth | DRF auth | |
|---|---|---|
| Main use | Server-rendered pages | JSON API |
| Storage | session + cookie | header (Token / JWT) |
| Middleware | AuthenticationMiddleware | DEFAULT_AUTHENTICATION_CLASSES |
| Login form | LoginView (template) | obtain_auth_token / JWT view |
request.user | Filled by middleware | Filled by auth class |
| Can be used together | — | Yes (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.
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 #
| Class | Where it fits |
|---|---|
SessionAuthentication | Same-origin browser (Browsable API, sharing with admin) |
BasicAuthentication | Debug/test (not for production) |
TokenAuthentication | Simple token. Stored in DB’s authtoken_token |
| JWT (simplejwt) | Standard token. Stateless, expiration/refresh |
| OAuth2 | External 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.
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",
],
}uv run python manage.py migratefrom 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 #
from rest_framework.authtoken.views import obtain_auth_token
urlpatterns = [
...,
path("api/auth/token/", obtain_auth_token),
]curl -X POST http://localhost:8000/api/auth/token/ \
-d "username=alice&password=secret"{"token": "9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b"}After that, attach the token in a header on every 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.
uv add djangorestframework-simplejwtREST_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).
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 #
curl -X POST http://localhost:8000/api/auth/token/ \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"secret"}'{
"access": "eyJhbGciOi...",
"refresh": "eyJhbGciOi..."
}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.
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 #
| TokenAuth | JWT (simplejwt) | |
|---|---|---|
| Storage | DB | Client only |
| Verify cost | DB lookup | Signature check (memory) |
| Expiration | None (manual delete) | Automatic (exp) |
| Refresh | None | Yes |
| Revocation | Delete token → immediate | Until expiry, or blacklist |
| Header prefix | Token | Bearer |
| Best fit | Internal tools, simple | General 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 #
| Class | Meaning |
|---|---|
AllowAny | Anyone (no auth needed) |
IsAuthenticated | Logged-in users only |
IsAdminUser | is_staff=True only |
IsAuthenticatedOrReadOnly | Anyone can read, only logged-in users can write |
DjangoModelPermissions | Django’s add_, change_, delete_ permissions (Intermediate #4) |
DjangoObjectPermissions | Object-level permissions (with django-guardian) |
Per-view override #
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.
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.
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.userfrom .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.
has_permission— at view entry. For every request.has_object_permission— afterget_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.
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
#
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.
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 #
"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
#
"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
#
class IsOwner(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
return obj.author == request.userIt’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.
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_CLASSESglobal config- TokenAuthentication —
authtokenapp, signal for auto token,Token <key>header - JWT (simplejwt) —
TokenObtainPairView,TokenRefreshView, refresh rotation,Bearerheader - Permission classes —
AllowAny,IsAuthenticated,IsAdminUser,IsAuthenticatedOrReadOnly - Per-view
permission_classesoverride,get_permissions()per-action branching - Object-level permission — two stages:
has_permission+has_object_permission IsOwnerOrReadOnlycustom 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.