Video coming soon

The recording for this lesson has not been published yet.

What we cover

  • django.contrib.messages levels and tags
  • Rendering messages in the base template
  • csrf_token, CSRF cookies and AJAX requests
  • The post-redirect-get pattern

Lesson notes

1. Post-redirect-get, and why a message is needed at all

When a form submits successfully you must not render a page in response to the POST. You redirect. Otherwise the browser remembers a POST for that URL, and a refresh or a back button re-submits it - a second comment, a second payment.

  • POST the data.
  • Redirect with 302 to a page the user can safely reload.
  • GET that page.

But a redirect throws away your context: the new page has no idea that something just succeeded. That is exactly the gap the messages framework fills - it stores one short notice in the session and shows it on the very next request.

2. The messages framework is already installed

startproject wires it up for you. Check that all three pieces are present:

# mysite/settings.py
INSTALLED_APPS = [
    ...
    "django.contrib.messages",
]

MIDDLEWARE = [
    ...
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
]

TEMPLATES = [
    {
        ...
        "OPTIONS": {
            "context_processors": [
                ...
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

3. Levels and tags

ShortcutLevelTagUse it for
messages.debug()10debugDevelopment noise - hidden by default
messages.info()20infoNeutral facts: "your draft was autosaved"
messages.success()25successThe action worked
messages.warning()30warningIt worked, but read this
messages.error()40errorIt did not work
from django.contrib import messages


def post_new(request):
    form = PostForm(request.POST or None)
    if request.method == "POST":
        if form.is_valid():
            post = form.save(commit=False)
            post.author = request.user
            post.save()
            messages.success(request, f"'{post.title}' was published.")
            return redirect(post.get_absolute_url())
        messages.error(request, "The post was not saved - see the errors below.")
    return render(request, "blog/post_edit.html", {"form": form})

The default level is INFO, so debug messages are dropped. Change it globally, or make Bootstrap and Tabler happy by mapping the error tag to the CSS class you actually use:

# mysite/settings.py
from django.contrib.messages import constants as message_constants

MESSAGE_LEVEL = message_constants.DEBUG if DEBUG else message_constants.INFO

MESSAGE_TAGS = {
    message_constants.DEBUG: "secondary",
    message_constants.INFO: "info",
    message_constants.SUCCESS: "success",
    message_constants.WARNING: "warning",
    message_constants.ERROR: "danger",   # Bootstrap has no "error" class
}

4. Render them once, in the base template

Put this in base.html and every page in the project gets feedback for free.

{% if messages %}
  <div class="container-xl mt-3">
    {% for message in messages %}
      <div class="alert alert-{{ message.tags }} alert-dismissible" role="alert">
        <div>{{ message }}</div>
        <a class="btn-close" data-bs-dismiss="alert" aria-label="close"></a>
      </div>
    {% endfor %}
  </div>
{% endif %}

5. What CSRF actually is

You have typed {% csrf_token %} in every form without being told why. Here is the attack it stops.

Your session cookie is sent by the browser on every request to your domain, including requests started by somebody else's page. So evil.example can contain a hidden form that posts to yourblog.example/post/5/delete/, and if you are logged in, the browser attaches your cookie and the delete succeeds. You never clicked anything on your own site. That is cross-site request forgery.

The fix is to require a second proof that the request came from a page you served. Django uses the double-submit pattern: the same secret has to arrive in a cookie and in the POST body. The attacker's page can make the browser send your cookies, but it cannot read them, so it cannot put the matching value in the body.

GET /post/new/ your site response sets csrftoken cookie + hidden input csrfmiddlewaretoken masked, so both differ per request POST from your page cookie + token in the body CsrfViewMiddleware compares cookie vs body (and Origin) match -> view runs POST from evil.example cookie is sent automatically but it cannot read it 403 Forbidden CSRF verification failed safe methods (GET, HEAD, OPTIONS) are never checked - so keep them safe

6. Using it correctly

  • CsrfViewMiddleware is in MIDDLEWARE by default. Leave it there.
  • Every <form method="post"> that points at your own site needs {% csrf_token %}, including delete and logout forms.
  • Never make a state-changing action a GET. A <a href="/post/5/delete/"> is not CSRF-protected, and a link prefetcher or an email client will eventually click it for you. Use a small POST form with a button.
  • In production over HTTPS, set CSRF_COOKIE_SECURE = True and list your real domains in CSRF_TRUSTED_ORIGINS (with the scheme: ["https://myblog.example"]).
<form method="post" action="{% url 'blog:post_delete' post.pk %}">
  {% csrf_token %}
  <button class="btn btn-danger">Delete</button>
</form>

7. CSRF and fetch()

A JSON request has no hidden input, so send the token in the X-CSRFToken header. Read it from the cookie:

function getCookie(name) {
  return document.cookie.split("; ")
    .find((row) => row.startsWith(name + "="))
    ?.split("=")[1];
}

await fetch("/comments/42/like/", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-CSRFToken": getCookie("csrftoken"),
  },
  body: JSON.stringify({ value: 1 }),
});

Two prerequisites. The cookie must exist, which means the page was rendered by a view that used the token - add @ensure_csrf_cookie to the view if it renders no form. And it must be readable from JavaScript, so leave CSRF_COOKIE_HTTPONLY at its default of False. HTMX sends the header for you if you configure it once:

<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>

8. Testing both

from django.contrib.messages import get_messages
from django.test import Client, TestCase


class CommentFlowTests(TestCase):
    def test_success_message_and_redirect(self):
        response = self.client.post(f"/post/{self.post.pk}/", {"body": "Nice one"})
        self.assertRedirects(response, f"/post/{self.post.pk}/#comments")
        texts = [str(m) for m in get_messages(response.wsgi_request)]
        self.assertIn("Thanks!", texts[0])

    def test_post_without_csrf_token_is_rejected(self):
        client = Client(enforce_csrf_checks=True)   # off by default in tests
        response = client.post(f"/post/{self.post.pk}/", {"body": "Nope"})
        self.assertEqual(response.status_code, 403)

The test client skips CSRF checks so your tests stay readable; enforce_csrf_checks=True turns them back on for the one test where that is the point.

9. Exercise

  1. Add the messages block to base.html and the MESSAGE_TAGS mapping so that errors render as alert-danger.
  2. Add a success message plus a redirect to your post create, post edit and comment views, then refresh after a submit and confirm nothing is posted twice.
  3. Loop over messages twice in the base template and watch the second loop come out empty. Undo it.
  4. Remove {% csrf_token %} from the comment form, submit, and read the 403 debug page top to bottom. Put it back.
  5. Replace any delete link with a POST form and a button.
  6. Write the two tests above, including the one with enforce_csrf_checks=True.

Further reading

After this lesson you will

  • Give users feedback after every form submit
  • Explain what the CSRF token actually protects