Django Intermediate #5: Messages / Sessions / Cookies

7 min read

HTTP is stateless. By default, requests don’t know anything about each other. Three tools layer the concept of “the same user” on top of that.

  • Messages — pass briefly from one request to the next (flash)
  • Sessions — state across many requests for one user
  • Cookies — the lowest-level medium that makes the two above possible

This post goes top to bottom — from messages all the way to cookie security, all in one place.

The MessageMiddleware and SessionMiddleware we saw in #3 are what makes this work.

Messages — the flash pattern #

A notification like “Saved” that appears once on the next page after a redirect. Building this yourself means writing session-touching code every time, but Django solves it with the messages framework.

Setup (usually automatic) #

Projects created with startproject already have it.

settings.py — verify
INSTALLED_APPS = [
    ...
    "django.contrib.messages",
]

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

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

Adding messages in views #

blog/views.py
from django.contrib import messages
from django.shortcuts import redirect

def post_create(request):
    if request.method == "POST":
        form = PostForm(request.POST)
        if form.is_valid():
            post = form.save()
            messages.success(request, "The post has been saved.")
            return redirect("post_detail", pk=post.pk)
        messages.error(request, "Please check the input again.")
    else:
        form = PostForm()
    return render(request, "blog/post_form.html", {"form": form})

Per-level helpers:

FunctionLevelUse
messages.debugDEBUGDev debugging
messages.infoINFOSimple info
messages.successSUCCESSSuccess notice
messages.warningWARNINGWarning
messages.errorERRORFailure

DEBUG is hidden by default. Turn it on with MESSAGE_LEVEL = messages.DEBUG.

In CBV — SuccessMessageMixin #

CBV
from django.contrib.messages.views import SuccessMessageMixin
from django.views.generic.edit import CreateView

class PostCreateView(SuccessMessageMixin, CreateView):
    model = Post
    form_class = PostForm
    success_message = "%(title)s post saved."

Insert model fields into the message via %(field)s. (The Mixin pattern from #1 CBV.)

Rendering in templates #

base.html — render all messages in one place
{% if messages %}
  <ul class="messages">
    {% for message in messages %}
      <li class="message message--{{ message.tags }}">
        {{ message }}
      </li>
    {% endfor %}
  </ul>
{% endif %}
  • message.tagssuccess, error, etc. (use as CSS class)
  • Once rendered, they automatically disappear — they won’t be shown again on the next request

Message backends #

The default backend is FallbackStorage — it tries session and falls back to cookie if not available.

settings.py — backend can be changed
MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage"
# or "...cookie.CookieStorage"

The cookie backend can grow headers, and the session backend depends on middleware order. The default FallbackStorage is usually the answer.

Session — state across requests #

A session is a dict-like store kept on the server per user. The client only carries the session ID in a cookie, while the actual data stays on the server (with the default backend).

Basic usage #

blog — recently viewed posts
def post_detail(request, pk):
    post = get_object_or_404(Post, pk=pk)

    recent = request.session.get("recent_posts", [])
    if pk in recent:
        recent.remove(pk)
    recent.insert(0, pk)
    recent = recent[:10]
    request.session["recent_posts"] = recent

    return render(request, "blog/post_detail.html", {"post": post})

The API is similar to a regular dict.

session API
request.session["key"] = "value"        # store
request.session.get("key")              # read (None if missing)
request.session.get("key", default)     # default value
del request.session["key"]              # delete
"key" in request.session                # existence check

request.session.flush()                  # clear all and create new session key
request.session.cycle_key()              # rotate key only (keep data) — recommended after permission changes

Session expiry — set_expiry #

expiry control
request.session.set_expiry(60 * 60)     # 1 hour (seconds)
request.session.set_expiry(0)           # expire when browser closes
request.session.set_expiry(None)        # default (settings.SESSION_COOKIE_AGE)

The default expiry is SESSION_COOKIE_AGE = 1209600 (2 weeks). Use set_expiry(0) or a longer value for features like a “Stay logged in” checkbox.

Session backends — where to store #

settings.py
SESSION_ENGINE = "django.contrib.sessions.backends.db"  # default
BackendStorageWhen
db (default)DB (django_session table)Almost everywhere — the default works well
cacheCache (Redis, etc.)Very fast. Disappears if cache is cleared (caution)
cached_dbCache → DB fallbackDB durability + cache speed
fileFilesystemSingle server, low traffic
signed_cookiesThe cookie itself (signed)Stateless server. Data size limit, sent every request

The usual answer in production: db or cached_db. If Redis is already there, cache or cached_db for acceleration.

signed_cookies can make the server completely stateless, but session data is sent on every request, so keep data small. Don’t put secrets in (only signed, not encrypted).

Cleaning up expired sessions #

The db backend doesn’t auto-remove expired rows. Clean up periodically:

run periodically via cron
python manage.py clearsessions

Cookies — the lowest level #

Small values like session IDs, CSRF tokens, and locale preferences are sometimes handled directly as cookies.

Reading/writing cookies #

working with cookies directly
def view(request):
    visited = request.COOKIES.get("visited", "0")
    visited = str(int(visited) + 1)

    response = render(request, "page.html", {"visited": visited})
    response.set_cookie(
        "visited",
        visited,
        max_age=60 * 60 * 24 * 30,   # 30 days
        httponly=True,
        secure=True,
        samesite="Lax",
    )
    return response

def logout_view(request):
    response = redirect("home")
    response.delete_cookie("visited")
    return response

request.COOKIES is a read-only object that holds all cookies as a dict. Writing happens on the response object.

Cookie security — HttpOnly / Secure / SameSite #

These three security attributes for cookies are fundamentals you must know.

HttpOnly #

Set-Cookie: sessionid=abc; HttpOnly

Blocks reading via JavaScript’s document.cookie. Even if XSS occurs, the session cookie can’t be stolen.

Session cookies and auth cookies must be HttpOnly. Django’s defaults for SESSION_COOKIE_HTTPONLY and CSRF_COOKIE_HTTPONLY:

DefaultMeaning
SESSION_COOKIE_HTTPONLYTrueBlock JS access for session cookie
CSRF_COOKIE_HTTPONLYFalseJS must put token in header, so it has to be readable

The CSRF cookie is the exception because — for fetch requests, the token must be sent in the X-CSRFToken header.

Secure #

Set-Cookie: sessionid=abc; Secure

Sent only over HTTPS. Cookies don’t go over HTTP. Always on in production.

settings.py — production
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True              # auto HTTP → HTTPS redirect
SECURE_HSTS_SECONDS = 31536000          # 1-year HSTS
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

SecurityMiddleware (#3) enforces these settings. Detailed production security in Advanced #7.

SameSite #

Controls whether the cookie is sent with requests originating from another domain. The strongest line of defense for CSRF.

ValueMeaning
StrictOnly same-site requests (not even sent on external link clicks)
LaxDefault recommendation. Sent on safe top-level navigation like GET, not on POST etc.
NoneAll requests (Secure must also be on)
settings.py
SESSION_COOKIE_SAMESITE = "Lax"     # default
CSRF_COOKIE_SAMESITE = "Lax"

Strict can make UX awkward — when a user enters via an external link they may appear logged out. Lax is usually the answer.

CSRF and cookies — briefly #

CSRF (Cross-Site Request Forgery) is an attack where another site exploits the user’s auth cookie to forge requests.

Django’s defense:

  1. {% csrf_token %} in the form → inserts a hidden input with the token
  2. Same token stored in a cookie (CSRF cookie)
  3. POST/PUT/DELETE requests compare the two values (CsrfViewMiddleware)
  4. Cookie and form value mismatch → 403 returned

For JavaScript fetch, send the token alongside in the X-CSRFToken header.

fetch + CSRF token
function getCookie(name) {
  const m = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)"));
  return m ? m[2] : null;
}

await fetch("/api/posts/", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-CSRFToken": getCookie("csrftoken"),
  },
  body: JSON.stringify(data),
});

