CSRF Verification Failed: What Django Is Actually Telling You
CSRF Verification Failed: What Django Is Actually Telling You
Forbidden (403)
CSRF verification failed. Request aborted.
Nothing changed in your form. It worked yesterday. It works locally. And the first search result
suggests decorating the view with @csrf_exempt, which is roughly like fixing a smoke alarm by
removing the battery.
What the protection is for
Cross-site request forgery works like this. You are logged into your bank. You visit an unrelated
page which contains a hidden form that submits to your bank's transfer endpoint. Your browser
helpfully attaches your session cookie, because that is what browsers do, and the transfer happens
with your authority.
Django prevents this by requiring a token that the attacking site cannot read: it is bound to your
session and delivered on your own pages only. A request without a matching token is rejected. That
is the 403 you are looking at.
The six real causes
The token is missing from the form. Every POST form needs {% csrf_token %} inside the form
element. If the form is rendered by JavaScript, the token must be included there too.
The cookie never arrived. The token is validated against a cookie. If cookies are blocked, or the
cookie was set on a different domain than the one submitting, validation fails. Test in a fresh
private window before assuming anything more complicated.
A proxy is rewriting the scheme. Your proxy terminates HTTPS and forwards HTTP to Django. Django
believes the request is insecure, decides the referer does not match, and rejects it. Fix it by
telling Django what to trust:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
proxy_set_header X-Forwarded-Proto $scheme;
The origin is not trusted. If the form is served from one hostname and submitted to another —
including apex versus www — Django requires the origin to be listed:
CSRF_TRUSTED_ORIGINS = ["https://example.com", "https://www.example.com"]
Note the scheme is required. A bare hostname is not accepted.
The session expired. The user left the form open, the session ended, and the token no longer
matches anything. Technically correct behaviour and a poor experience — catch it and re-render the
form with a clear message rather than showing the default page.
AJAX without the header. A fetch or XHR POST must send the token as a header:
fetch("/api/thing/", {
method: "POST",
headers: {"X-CSRFToken": getCookie("csrftoken"), "Content-Type": "application/json"},
body: JSON.stringify(data),
});
Diagnosing it quickly
Turn on the detailed reason temporarily — Django logs the specific cause under the
django.security.csrf logger. The reason string tells you which of the six you are dealing with,
and saves you from guessing.
Then check, in order: does the form contain a token; does the browser hold the cookie; does the
scheme Django sees match reality; is the submitting origin trusted.
Why not just exempt the view
Because the exemption is permanent and the reason you added it is forgotten within a month. A login
form, a payment endpoint or an account settings page without this protection is exploitable in a way
that requires no access to your systems at all — only a user who is logged in and visits the wrong
page.
If you genuinely have an endpoint that cannot use it, such as an incoming webhook, that endpoint
must authenticate the caller some other way: a signature over the payload, or a shared secret. It
should never simply be unprotected.
For the wider picture of hardening a Django deployment, see Firewall and SSH Hardening for Django
Servers.