CSRF, XSS, SQL injection, and clickjacking
coreintermediateDjango defends against all four of these by default, and each defence has one specific way to switch it off. **CSRF** — `CsrfViewMiddleware` requires a secret token on unsafe methods, so a form on another site cannot make an authenticated request on the user's behalf; `@csrf_exempt` removes it. **XSS** — templates autoescape the characters that are dangerous in HTML; `|safe`, `mark_safe()` and `{% autoescape off %}` remove it. **SQL injection** — the ORM parameterises every query, sending SQL and values separately; string-formatting into `.raw()`, `extra()` or `RawSQL()` removes it. **Clickjacking** — `XFrameOptionsMiddleware` sends `X-Frame-Options: DENY`, so your pages cannot be loaded in an invisible frame over a page the attacker controls. Knowing where each defence ends is more useful than knowing that it exists.
Think of it as
All four are the same shape: untrusted input crossing into a context where it can be *interpreted* rather than merely stored. SQL injection is user text reaching the SQL parser; XSS is user text reaching the HTML parser; CSRF is an attacker's page reaching your endpoint with the user's ambient credentials attached; clickjacking is your page reaching a context the user cannot see. Django handles the boundary correctly in each case, so vulnerabilities almost never come from the framework — they come from a line that steps around it, and each one is a recognisable shape you can grep for. That reframes review usefully: rather than auditing for "is this safe", look for the four opt-outs by name — `mark_safe`, `|safe`, `csrf_exempt`, and an f-string inside a `raw()`/`extra()`. There is one gap where the defaults genuinely do not cover you, and it is worth remembering because it looks safe: autoescaping escapes for *element content*, and an unquoted HTML attribute is a different context. `<div class={{ value }}>` with a value containing a space and an event handler executes, and Django's own documentation gives this exact example. Quoting the attribute fixes it, which is why "always quote attributes" belongs in the same mental slot as the other three rules.
What we're doing: Render user-supplied rich text without handing the page over to whoever wrote it.
- 3–5
- Three allow-lists, not a block-list. Enumerating what is permitted is the only approach that survives a new HTML feature or a novel encoding trick.
- 9–10
- Storing both the original and the sanitised version means the sanitiser can be re-run after an upgrade, and the user's own text is never destroyed.
- 13–19
- Sanitising on write rather than on render: it happens once per comment instead of once per page view, and there is exactly one place where the safe value is produced.
- 21–24
- The `|safe` is the whole point of the exercise, and it is defensible only because of the twelve lines above it. Note the quoted attributes on the anchor — autoescaping does not protect an unquoted one.
Why this works: Sanitising once on write and marking the stored result safe keeps `|safe` to a single, auditable place — as opposed to `mark_safe()` scattered through templates, where each occurrence is a separate judgement nobody re-checks.
Reaching for `mark_safe()` to make formatting work
Wrong
Better
What you see: Nothing breaks — bold and links render correctly, which is exactly why it ships. A comment containing an `onerror` handler on an image tag then executes for every reader of that page, in their session.
Why: Markdown renderers pass embedded HTML through by design, so `markdown(user_text)` is still user-controlled HTML. `mark_safe()` does not inspect anything; it sets a flag telling the template not to escape. Combining the two means the user chooses what the template renders. Sanitising with an allow-list before marking safe is what makes the flag truthful.
- Untrusted input
- Into the HTML parser — XSS
- Autoescaped by default — the dangerous HTML characters are escaped in element content
- |safe · mark_safe() — every use needs a reason written next to it
- Unquoted attributes — class={{ v }} executes — autoescaping does not cover this context
- Into the SQL parser — SQL injection
- The ORM parameterises everything — SQL and values travel separately
- .raw(sql, params) is safe too — the params list is what makes it safe, not the method
- f-string · extra() · RawSQL() — the three shapes to grep for in review
- Into your endpoint, from elsewhere — CSRF and clickjacking
- CsrfViewMiddleware — a secret token on POST/PUT/PATCH/DELETE
- X-Frame-Options: DENY — the default — your page cannot be framed
- @csrf_exempt · @xframe_options_exempt — the two decorators worth a second reviewer
Four defences, and the exact line that disables each
Together
Remember: All four attacks are untrusted input reaching an interpreter, and Django closes each boundary by default — so the vulnerability is almost always the line that opts out. Grep for those four shapes: `@csrf_exempt`, `|safe`/`mark_safe()`, `@xframe_options_exempt`, and an f-string inside `.raw()`/`extra()`/`RawSQL()`. Sanitise with an allow-list on write, so `|safe` lives in one auditable place. And quote every HTML attribute: autoescaping covers element content, not an unquoted attribute value.
See also: host transport and cookie hardening · identity and server side request safety · autoescaping and safe strings · raw sql escape hatches

