skip to content

A Django page's fetch() POST returns 403 'CSRF token missing'; how do you send the token correctly instead of exempting the view?

level: middleimportance: must knowfreq 72%

answer

  1. the body is JSON, not a form
  2. a custom request header
  3. HTTP_X_CSRFTOKEN in request.META
  4. read the csrftoken cookie
  5. ensure_csrf_cookie on the page view

basics

~20 s

Read the token from the csrftoken cookie, or from a rendered csrfmiddlewaretoken input, and send it in an X-CSRFToken request header. Django reads the token only from form-encoded POST data or that header, never from a JSON body.

solid answer

~40 s

`CsrfViewMiddleware` looks for the token in `request.POST["csrfmiddlewaretoken"]` first and falls back to the header named by `CSRF_HEADER_NAME` (default `HTTP_X_CSRFTOKEN`, i.e. `X-CSRFToken`). A `fetch()` that sends JSON leaves `request.POST` empty, so a token placed inside the JSON is never seen and the request fails with "CSRF token missing." The fix is to read the `csrftoken` cookie in JavaScript and send it as `X-CSRFToken`. If the page never rendered `{% csrf_token %}`, the cookie may not exist and the reason becomes "CSRF cookie not set."; decorate the view that serves the page with `ensure_csrf_cookie`. Read the cookie at request time rather than once at page load, because `login()` rotates the secret. `csrf_exempt` would only silence the error by removing the protection from an endpoint the browser authenticates with its session cookie.

code

python · 22 lines
python
import json

from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.shortcuts import render
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_POST


@ensure_csrf_cookie
def report_builder(request):
    # Forms on this page are built in JavaScript, so no {% csrf_token %} is
    # rendered; the decorator still makes the response set the csrftoken cookie.
    return render(request, "reports/builder.html")


@login_required
@require_POST
def rename_report(request, pk):
    data = json.loads(request.body)  # request.POST is empty for JSON bodies
    request.user.reports.filter(pk=pk).update(title=data["title"])
    return JsonResponse({"ok": True})

go deeper

for a junior

Remember that AJAX requests send the Django CSRF token in an X-CSRFToken header, and that the value normally comes from the csrftoken cookie.

for a middle

Explain the lookup order, POST field then header, why a JSON body is invisible to it, and when ensure_csrf_cookie is needed to get the cookie set at all.

for a senior

Diagnose from the 403 reason string, account for token rotation at login, and prove the fix with a test client that enforces CSRF instead of trusting the default one.

for a principal

Standardise one front-end helper that attaches the header to every unsafe request, so exemptions never become the team's workaround for a missing token.

