skip to content

When is Django's csrf_exempt safe on a view, such as an inbound webhook, and what must replace the protection it removes?

level: seniorimportance: should knowfreq 45%

answer

  1. who carries ambient credentials?
  2. no browser, no cookie
  3. authenticate the body instead
  4. constant-time comparison
  5. decorate dispatch, not post

basics

~20 s

csrf_exempt is safe only on a view that grants nothing on the strength of browser cookies, such as a server-to-server webhook. Replace the lost check with the caller's own authentication, typically a signature over the raw body, verified before acting.

solid answer

~40 s

CSRF protection exists because a browser can attach the victim's cookies to a forged request. A webhook caller is another server: it has no `csrftoken` cookie and cannot send a token, so `CsrfViewMiddleware` would 403 every delivery, and `@csrf_exempt` is the right tool. It is safe only if the view authorises nothing through the session or `request.user`, and it authenticates the caller another way, typically an HMAC signature over `request.body` compared with `hmac.compare_digest()`. Exempting a view that a logged-in browser can also reach, just to make a `fetch()` stop failing, reopens exactly the attack the middleware blocks. On a class-based view, apply it with `method_decorator(csrf_exempt, name="dispatch")` or wrap `as_view()` in the URLconf, because the middleware inspects the callable that `as_view()` returns, and `as_view()` copies decorator-set attributes from `dispatch`, not from `post()`.

code

python · 25 lines
python
import hashlib
import hmac
import json

from django.conf import settings
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from .services import record_shipment_update  # your domain logic