If SameSite=Lax is on, simple POST forgeries are already blocked. The CSRF token serves as double defense.

Small real example — guest cart #

Sessions are a common fit here — temporarily storing an unauthenticated user’s cart in the session.

cart/views.py
def add_to_cart(request, product_id):
    cart = request.session.get("cart", {})
    cart[str(product_id)] = cart.get(str(product_id), 0) + 1
    request.session["cart"] = cart
    request.session.modified = True   # changes inside dicts aren't auto-detected!
    return redirect("cart_detail")

def cart_detail(request):
    cart = request.session.get("cart", {})
    products = Product.objects.filter(pk__in=cart.keys())
    items = [(p, cart[str(p.pk)]) for p in products]
    return render(request, "cart/detail.html", {"items": items})

One trap to know: Django doesn’t auto-detect mutations inside a dict. Either set request.session.modified = True, or — as in the code above — reassign the whole key with request.session["cart"] = cart.

Summary #

What we covered in this post:

  • Messagesmessages.success/info/warning/error/debug, template {% for message in messages %}
  • CBV uses SuccessMessageMixin
  • Message backends — default FallbackStorage is usually the answer
  • Sessionsrequest.session["key"], get, flush, cycle_key, set_expiry
  • Session backends: db (default), cache, cached_db, file, signed_cookies
  • Clean up expired sessions: python manage.py clearsessions
  • Cookiesrequest.COOKIES, response.set_cookie, response.delete_cookie
  • Security attributes:
    • HttpOnly — block JS access (required for session cookie)
    • Secure — sent only over HTTPS (required in production)
    • SameSite=Lax — strong line of defense for CSRF
  • Production settings: SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE, SECURE_SSL_REDIRECT, HSTS
  • CSRF token + cookie compare; fetch uses X-CSRFToken header
  • For changes inside a session dict, set request.session.modified = True

In the next post (#6 Static/Media operations), we revisit static files — first introduced in Basics #5 — from an operations perspectivecollectstatic, MEDIA_*, S3 / WhiteNoise / Storage backends.

X