Documentation

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
Adam Birds
Published 24 October 20261 min read0 views

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_methods already use wraps, so stacking them in either order is safe. The problem only comes from custom decorators.
  • Tested with Django 6.0 and Python 3.12.
Last updated: 24 October 2026v1