@csrf_exempt
@require_POST
def shipping_webhook(request):
    sent = request.headers.get("X-Signature", "")  # header name is the provider's
    expected = hmac.new(
        settings.SHIPPING_WEBHOOK_SECRET.encode(),
        request.body,
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(sent, expected):
        return HttpResponseForbidden("bad signature")
    record_shipment_update(json.loads(request.body))
    return HttpResponse(status=204)

go deeper

for a junior

Know that csrf_exempt switches Django's CSRF check off for one view, and that it is meant for callers that are not browsers.

for a middle

Explain why a webhook cannot send a token, how the middleware finds the exemption, and why a class-based view must exempt dispatch.

for a senior

Pair every exemption with real caller authentication over the raw body, keep cookie-based authorisation out of exempt views, and test with a CSRF-enforcing client.

for a principal

Treat each csrf_exempt as a reviewed security decision with a named alternative authentication, and keep browser-facing and machine-facing endpoints apart.

## Why the middleware blocks webhooks `CsrfViewMiddleware` rejects any unsafe request (anything but GET, HEAD, OPTIONS or TRACE) that lacks the `csrftoken` cookie and a matching token. That requirement is aimed at **browsers**: the attack it stops is a page on another site making the victim's browser send a request that the browser can decorate with the victim's cookies, the **ambient credentials**. A **webhook** is an HTTP callback from another company's server, for example a shipping carrier announcing a delivery. That server never loaded one of your pages, holds no CSRF cookie and cannot produce a token. Every delivery gets a 403: over HTTPS, with no `Origin` or `Referer` header, the reason is "Referer checking failed - no Referer."; over plain HTTP it is "CSRF cookie not set." The fix is not to fake a token but to take the view out of CSRF checking and authenticate the caller properly. ## The rule for exempting `csrf_exempt` (from `django.views.decorators.csrf`) sets an attribute on the view function; `CsrfViewMiddleware.process_view()` sees it and returns without checking. The view is then safe only when: - **nothing in it trusts cookies**: no authorisation from `request.user`, the session, or a permission check that a logged-in browser would satisfy; - **the caller is authenticated another way**: a signature, a shared secret in a header, or mutual TLS at the edge; - **browsers have no reason to call it**: if your own front end also uses the URL, it needs the normal token path, not an exemption. Exempting an endpoint just because an AJAX call failed is the classic misuse. The user's session cookie can still ride along on forged requests to that endpoint (a `SameSite=Lax` session cookie narrows this but does not close it, for example for requests from a sibling subdomain), so the view is back to trusting ambient credentials. ## Replacing the protection The usual pattern for a signed webhook: 1. Read the **raw bytes** from `request.body`. Do not re-serialise parsed JSON, because the signature covers the exact bytes the sender produced. 2. Compute the expected signature with the provider's algorithm and your shared secret, typically `hmac.new(secret, body, hashlib.sha256)`. 3. Compare with **`hmac.compare_digest()`**, which does not stop at the first differing byte, rather than `==`. 4. Reject a bad signature with 403 before touching the database. 5. Only then parse and act, ideally idempotently, since providers retry deliveries. The header name and signing scheme are the provider's; the steps are the same. The exemption is narrow. Every other middleware, from sessions and authentication to security headers, still runs for the view, and the CSRF middleware has already processed the incoming cookie before it bails out, so `get_token()` keeps working inside an exempt view. Only the reject-or-accept decision is skipped. ## The related decorators All four live in `django.views.decorators.csrf`, and since Django 5.0 all four accept async views as well. | Decorator | Can reject a request? | Forces the cookie? | Use it for | |---|---|---|---| | `csrf_exempt` | no | no | server-to-server endpoints authenticated another way | | `csrf_protect` | yes, exactly like the middleware | no | views that must stay protected even if the middleware is removed, as the contrib apps do | | `requires_csrf_token` | no | no, only if the token is used | 404/500 handlers or exempt views that still render `{% csrf_token %}` | | `ensure_csrf_cookie` | no | yes | pages whose forms are built in JavaScript | Django's docs recommend keeping the middleware on and exempting the few views that need it, rather than removing the middleware and applying `csrf_protect` view by view, because a forgotten decorator is a silent hole. ## Class-based views and URLconfs The middleware looks for the `csrf_exempt` attribute on the **callable the URLconf resolves to**, which for a class-based view is the function returned by `View.as_view()`. `as_view()` copies the attributes that decorators set on the class's `dispatch` method onto that function; it does not look at `post()` or at class attributes. So: - `@method_decorator(csrf_exempt, name="dispatch")` on the class works; - `path("hooks/shipping/", csrf_exempt(ShippingWebhookView.as_view()))` works; - decorating `post()`, or setting a `csrf_exempt = True` class attribute, silently does nothing. There is no setting that exempts a URL prefix; every exempt view is marked individually, which keeps each exemption visible in review. ## Testing the exemption `django.test.Client` skips CSRF checks by default, so a webhook test passes whether or not the view is exempt. Build the test client with `Client(enforce_csrf_checks=True)` to prove that the exemption is in place and that the signature check, not the token, is what guards the view.

  • An exempt Django view still renders a template containing {% csrf_token %}; what do you add so the tag works?
    Stack `requires_csrf_token` under `csrf_exempt`, as the innermost decorator. It runs the middleware's token setup for that view without ever rejecting the request, so the context has a token for the tag while the view itself stays unchecked.
  • Your webhook tests pass but production answers every delivery with 403. How can that happen?
    `django.test.Client` is created with `enforce_csrf_checks=False`, so tests skip the CSRF check and cannot notice a missing `csrf_exempt`, for example one put on `post()` instead of `dispatch`. Test the webhook with `Client(enforce_csrf_checks=True)` and no cookies, as the real caller sends it.
  • Can you exempt a whole URL prefix, such as /hooks/, in Django settings?
    No. Django has no setting for CSRF-exempt paths; the middleware only honours the attribute on each resolved view. Mark each webhook view individually. That is deliberate friction: every exemption shows up in the code where a reviewer can ask how the caller is authenticated.

saying these in an interview costs you the question

  • csrf_exempt is fine on any view that returns JSON
  • An exempt view is safe as long as it requires login
  • Comparing webhook signatures with == is good enough
  • Putting csrf_exempt on a class-based view's post() method works
  • Remove the middleware and add csrf_protect where needed