Django / Editorial solution guide

Diagnose CSRF 403 responses without disabling protection

Separate missing tokens, cookie delivery, origin checks, and trusted proxy assumptions before changing the request contract.

About 4 min readContent reviewed

Read-only knowledge base · No public comments

Recognize the symptoms

A form or browser JSON submission receives 403 even though the same user can view the page. The error may appear only after login, on a different hostname, or behind an HTTPS proxy. A successful ordinary Django test Client POST may not reproduce it because that client relaxes CSRF enforcement by default.

Understand the likely causes

  • The request can lack the required token, send a token that does not match the current cookie secret, omit the cookie under its delivery policy, or fail origin/referer checks. Login can rotate CSRF state, so a stale page is another possible source.
  • CSRF, authentication, form validity, and resource permission are different checks. A valid session does not bypass the CSRF contract, and adding an origin to ALLOWED_HOSTS does not create a CSRF trusted-origin entry.

1. Identify the actual rejecting boundary

Record the method, internal destination, browser origin, response status, and safe diagnostic reason from restricted server evidence. Do not publish cookies, CSRF secrets, session tokens, or complete settings dumps. Confirm whether the response came from Django’s CSRF middleware, application authorization, or an upstream component. Reproduce with synthetic data through the intended origin and proxy path. A GET page loading successfully only proves that read path; it does not establish an unsafe POST’s token and origin checks. Preserve the first concrete reason rather than changing several protection settings simultaneously.

Verify request-aware template rendering and the csrf_token tag for an internal form, or the documented header/token arrangement for the selected AJAX client. Inspect cookie domain, path, secure behavior, same-site policy, and whether the browser actually includes the cookie on this request. If JavaScript cannot read the cookie under the intended policy, use the documented DOM-token arrangement rather than weakening the cookie globally. Reload a stale form after login when its token no longer represents current CSRF state. Keep tokens away from forms that post to unrelated external destinations.

3. Review origin and proxy interpretation separately

Compare the actual browser origin with the intended target and any deliberately configured CSRF_TRUSTED_ORIGINS. These entries include schemes under the Django 5.2 contract and are not interchangeable with ALLOWED_HOSTS. A legitimate cross-origin frontend may also need separate browser credential and CORS policy; a CSRF setting does not implement all of that. If TLS terminates at a proxy, verify trusted scheme forwarding and the proxy’s control of client-supplied headers before enabling related trust settings. Do not fix an unexplained failure with a wildcard or csrf_exempt. Those changes can erase the protection rather than restore the intended path.

4. Verify rejection and a valid protected submission

The complete local fixture enables CSRF checking, rejects a tokenless write, obtains a fresh token from an internal GET, and sends the matching header with the client’s cookie. It opens no server and performs no write. This proves one isolated middleware contract, not the deployed proxy or browser configuration. Repeat the actual repaired flow with an approved synthetic account and verify both a valid submission and an expected negative case. Keep authorization and form validation tests separate. A useful repair explains why the original request failed and which intended trust boundary remains enforced.

python Isolated executable example
from django.conf import settings
settings.configure(
    SECRET_KEY="synthetic-course-key",
    USE_I18N=False, USE_TZ=True,
    INSTALLED_APPS=[],
    DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
    DEFAULT_AUTO_FIELD="django.db.models.AutoField",
    ALLOWED_HOSTS=["testserver"],
)
import django
django.setup()

import json
from django.http import HttpResponse, JsonResponse
from django.middleware.csrf import get_token
from django.test import Client, override_settings
from django.urls import path

def endpoint(request):
    if request.method == "GET":
        return JsonResponse({"csrf": get_token(request)})
    return HttpResponse("accepted", content_type="text/plain")

urlpatterns = [path("form/", endpoint)]
with override_settings(ROOT_URLCONF=__name__, MIDDLEWARE=["django.middleware.csrf.CsrfViewMiddleware"], TEMPLATES=[{"BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": False}]):
    client = Client(enforce_csrf_checks=True)
    print(client.post("/form/", {}).status_code)
    token = json.loads(client.get("/form/").content)["csrf"]
    print(client.post("/form/", {}, HTTP_X_CSRFTOKEN=token).status_code)
Expected output
403
200

The fixture performs only in-process synthetic requests. The first is intentionally rejected; the fresh token and cookie permit the second.

Verify the fix

The original browser flow succeeds through the intended origin while tokenless or disallowed-origin requests remain rejected. Verify cookie continuity after login and inspect the actual proxy path where relevant. Do not claim the isolated fixture tested those deployment layers.

Keep it from recurring

  • Keep CSRF middleware and request-aware forms standard, document origin/proxy ownership, and retain protected positive and negative regression cases. Avoid broad protection exceptions as a routine debugging technique.

Official references