On this page
Securing a Django REST Framework API means getting three separate layers right. Authentication works out who is calling. Permissions decide whether that caller may do this action, on this object. Throttling limits how often they can try. Each layer has defaults that are easy to misread, and the gaps between them are where data leaks.
| Layer | Question it answers | Failure response |
|---|---|---|
| Authentication | Who is calling? | 401, or 403 when the scheme has no login challenge |
| Permissions | May this caller do this, to this object? | 403 (401 for an anonymous caller when the scheme has a challenge) |
| Throttling | Is the caller sending too many requests? | 429 |
Start from safe defaults#
DRF's default permission is AllowAny. Change it, so that a view you forget to configure is closed instead of open:
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
}Public endpoints then opt out with permission_classes = [AllowAny], which stands out in a code review.
Choose an authentication scheme#
| Scheme | Best for | Keep in mind |
|---|---|---|
| SessionAuthentication | A browser front end on the same site as the API | Uses Django's login and cookies. Unsafe requests need a CSRF token. |
| TokenAuthentication | Simple server-to-server clients | One database token per user, with no expiry built in |
| JWT (Simple JWT) | Mobile apps, front ends on another domain, several services | Signed tokens with an expiry. Short-lived access tokens plus refresh tokens. |
JWT with Simple JWT#
Simple JWT is the usual JWT package for DRF. Install it and add its views:
pip install djangorestframework-simplejwtfrom django.urls import path
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"),
]A client posts a username and password to /api/token/ and gets two tokens back: a short-lived access token that it sends on every request as Authorization: Bearer <token>, and a longer-lived refresh token that it uses only to get new access tokens.
from datetime import timedelta
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=10),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
}Rotation issues a new refresh token on every refresh. With the blacklist app installed (add rest_framework_simplejwt.token_blacklist to INSTALLED_APPS and run migrate), the old one stops working, so a stolen refresh token doesn't live long either.
Permissions: view level and object level#
The built-in classes cover the common cases: IsAuthenticated, IsAdminUser, IsAuthenticatedOrReadOnly, and DjangoModelPermissions, which maps HTTP methods to Django's add, change and delete permissions. They can be combined with &, | and ~, as in [IsAuthenticated & (IsOwner | IsAdminUser)].
For rules about one specific object, such as "only the author may edit", write a class with has_object_permission():
from rest_framework import permissions
class IsAuthorOrReadOnly(permissions.BasePermission):
"""Anyone may read; only the author may change or delete."""
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.author == request.userfrom rest_framework import permissions, viewsets
class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.select_related("author")
serializer_class = ArticleSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly]
def perform_create(self, serializer):
serializer.save(author=self.request.user)Scope private data in get_queryset()#
For data that belongs to one user, filter the queryset itself. This one line is what stops one user from listing, or even guessing their way to, another user's records:
class NoteViewSet(viewsets.ModelViewSet):
serializer_class = NoteSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Note.objects.filter(owner=self.request.user)
def perform_create(self, serializer):
serializer.save(owner=self.request.user)A scoped queryset also turns another user's object into a 404 rather than a 403, so the API doesn't confirm that the object exists. Staff screens that need everything can branch on self.request.user.is_staff inside get_queryset().
Throttle what attackers will hammer#
Throttling limits requests per client over time. Login and token endpoints need it most, because they are what password-guessing scripts call.
REST_FRAMEWORK = {
# ...the authentication and permission settings from above
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
"rest_framework.throttling.ScopedRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "60/minute",
"user": "600/minute",
"login": "5/minute",
},
}from rest_framework_simplejwt.views import TokenObtainPairView
class LoginView(TokenObtainPairView):
throttle_scope = "login"ScopedRateThrottle applies only to views that set a throttle_scope. Throttle counters live in Django's cache, so production needs a shared cache such as Redis. With the default per-process memory cache, each worker counts on its own. Behind a reverse proxy, set NUM_PROXIES so that DRF takes the client's address from X-Forwarded-For.
A short security checklist#
- Serve the API over HTTPS only. Behind a proxy, set
SECURE_PROXY_SSL_HEADER. - Default to
IsAuthenticated, and mark public views withAllowAny. - Scope every
get_queryset()that returns private data to the requesting user. - Keep JWT access tokens short-lived, and rotate and blacklist refresh tokens.
- Throttle login, token and password-reset endpoints.
- Allow cross-origin requests only from known origins, for example with django-cors-headers and
CORS_ALLOWED_ORIGINS. - Run
python manage.py check --deploybefore every release.
Then prove it with tests: one per endpoint showing that another user can't read or change the data. The pytest guide shows a compact way to test a whole permission matrix.
Frequently asked questions#
Should I use JWT or session authentication with Django REST Framework?
Use sessions when the front end is a browser app on the same site as the API: Django's cookies and CSRF protection are mature, and logging out takes effect at once. Use JWT for mobile apps, third-party clients and front ends on another domain, where cookies are awkward.
Why does my API return 403 instead of 401?
DRF returns 401 only when the first authentication class can send a WWW-Authenticate challenge, as token and JWT authentication do. Session authentication can't, so unauthenticated requests get 403.
Why doesn't my object permission apply to the list endpoint?
has_object_permission() runs only from get_object(), and list views never call it. Filter the list in get_queryset() instead.
How do I log a user out when using JWT?
Blacklist their refresh token, for example with Simple JWT's TokenBlacklistView and the blacklist app, and delete both tokens on the client. The current access token stays valid until it expires, which is why it should be short-lived.
More in this series#
This is part 2 of 5 in Django REST Framework in practice:
- DRF serializers: validation, nested writes and speed
- Authentication, permissions and JWT in DRF (this post)
- Pagination, filtering and search in DRF
- Fixing N+1 queries with select_related and prefetch_related
- Testing DRF APIs with pytest
Comments
No comments yet. Be the first.