Django csrf_exempt Ignored When a Custom Decorator Wraps the View
A custom decorator applied outside @csrf_exempt drops the exemption and returns 403. Fix it with functools.wraps.
A Django view marked @csrf_exempt still returns 403 for non-browser clients when a custom decorator (CORS, logging, rate limiting) is applied on top of it.
WARNING django.security.csrf: Forbidden (CSRF cookie not set.): /oauth/token
This typically appears on API-style endpoints such as OAuth token or registration endpoints, webhooks, or anything called by a CLI or server rather than a browser.
Cause
@csrf_exempt does not change the view’s behaviour. It sets an attribute, view.csrf_exempt = True, and CsrfViewMiddleware checks that attribute on the outermost callable in the URLconf.
A hand-written decorator that returns a new inner function without copying attributes hides the exemption:
def add_cors(view):
def wrapper(request, *args, **kwargs):
response = view(request, *args, **kwargs)
response["Access-Control-Allow-Origin"] = "*"
return response
wrapper.__name__ = view.__name__ # csrf_exempt is NOT copied
return wrapper
@add_cors
@csrf_exempt
def token(request):
...
Here token is really wrapper, which has no csrf_exempt attribute, so the middleware enforces CSRF again.
Unit tests often miss this because Django’s test Client skips CSRF checks by default.
Fix
Use functools.wraps in the custom decorator. It copies the wrapped function’s __dict__, including csrf_exempt:
from functools import wraps
def add_cors(view):
@wraps(view)
def wrapper(request, *args, **kwargs):
response = view(request, *args, **kwargs)
response["Access-Control-Allow-Origin"] = "*"
return response
return wrapper
Alternatively, apply @csrf_exempt as the outermost decorator so the attribute lands on the final callable:
@csrf_exempt
@add_cors
def token(request):
...
Verify
Add a regression test with a client that enforces CSRF, as real requests do:
from django.test import Client, TestCase
class CsrfExemptionTests(TestCase):
def test_token_endpoint_does_not_require_csrf(self):
client = Client(enforce_csrf_checks=True)
response = client.post("/oauth/token", {"grant_type": "refresh_token"})
self.assertNotEqual(response.status_code, 403)
Without the fix this test returns 403; with it, the view’s own response is returned.
You can also check the attribute directly:
from myapp.views import token
assert getattr(token, "csrf_exempt", False) is True
Notes
- The same applies to other attribute-based Django markers.
- Django’s built-in decorators such as
require_http_methodsalready usewraps, so stacking them in either order is safe. The problem only comes from custom decorators. - Tested with Django 6.0 and Python 3.12.