Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 034 / 100

Django REST API authentication: best practices explained

Sessions, tokens and JWT — choose one per client type.

Expona Admin8 min read

Django REST API authentication: best practices explained

Authentication is often where backend teams make their first critical architectural choice: pick the wrong one for your client type, and you'll spend months retrofitting. Django REST API authentication: best practices explained means matching your auth mechanism to whether you're serving web browsers, mobile apps, or other services. We'll walk through the three main patterns, show you how to layer permissions and throttling on top, and highlight the mistakes we've seen slow teams down in production.

Matching auth mechanisms to your client types

The biggest mistake is treating all clients the same. A same-domain web app, a mobile app, and a server calling your API should use different authentication strategies. Let's break down the three primary options:

Session authentication for same-domain web applications

If your frontend and backend live on the same domain (or you control both), session-based authentication remains the simplest choice. The client sends credentials once, the server creates a session, and subsequent requests include a session cookie automatically handled by the browser.

python

# settings.py
INSTALLED_APPS = [
    'rest_framework',
    'rest_framework.authtoken',
    # ...
]

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.SessionAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
}

# views.py
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated

class AccountView(APIView):
    permission_classes = [IsAuthenticated]
    
    def get(self, request):
        return Response({'username': request.user.username})

Sessions work well because browsers handle cookies automatically, CSRF protection is built in (via Django's middleware), and users stay logged in across page refreshes. However, session servers don't scale horizontally without sticky sessions, and they're unsuitable for mobile clients that need stateless auth.

JWT (JSON Web Tokens) for mobile and single-page applications

Mobile apps and modern SPAs can't rely on browser cookies. JWT provides a stateless token the client stores and sends with each request. The server verifies the token's signature without querying a session store.

python

# settings.py
INSTALLED_APPS = [
    'rest_framework_simplejwt',
    # ...
]

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ],
}

# urls.py
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'),
]

# views.py
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response

@api_view(['GET'])
@permission_classes([IsAuthenticated])
def protected_endpoint(request):
    return Response({'user_id': request.user.id})

A client posts credentials to /api/token/, receives an access token (short-lived, minutes to hours) and a refresh token (longer-lived, days to weeks), then includes the access token in the Authorization: Bearer <token> header. When the access token expires, the client uses the refresh token to get a new one without re-entering credentials. This pattern scales horizontally because the server doesn't store state.

API keys and OAuth client credentials for server-to-server

When one service needs to call another, neither session cookies nor user JWTs fit. API keys work for simple cases: a client includes a key in a header, the server looks it up and associates it with a specific service account.

python

# models.py
from rest_framework.authtoken.models import Token

# Token is created automatically when a User is created if you use signals,
# or manually: Token.objects.create(user=service_account_user)

# views.py
from rest_framework.authentication import TokenAuthentication
from rest_framework.permissions import IsAuthenticated

class WebhookView(APIView):
    authentication_classes = [TokenAuthentication]
    permission_classes = [IsAuthenticated]
    
    def post(self, request):
        # request.user is the service account
        return Response({'received': True})

For more complex scenarios—scoped access, revocation without re-issuing, delegated permissions—OAuth 2.0 client credentials flow is standard. Libraries like django-oauth-toolkit handle the flow; the server grants the calling service a bearer token valid for specific scopes.

Layering permissions on top of authentication

Authentication answers "who are you?" Permissions answer "are you allowed to do this?" Django REST Framework separates the two: once a request is authenticated, permission classes decide if the user can proceed.

Built-in permission classes

Start with the framework's built-in classes:

  • IsAuthenticated — any logged-in user can access.
  • IsAdminUser — only staff=True users.
  • IsAuthenticatedOrReadOnly — anyone can read, only authenticated users can write.
  • AllowAny — no restrictions (useful for signup, password reset).
python

from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.views import APIView
from rest_framework.response import Response

class ArticleView(APIView):
    permission_classes = [IsAuthenticatedOrReadOnly]
    
    def get(self, request, pk):
        # Anyone can read
        article = Article.objects.get(pk=pk)
        return Response(article.data)
    
    def put(self, request, pk):
        # Only authenticated users can update
        article = Article.objects.get(pk=pk)
        article.update(request.data)
        return Response(article.data)

Custom permission classes for business logic

Often you need to encode your domain rules. Custom permission classes inherit from BasePermission and implement has_permission for view-level checks.

python

from rest_framework.permissions import BasePermission

class IsSubscribed(BasePermission):
    def has_permission(self, request, view):
        if not request.user or not request.user.is_authenticated:
            return False
        return request.user.subscription_status == 'active'

