Engineering

Authentication, Permissions and JWT in Django REST Framework

On this page
  1. Start from safe defaults
  2. Choose an authentication scheme
  3. JWT with Simple JWT
  4. Permissions: view level and object level
  5. Scope private data in get_queryset()
  6. Throttle what attackers will hammer
  7. A short security checklist
  8. Frequently asked questions
  9. More in this series

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.

LayerQuestion it answersFailure response
AuthenticationWho is calling?401, or 403 when the scheme has no login challenge
PermissionsMay this caller do this, to this object?403 (401 for an anonymous caller when the scheme has a challenge)
ThrottlingIs 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:

settings.py
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#

SchemeBest forKeep in mind
SessionAuthenticationA browser front end on the same site as the APIUses Django's login and cookies. Unsafe requests need a CSRF token.
TokenAuthenticationSimple server-to-server clientsOne database token per user, with no expiry built in
JWT (Simple JWT)Mobile apps, front ends on another domain, several servicesSigned 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:

bash
pip install djangorestframework-simplejwt
urls.py
from 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.

settings.py
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():

permissions.py
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.user
views.py
from 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:

views.py
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.

settings.py
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",
    },
}
views.py
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 with AllowAny.
  • 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 --deploy before 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:

  1. DRF serializers: validation, nested writes and speed
  2. Authentication, permissions and JWT in DRF (this post)
  3. Pagination, filtering and search in DRF
  4. Fixing N+1 queries with select_related and prefetch_related
  5. Testing DRF APIs with pytest

Comments

No comments yet. Be the first.

Leave a comment

Only used to tell you about a reply. Never shown.

Plain text; line breaks are kept.

Let's build something

Hiring for a backend role, or have a project in mind? Send a message and I will reply by email.

At most 2 links.