## Where Django looks for the token A **CSRF token** is the second copy of the visitor's CSRF secret that must accompany every unsafe request (anything other than GET, HEAD, OPTIONS or TRACE). The first copy is the `csrftoken` cookie. `django.middleware.csrf.CsrfViewMiddleware` searches for the second copy in a fixed order: 1. For a POST, it reads `request.POST.get("csrfmiddlewaretoken")`. `request.POST` is only populated for form encodings (`application/x-www-form-urlencoded` and `multipart/form-data`). 2. If that is empty, it reads `request.META[settings.CSRF_HEADER_NAME]`. The default is `"HTTP_X_CSRFTOKEN"`, which is how Django stores a request header sent as `X-CSRFToken`. 3. If neither is present, the request is rejected with the reason "CSRF token missing." A typical `fetch()` sends `Content-Type: application/json`. Django does not parse JSON into `request.POST`, so a `csrfmiddlewaretoken` key inside the JSON body is invisible to the middleware. PUT, PATCH and DELETE never get the POST-field path at all. For AJAX, the **header is the supported channel**. ## Reading the 403 reason With `DEBUG = True` the 403 page shows the reason; in production the same text is logged as a warning on the `django.security.csrf` logger. Each reason points to a different fix: | Reason | What it means | Fix | |---|---|---| | `CSRF cookie not set.` | no `csrftoken` cookie arrived | make the page view set it with `ensure_csrf_cookie` | | `CSRF token missing.` | no POST field and no header | add the `X-CSRFToken` header | | `CSRF token from the 'X-Csrftoken' HTTP header incorrect.` | header present but for another secret | stop caching the token; read it at send time | | `CSRF token from the 'X-Csrftoken' HTTP header has incorrect length.` | header holds something that is not a token | check what the script reads, e.g. `undefined` or a wrong cookie | | `Origin checking failed - … does not match any trusted origins.` | request came from another origin | see `CSRF_TRUSTED_ORIGINS` | The cookie is checked before the token, so "CSRF cookie not set." can mask a missing header. ## Getting the token into JavaScript - **From the cookie (default).** With `CSRF_USE_SESSIONS` and `CSRF_COOKIE_HTTPONLY` both at their default `False`, JavaScript can read `document.cookie` and take the `csrftoken` value. The cookie holds the unmasked secret; the middleware accepts either the masked 64-character form or the unmasked 32-character one. - **From the DOM.** If either setting is `True`, the cookie is not readable, so render `{% csrf_token %}` somewhere and read the hidden input's value. - **Guaranteeing the cookie exists.** The middleware only sends the cookie when something calls `get_token()`. A page whose forms are built entirely in JavaScript never renders the tag, so decorate the view that serves that page with `django.views.decorators.csrf.ensure_csrf_cookie`. ## Sending it Set the header on every unsafe request. Django's own guide also passes `mode: "same-origin"` so the token is never sent to another domain by mistake. If your front-end library insists on another header name, point Django at it rather than rewriting the library: `CSRF_HEADER_NAME` takes the `request.META` form, so a header sent as `X-XSRF-TOKEN` becomes `"HTTP_X_XSRF_TOKEN"`. ## Pitfalls - **Caching the token at page load.** `django.contrib.auth.login()` calls `rotate_token()`, so after a login through an AJAX modal the cached value is wrong. Read the cookie when you send. - **Reaching for `csrf_exempt`.** The endpoint is called by a browser that carries the session cookie, which is exactly the case CSRF protection exists for; exempting it lets a forged request make that call on the user's behalf wherever the browser still attaches the session cookie. - **Tests that pass while production fails.** `django.test.Client` skips CSRF checks by default, so a missing header only shows up in the browser. Construct `Client(enforce_csrf_checks=True)` for the test that proves the header is sent. - **Assuming the fix worked because the error changed.** Confirm in the browser's network panel that the request carries both the `csrftoken` cookie and an `X-CSRFToken` header with a 32- or 64-character alphanumeric value, and that the server's `django.security.csrf` warnings stop for that path. A header holding `null` or `undefined` means the script found no cookie, which points back to `ensure_csrf_cookie` or to `CSRF_COOKIE_HTTPONLY`. - **Assuming CORS is the culprit.** A same-origin `fetch()` involves no CORS at all; a 403 from Django with a CSRF reason is the middleware, not the browser.

  • Your front-end HTTP library sends the token as X-XSRF-TOKEN and reads a cookie called XSRF-TOKEN; how do you make Django accept that?
    Set `CSRF_HEADER_NAME = "HTTP_X_XSRF_TOKEN"`, because the setting names the `request.META` key: upper-cased, hyphens turned to underscores, `HTTP_` prefixed. Set `CSRF_COOKIE_NAME = "XSRF-TOKEN"` so the cookie the library looks for is the one Django writes. Setting the header name to the raw `"X-XSRF-TOKEN"` silently never matches.
  • The fetch() calls worked until the user logged in through an AJAX modal, then every call returns 403. Why?
    `login()` calls `rotate_token()`, so the login response carries a new `csrftoken` cookie with a new secret. Code that read the token once at page load keeps sending a token for the old secret, and the middleware reports it as incorrect. Reading the cookie inside the function that sends each request picks up the rotated value.
  • Your Django test posts JSON to the endpoint with the default test client and passes; how do you test that the missing header really 403s?
    `django.test.Client` defaults to `enforce_csrf_checks=False` and marks every request so the middleware skips the check. Create `Client(enforce_csrf_checks=True)`, fetch a page first so the `csrftoken` cookie lands in the client's cookie jar, then assert a POST without `X-CSRFToken` gets 403 and one with the cookie's value in the header succeeds.

saying these in an interview costs you the question

  • Put csrfmiddlewaretoken inside the JSON body and Django will find it
  • A 403 from a same-origin fetch() means CORS is blocking it
  • Mark the API view csrf_exempt since the user is logged in anyway
  • Read the token once at page load and reuse it for the session
  • Set CSRF_HEADER_NAME to the raw header name, like X-XSRF-TOKEN