class ListSubscribersView(APIView):
    permission_classes = [IsSubscribed]
    
    def get(self, request):
        subscribers = Subscriber.objects.filter(user=request.user)
        return Response(SubscriberSerializer(subscribers, many=True).data)

Object-level permissions for ownership checks

View-level permissions are too coarse when you need to check if a user owns the resource they're accessing. The has_object_permission method runs after has_permission and receives the target object.

python

from rest_framework.permissions import BasePermission

class IsOwnerOrReadOnly(BasePermission):
    def has_object_permission(self, request, view, obj):
        # Read permissions are allowed to any request
        if request.method in ['GET', 'HEAD', 'OPTIONS']:
            return True
        # Write permissions are only allowed to the owner
        return obj.owner == request.user

class PostDetailView(APIView):
    permission_classes = [IsOwnerOrReadOnly]
    
    def get(self, request, pk):
        post = Post.objects.get(pk=pk)
        self.check_object_permissions(request, post)
        return Response(PostSerializer(post).data)
    
    def put(self, request, pk):
        post = Post.objects.get(pk=pk)
        self.check_object_permissions(request, post)
        serializer = PostSerializer(post, data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data)
        return Response(serializer.errors, status=400)

When using generic views, the framework calls check_object_permissions automatically. Make sure to query the object and call the method explicitly if you're using plain APIView.

Rate limiting with throttle classes

Authentication opens the door; throttling prevents abuse. Throttle classes rate-limit requests per user, per IP, or per client identifier. This protects your backend from being hammered and ensures fair resource allocation.

Built-in throttle classes

  • AnonRateThrottle — limits anonymous (unauthenticated) users by IP.
  • UserRateThrottle — limits authenticated users by username.
  • ScopedRateThrottle — uses different limits for different views based on a scope attribute.
python

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

# views.py
from rest_framework.throttling import UserRateThrottle
from rest_framework.views import APIView

class ContactUsView(APIView):
    throttle_classes = [UserRateThrottle]  # Override default
    
    def post(self, request):
        # Even authenticated users can only POST once per minute in this view
        # (if you set the scope separately)
        return Response({'sent': True})

Custom throttle classes for fine-grained control

For payment or high-value operations, you might throttle more aggressively. Inherit from BaseThrottle and implement allow_request:

python

from rest_framework.throttling import BaseThrottle
from django.core.cache import cache
import time

class PaymentThrottle(BaseThrottle):
    def allow_request(self, request, view):
        if not request.user.is_authenticated:
            return True  # Anonymous users aren't making payments
        
        cache_key = f'payment_{request.user.id}'
        last_payment = cache.get(cache_key)
        
        if last_payment is None:
            cache.set(cache_key, time.time(), 3600)  # 1 hour expiry
            return True
        
        time_since_last = time.time() - last_payment
        if time_since_last < 60:  # Min 60 seconds between payments
            return False
        
        cache.set(cache_key, time.time(), 3600)
        return True
    
    def throttle_success(self):
        return True

class CheckoutView(APIView):
    throttle_classes = [PaymentThrottle]
    
    def post(self, request):
        # Process payment
        return Response({'transaction_id': '...'})

Common mistakes

Forgetting to call check_object_permissions in custom views: If you write your own APIView subclass, manually call self.check_object_permissions(request, obj) before returning data. Generic views do this for you, but plain views don't.

Using session auth for mobile clients: Mobile apps can't reliably store and send cookies; you'll hit CORS and state-management issues. Use JWT instead.

Setting overly generous token expiry times: An access token with a 30-day lifetime is a security liability. Keep them short (minutes to a few hours) and use refresh tokens for longer sessions.

Applying throttles globally when only some endpoints need them: Throttling a login endpoint the same as a list endpoint wastes rate limits. Use scoped throttles or override at the view level.

Storing sensitive data in JWT claims: JWTs are signed, not encrypted. Anyone with the token can read the payload. Don't include passwords, secrets, or PII.

Key takeaways

  • Choose session auth for same-domain web apps, JWT for mobile and SPAs, and API keys or OAuth for server-to-server calls.
  • Always pair authentication with permission classes; IsAuthenticated is a baseline, but custom classes encode your business rules.
  • Use has_object_permission for ownership checks on specific resources; don't assume all authenticated users have equal access.
  • Throttle by user or IP to prevent abuse; custom throttle classes let you enforce domain-specific rate limits (payment operations, signup, etc.).
  • In production, keep JWT access tokens short-lived (15 min to 1 hour), store refresh tokens securely, and monitor authentication failures for suspicious patterns.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles