Body:
Environment:
- Django 5.x
- Django REST Framework
- djangorestframework-simplejwt
- React (Vite) frontend using JWT Bearer tokens
Problem:
I have a
ModelViewSetwithauthentication_classes = [JWTAuthentication]set at the class level. When I add a custom@actiondecorator and overridepermission_classes, the endpoint starts returning403 Forbidden (CSRF cookie not set)on unsafe methods (PATCH, POST).Expected behavior: The action should inherit
authentication_classesfrom the parent viewset.Actual behavior: Overriding
permission_classeson the action causesauthentication_classesto stop inheriting from the viewset and fall back to DRF’s global defaults, which includeSessionAuthentication. That class enforces CSRF on unsafe methods, causing the 403 even though the request has a valid Bearer token.Workaround: Explicitly setting
authentication_classes=[JWTAuthentication]on every@actionthat overridespermission_classesfixes it — but this feels like a bug or at least a surprising gotcha worth documenting.Question: Is this intended DRF behavior? If so, where is it documented?
code:
from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticate
from applications.models import JobApplication
from applications.serializers.applications import JobApplicationSerializer
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework.decorators import action
from rest_framework.response import Response
class JobApplicationViewSet(ModelViewSet):
serializer_class = JobApplicationSerializer
permission_classes = [IsAuthenticated]
authentication_classes = [JWTAuthentication]
def get_queryset(self):
return JobApplication.objects.filter(
employee=self.request.user.employee
)
def perform_create(self, serializer):
serializer.save(
employee=self.request.user.employee
)
@action(
detail=True,
methods=["patch"],
permission_classes=[IsAuthenticated],
authentication_classes=[JWTAuthentication],
)
def add_stage(self, request, pk=None):
try:
application = JobApplication.objects.get(pk=pk)
except JobApplication.DoesNotExist:
return Response({"error": "Not found"}, status=404)
new_stage = request.data.get("stage")
if not new_stage:
return Response({"error": "Stage is required"}, status=400)
history = application.status or []
history.append(new_stage)
application.status = history
application.save()
return Response({"status": application.status})
browser client:
const res = await fetch(
`http://localhost:8000/api/job-applications/${candidateId}/add-stage/`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
stage,
}),
}