Fix: Django REST Framework 403 Permission Denied
Part of: Python Errors
Quick Answer
How to fix Django REST Framework 403 Forbidden and permission denied errors — authentication classes, permission classes, IsAuthenticated vs AllowAny, object-level permissions, and CSRF issues.
The Error
A Django REST Framework API endpoint returns a 403 Forbidden response:
{
"detail": "Authentication credentials were not provided."
}Or:
{
"detail": "You do not have permission to perform this action."
}Or a CSRF-related 403:
403 Forbidden
CSRF Failed: CSRF token missing or incorrect.Or a token authentication error:
{
"detail": "Invalid token."
}Why This Happens
DRF uses a two-step security model: authentication (who are you?) followed by authorization (what are you allowed to do?). A 403 can come from either step failing.
The first step resolves identity. DRF iterates through every class listed in DEFAULT_AUTHENTICATION_CLASSES (or the view’s authentication_classes). Each class inspects the incoming request for credentials — a session cookie, a token in the Authorization header, a JWT. If none of them find valid credentials, request.user is set to AnonymousUser and request.auth is None. At that point, any permission class that requires an authenticated user will reject the request. The error message "Authentication credentials were not provided." fires here, even though the HTTP status is 403 rather than 401, because DRF only returns 401 when the first authentication class defines a WWW-Authenticate header.
The second step checks authorization. Even when the user is successfully authenticated, DRF calls check_permissions() before the view runs and check_object_permissions() inside get_object(). Each permission class in the view’s permission_classes list must return True. If any one returns False, DRF raises PermissionDenied with "You do not have permission to perform this action." — or the custom message from the permission class’s message attribute. Object-level permissions add another layer: a user can pass list-level checks but be denied access to a specific record because they don’t own it or lack the right role.
CSRF errors are a special case tied specifically to SessionAuthentication. Django’s CSRF middleware normally runs on all POST/PUT/PATCH/DELETE requests, but DRF overrides this — it defers CSRF enforcement to the authentication class. SessionAuthentication.enforce_csrf() manually runs CSRF validation on every unsafe request. If the browser doesn’t send the csrftoken cookie or the X-CSRFToken header, the request is rejected with "CSRF Failed: CSRF token missing or incorrect." This only affects browser-based clients using session cookies; token-based and JWT-based clients bypass CSRF entirely because they use the Authorization header instead.
How Other Tools Handle This
DRF’s layered permission model is one of several approaches to API authorization. Understanding the alternatives clarifies what DRF is doing and helps when porting APIs between frameworks.
FastAPI uses Python’s dependency injection via Depends(). There are no global permission classes. Instead, you declare a dependency function that extracts and validates the user from the request, then inject it into every route that needs protection:
# FastAPI — dependency-based auth
async def get_current_user(token: str = Depends(oauth2_scheme)):
user = decode_token(token)
if not user:
raise HTTPException(status_code=401)
return user
@app.get("/protected")
async def protected(user: User = Depends(get_current_user)):
return {"user": user.username}This is explicit — every endpoint either declares the dependency or it doesn’t. There are no surprise global defaults. The trade-off is repetition: you must add Depends(get_current_user) to every protected route.
Express.js uses middleware functions. Authentication and authorization are separate middleware you attach to routes or routers. There is no built-in permission system; you write functions that call next() on success or return a 403:
// Express — middleware chain
function requireAuth(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
next();
}
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) return res.status(403).json({ error: 'Forbidden' });
next();
};
}
app.delete('/posts/:id', requireAuth, requireRole('admin'), deletePost);Spring Security uses annotations and a filter chain. The @PreAuthorize annotation on a controller method is evaluated before the method runs, using Spring Expression Language (SpEL). Global rules are configured in a SecurityFilterChain bean. Spring separates authentication (handled by AuthenticationManager) from authorization (handled by AccessDecisionManager or the newer AuthorizationManager), similar to DRF but with more ceremony.
Rails with Pundit takes a policy-object approach. Each model has a corresponding policy class that defines create?, update?, destroy? methods. The controller calls authorize @post, which instantiates the policy, passes the current user and the record, and raises Pundit::NotAuthorizedError if the method returns false. CanCanCan uses a single Ability class with a DSL (can :manage, Post, user_id: user.id) and checks via authorize! :update, @post. Both approaches are more object-oriented than DRF’s flat list of permission classes, but less flexible for view-level logic like per-action overrides.
The key DRF-specific pattern to remember: permission classes are AND-combined by default (all must return True), authentication classes are tried in order until one succeeds, and CSRF enforcement is tied to SessionAuthentication specifically — not to Django’s middleware.
Fix 1: Set the Correct Permission Class on the View
Override the permission classes on specific views to control who can access them:
from rest_framework.permissions import IsAuthenticated, AllowAny, IsAdminUser
from rest_framework.views import APIView
from rest_framework.response import Response
# Require authentication for this specific view
class PrivateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
return Response({'data': 'sensitive'})
# Allow any user (no authentication required)
class PublicView(APIView):
permission_classes = [AllowAny]
def get(self, request):
return Response({'data': 'public'})
# Admin only
class AdminOnlyView(APIView):
permission_classes = [IsAdminUser]
def get(self, request):
return Response({'data': 'admin only'})With ViewSets:
from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.decorators import action
class PostViewSet(ModelViewSet):
serializer_class = PostSerializer
queryset = Post.objects.all()
def get_permissions(self):
"""Different permissions for different actions."""
if self.action in ('list', 'retrieve'):
# Public read access
permission_classes = [AllowAny]
else:
# Authenticated write access
permission_classes = [IsAuthenticated]
return [permission() for permission in permission_classes]Built-in permission classes:
| Class | Behavior |
|---|---|
AllowAny | No restrictions — any request is allowed |
IsAuthenticated | Request must be authenticated |
IsAdminUser | User must have is_staff = True |
IsAuthenticatedOrReadOnly | Read (GET, HEAD, OPTIONS) is open; write requires auth |
DjangoModelPermissions | Requires Django model-level permissions (add_, change_, delete_, view_) |
DjangoObjectPermissions | Per-object permissions using Django’s object permission framework |
Fix 2: Fix the Global Default Permission and Authentication Settings
Check settings.py to understand what applies globally to all views:
# settings.py
REST_FRAMEWORK = {
# Global default — applied to all views without explicit permission_classes
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated', # ← Requires auth everywhere
# 'rest_framework.permissions.AllowAny', # ← Open (insecure for APIs)
],
# Authentication methods to try, in order
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.TokenAuthentication',
# 'rest_framework_simplejwt.authentication.JWTAuthentication',
],
}To open specific views while keeping global auth:
# Override per-view — don't change the global default
class RegisterView(APIView):
permission_classes = [AllowAny] # Override the global IsAuthenticated
authentication_classes = [] # No authentication needed
def post(self, request):
# Handle registration
...Fix 3: Fix TokenAuthentication — Include the Token Correctly
DRF’s TokenAuthentication requires the token in the Authorization header with the format Token <token-value>:
# WRONG — missing "Token" prefix
curl -H "Authorization: abc123" http://localhost:8000/api/data/
# WRONG — using "Bearer" prefix (that's JWT, not DRF token auth)
curl -H "Authorization: Bearer abc123" http://localhost:8000/api/data/
# CORRECT — DRF TokenAuthentication format
curl -H "Authorization: Token abc123" http://localhost:8000/api/data/Set up token authentication properly:
# settings.py
INSTALLED_APPS = [
...
'rest_framework.authtoken', # ← Must be in INSTALLED_APPS
]
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
}# Run migrations to create the token table
python manage.py migrateCreate a login endpoint that returns a token:
# views.py
from rest_framework.authtoken.views import obtain_auth_token
# urls.py
from django.urls import path
from rest_framework.authtoken.views import obtain_auth_token
urlpatterns = [
path('api/token/', obtain_auth_token), # POST with username/password → returns token
]# Get a token
curl -X POST http://localhost:8000/api/token/ \
-d '{"username": "alice", "password": "secret"}' \
-H "Content-Type: application/json"
# {"token": "9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b"}
# Use the token
curl -H "Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b" \
http://localhost:8000/api/data/Create tokens for existing users:
# Django shell
python manage.py shell
from rest_framework.authtoken.models import Token
from django.contrib.auth.models import User
user = User.objects.get(username='alice')
token, created = Token.objects.get_or_create(user=user)
print(token.key)Fix 4: Fix SessionAuthentication CSRF Errors
SessionAuthentication requires a valid CSRF token for write operations. This is by design — it protects against cross-site request forgery:
# The 403 with CSRF message:
# "CSRF Failed: CSRF token missing or incorrect."For browser-based JavaScript clients:
// Get CSRF token from the cookie
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
const csrfToken = getCookie('csrftoken');
// Include in all non-safe requests
fetch('/api/posts/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken, // ← Required for POST/PUT/PATCH/DELETE
},
body: JSON.stringify({ title: 'New Post' }),
});For API clients (mobile apps, third-party services) that use token or JWT authentication — use TokenAuthentication or JWTAuthentication instead. These don’t require CSRF:
# API clients (non-browser) — use token auth, which doesn't need CSRF
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication', # No CSRF required
'rest_framework.authentication.SessionAuthentication', # Browser clients
],
}Exempt specific views from CSRF (use with caution):
from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view
# Only do this if you have another form of authentication (token/JWT)
@csrf_exempt
@api_view(['POST'])
def webhook_endpoint(request):
# Webhook from external service — no CSRF token available
...Warning: Never exempt CSRF from endpoints that use session authentication without another security mechanism. CSRF exemption on session-based endpoints opens your API to cross-site request forgery attacks. See Fix: Django CSRF Verification Failed for a deeper look at CSRF troubleshooting.
Fix 5: Write Custom Permission Classes
For ownership-based or role-based access control, write a custom permission:
# permissions.py
from rest_framework.permissions import BasePermission, SAFE_METHODS
class IsOwnerOrReadOnly(BasePermission):
"""
Object-level permission: allow read for anyone,
write only if the user owns the object.
"""
def has_permission(self, request, view):
# Allow read (GET, HEAD, OPTIONS) for all
if request.method in SAFE_METHODS:
return True
# Write requires authentication
return request.user and request.user.is_authenticated
def has_object_permission(self, request, view, obj):
# Read is allowed for everyone
if request.method in SAFE_METHODS:
return True
# Write only if user owns the object
return obj.owner == request.user
class IsVerifiedUser(BasePermission):
"""Only allow verified (email-confirmed) users."""
message = 'Please verify your email address before accessing this resource.'
def has_permission(self, request, view):
return (
request.user and
request.user.is_authenticated and
request.user.profile.email_verified # Custom user profile field
)# views.py
class PostViewSet(ModelViewSet):
permission_classes = [IsAuthenticated, IsOwnerOrReadOnly]
serializer_class = PostSerializer
def get_queryset(self):
return Post.objects.filter(published=True)
def perform_create(self, serializer):
# Automatically set owner to the current user
serializer.save(owner=self.request.user)Combine multiple permission classes (AND logic):
# All permission classes must return True
permission_classes = [IsAuthenticated, IsVerifiedUser, IsOwnerOrReadOnly]OR logic with custom operator:
from rest_framework.permissions import BasePermission
class IsAdminOrOwner(BasePermission):
def has_object_permission(self, request, view, obj):
return request.user.is_staff or obj.owner == request.userFix 6: Debug Permission Issues
Enable DRF exception detail in development:
# settings.py (development only)
REST_FRAMEWORK = {
'EXCEPTION_HANDLER': 'myapp.exceptions.custom_exception_handler',
}# exceptions.py
from rest_framework.views import exception_handler
import logging
logger = logging.getLogger(__name__)
def custom_exception_handler(exc, context):
response = exception_handler(exc, context)
if response is not None and response.status_code in (401, 403):
# Log which permission class denied the request
request = context['request']
view = context['view']
logger.warning(
f"Permission denied: {exc} | "
f"User: {request.user} | "
f"View: {view.__class__.__name__} | "
f"Method: {request.method}"
)
return responseTest permission logic directly in Django shell:
python manage.py shell
from django.test import RequestFactory
from django.contrib.auth.models import User
from rest_framework.test import APIRequestFactory
from myapp.permissions import IsOwnerOrReadOnly
from myapp.models import Post
factory = APIRequestFactory()
# Simulate a request from a specific user
user = User.objects.get(username='alice')
post = Post.objects.first()
request = factory.get('/')
request.user = user
permission = IsOwnerOrReadOnly()
print(permission.has_permission(request, None)) # True/False
print(permission.has_object_permission(request, None, post)) # True/FalseCheck which authentication and permission classes are active on a view:
python manage.py shell
from myapp.views import PostViewSet
view = PostViewSet()
print(view.get_authenticators())
print(view.get_permissions())Still Not Working?
Check for missing perform_authentication call. Some custom middleware or view logic may need to explicitly authenticate:
class MyView(APIView):
def get(self, request):
# Force authentication resolution
request.user # Accessing this triggers authentication
if request.user.is_authenticated:
return Response({'data': 'ok'})
return Response({'detail': 'Not authenticated'}, status=401)Verify the token exists in the database. Tokens can be deleted or expired:
python manage.py shell
from rest_framework.authtoken.models import Token
# Check if a token exists
Token.objects.filter(key='9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b').exists()
# → False means the token was deleted or never createdCheck Django REST Framework’s browsable API — visit the endpoint in a browser while logged into Django admin. If it works there but not via the API client, the issue is in how the client sends authentication credentials.
For JWT authentication (djangorestframework-simplejwt), the token prefix is Bearer, not Token:
# SimpleJWT uses Bearer prefix
curl -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." \
http://localhost:8000/api/data/# settings.py with SimpleJWT
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
}Check for throttle_classes masquerading as permission errors. DRF’s throttle classes return a 429 status by default, but misconfigured throttles or custom throttle subclasses that return 403 can mimic permission denials. Inspect DEFAULT_THROTTLE_CLASSES and any per-view throttle_classes overrides. If you see "Request was throttled" in the detail field, the issue is rate limiting, not permissions.
Verify AUTHENTICATION_BACKENDS in Django settings. DRF’s SessionAuthentication delegates to Django’s standard auth backend. If you’ve replaced the default ModelBackend with a custom backend (LDAP, OAuth, etc.) that rejects the user, the permission error originates upstream of DRF. Run python manage.py shell and call django.contrib.auth.authenticate(username='alice', password='secret') to confirm the backend works independently.
Check for DEFAULT_RENDERER_CLASSES stripping error detail. If you’ve removed BrowsableAPIRenderer and only use JSONRenderer, error responses still include the detail field. But custom renderers that override the response structure may hide the real permission error message, making debugging harder. Temporarily restore the default renderers to see the full error context.
For related Django and API authorization issues, see Fix: Django CSRF Verification Failed, Fix: Spring Security 403 Forbidden, Fix: FastAPI 422 Unprocessable Entity, and Fix: Express Middleware Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Gunicorn Not Working — Worker Timeout, Boot Errors, and Signal Handling
How to fix Gunicorn errors — WORKER TIMEOUT killed, ImportError cannot import app, worker class not found, connection refused 502 behind nginx, graceful reload not working, and sync vs async worker selection.
Fix: Kafka Consumer Not Receiving Messages, Connection Refused, and Rebalancing Errors
How to fix Apache Kafka issues — consumer not receiving messages, auto.offset.reset, Docker advertised.listeners, max.poll.interval.ms rebalancing, MessageSizeTooLargeException, and KafkaJS errors.
Fix: OpenAI API Not Working — RateLimitError, 401, 429, and Connection Issues
How to fix OpenAI API errors — RateLimitError (429), AuthenticationError (401), APIConnectionError, context length exceeded, model not found, and SDK v0-to-v1 migration mistakes.
Fix: Python Packaging Not Working — Build Fails, Package Not Found After Install, or PyPI Upload Errors
How to fix Python packaging issues — pyproject.toml setup, build backends (setuptools/hatchling/flit), wheel vs sdist, editable installs, package discovery, and twine upload to PyPI.