---
title: "Django csrf_exempt Ignored When a Custom Decorator Wraps the View"
canonical: "https://techwiki.co.uk/general/django-csrf-exempt-ignored-custom-decorator"
author: "Adam Birds"
article_type: "documentation"
published: "2026-10-24T14:37:27.094Z"
updated: "2026-10-24T14:37:27.094Z"
categories: ["General"]
tags: ["django","python"]
---**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.**

```text
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:

```python
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`:

```python
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:

```python
@csrf_exempt
@add_cors
def token(request):
    ...
```

## Verify

Add a regression test with a client that enforces CSRF, as real requests do:

```python
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:

```python
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.
