Django Intermediate #5: Messages / Sessions / Cookies
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.
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 #
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:
| Function | Level | Use |
|---|---|---|
messages.debug | DEBUG | Dev debugging |
messages.info | INFO | Simple info |
messages.success | SUCCESS | Success notice |
messages.warning | WARNING | Warning |
messages.error | ERROR | Failure |
DEBUG is hidden by default. Turn it on with MESSAGE_LEVEL = messages.DEBUG.
In CBV — SuccessMessageMixin
#
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 #
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li class="message message--{{ message.tags }}">
{{ message }}
</li>
{% endfor %}
</ul>
{% endif %}message.tags—success,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.
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 #
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.
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 changesSession expiry — set_expiry
#
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 #
SESSION_ENGINE = "django.contrib.sessions.backends.db" # default| Backend | Storage | When |
|---|---|---|
db (default) | DB (django_session table) | Almost everywhere — the default works well |
cache | Cache (Redis, etc.) | Very fast. Disappears if cache is cleared (caution) |
cached_db | Cache → DB fallback | DB durability + cache speed |
file | Filesystem | Single server, low traffic |
signed_cookies | The 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:
python manage.py clearsessionsCookies — the lowest level #
Small values like session IDs, CSRF tokens, and locale preferences are sometimes handled directly as cookies.
Reading/writing cookies #
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 responserequest.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; HttpOnlyBlocks 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:
| Default | Meaning | |
|---|---|---|
SESSION_COOKIE_HTTPONLY | True | Block JS access for session cookie |
CSRF_COOKIE_HTTPONLY | False | JS 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; SecureSent only over HTTPS. Cookies don’t go over HTTP. Always on in 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 = TrueSecurityMiddleware (#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.
| Value | Meaning |
|---|---|
Strict | Only same-site requests (not even sent on external link clicks) |
Lax | Default recommendation. Sent on safe top-level navigation like GET, not on POST etc. |
None | All requests (Secure must also be on) |
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:
{% csrf_token %}in the form → inserts a hidden input with the token- Same token stored in a cookie (CSRF cookie)
- POST/PUT/DELETE requests compare the two values (
CsrfViewMiddleware) - Cookie and form value mismatch → 403 returned
For JavaScript fetch, send the token alongside in the X-CSRFToken header.
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.
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:
- Messages —
messages.success/info/warning/error/debug, template{% for message in messages %} - CBV uses
SuccessMessageMixin - Message backends — default
FallbackStorageis usually the answer - Sessions —
request.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 - Cookies —
request.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-CSRFTokenheader - 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 perspective — collectstatic, MEDIA_*, S3 / WhiteNoise / Storage backends.