
Django escapes template variables by default. XSS still happens when you mark a string safe, or write it into a sink that is not a template.
A comment field with |safe, a JSON blob dropped into a <script> tag, or an email you build with user HTML will undo the default. The framework cannot see those.
The usual mistake is ‘we use Django, so we do not have XSS’ after the first rich-text feature ships. Encoding is per sink. Autoescape is one sink.
This page is the Django-specific hatches, the middleware that still helps, and the tests that prove a comment cannot become a script.
Django 5.2.17 shipped on 4 August 2026. security weblog. CVE-2026-15920 was a stored URLField value rendered as a clickable admin link without URLValidator. Autoescape was already on. The scheme was the hole.
The control is the default escape, a grep for the four ways to turn it off, and a test that the template still prints text. Keep the XSS guide next to this page for the sink model. Read CSRF for the cookie Django already attaches. Flask’s |safe is the same hatch with a different engine: see Flask locks.
Autoescape is on. The hatch is the bug
CWE-79 is untrusted data becoming HTML the browser parses as the page. Django’s template language turns autoescape on for every variable. 5.2 automatic HTML escaping page. Five characters become entities: <, >, ', ", and &. {{ bio }} then prints text.
Autoescape is already on for {{ bio }}. |safe and mark_safe are the flap that lets stored HTML become the page.
SecureCoding
The DjangoTemplates engine takes OPTIONS['autoescape']. Default: True. The docs say set it False only when you render a non-HTML template. The Jinja2 backend Django ships also defaults autoescape to True. Leave both alone.
startproject already mounts CsrfViewMiddleware. That is a different ticket. XSS in a template can read the CSRF token the page already holds and fire the request the user is allowed to make. Closing the hatch is still the first job.
Custom filters have a second footgun. If you set is_safe = True on the filter function, Django treats the return value as already escaped. That is mark_safe with a worse name. Leave is_safe unset unless every argument was escaped inside the function. The 5.2 howto on custom template tags is the first-party page for that flag.
|safe and autoescape off
The safe filter marks one value trusted. Autoescape skips it. The rest of the template still escapes. That is why a review that only reads the view misses the hole: the view looks clean.
<!-- BAD: do not ship -->
<p>{{ bio|safe }}</p>
{% autoescape off %}
<p>{{ bio }}</p>
{% endautoescape %}
{% autoescape off %} is a block-level opt-out. Treat a hit like |safe. The matching on-tag exists if a parent already turned escaping off and you need it back for one section: {% autoescape on %}.
Unquoted attributes sit outside the escape Django can do well. Always quote:
<!-- BAD: unquoted attribute -->
<input value={{ name }}>
<!-- FIX -->
<input value="{{ name }}">
href can still hold a dangerous scheme after those five characters are escaped. The admin CVE in the next section is that leftover. A tight Content-Security-Policy is the belt. This page will not pretend a filter fixes href.
mark_safe versus format_html
django.utils.safestring.mark_safe is the Python-side hatch. It returns a SafeString. The template then skips escaping. The 5.2 safestring.py comment is blunt: the producer already turned characters the HTML engine must not honor into entities. If you wrap request.POST["bio"], you lied.
The docs prefer django.utils.html.format_html when you are building a fragment. It escapes every argument and leaves the format string, which you wrote, untouched.
from django.utils.html import format_html
from django.utils.safestring import mark_safe
def badge_html(label):
return format_html('<span class="badge">{}</span>', label)
def badge_html_wrong(label):
# BAD: do not ship
return mark_safe(f'<span class="badge">{label}</span>')
badge_html is the named helper. Call it from a custom filter or an inclusion tag. badge_html_wrong is the f-string that turns the argument back into grammar. Concatenating two SafeString values stays safe. Concatenating a SafeString with a plain str drops the mark. That is easy to get backwards in a review, which is why format_html exists.
A custom filter that emits HTML must escape the user bits, then mark the wrapper. Do not decorate the whole function with @mark_safe and then interpolate value.
import django.utils.html
from django import template
register = template.Library()
@register.filter
def as_badge(label):
return django.utils.html.format_html('<span class="badge">{}</span>', label)
as_badge is the filter name the template will call. Grep for mark_safe, SafeString, and @mark_safe the same week you grep |safe.
json_script for page data
The leftover that still looks modern is a Python dict printed into a <script> tag. escapejs is only for a whole JavaScript string literal inside single or double quotes. The 5.2 builtins page says it does not cover template literals, and it tells you to prefer a data- attribute or json_script.
that json_script section. The filter writes a <script type="application/json"> tag. It escapes <, >, and & as JSON unicode escapes so a value cannot break out of the tag. The page can run a strict CSP that forbids inline script. The data is not executable.
{{ profile|json_script:"profile-data" }}
<script src="{% static 'app/profile.js' %}"></script>
const node = document.getElementById("profile-data");
const profile = JSON.parse(node.textContent);
The id is profile-data on both sides. Do not build a <script> string in Python with json.dumps and mark_safe. That is the path json_script replaced. If you need the same payload in a unit test, render the template and assert the tag type is application/json and that a raw </script> sequence from the dict became a unicode escape.
| Hatch | What to do instead |
|---|---|
|safe | Leave the default. Sanitize on write if the product must store HTML. |
autoescape off | Keep the block on. Escape one variable if a parent already opted out. |
mark_safe | format_html for fragments you authored. |
| inline JSON | json_script plus JSON.parse on textContent. |
Admin URLField and schemes
CVE-2026-15920 is the 4 August 2026 reminder that escaping angle brackets is not the whole URL job. The admin rendered URLField values as links on changelists and read-only fields. The value was not run through URLValidator first. A stored scheme the validator would have refused became an href.
5.2.17 now validates with URLValidator inside display_for_field and falls back to plain text when that check fails. Upgrade off 5.2.16. You are affected when an unsafe scheme was stored without model validation: a direct queryset write, a deserializer, or a bulk import of untrusted input. A ModelForm or the admin add/change form already rejected those schemes. If you write your own admin column that prints a link, run the same validator before you emit <a href=.
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
from django.utils.html import format_html as html_fragment
_url = URLValidator()
def link_html(raw):
try:
_url(raw)
except ValidationError:
return raw
return html_fragment('<a href="{}" rel="nofollow">{}</a>', raw, raw)
link_html is the named helper. A failed scheme stays text. A passed value is still only as good as URLValidator‘s scheme list. Do not add a custom scheme you cannot name a reason for.
The same weblog shipped CVE-2026-15307, a high spatial-lookup file write. That is not XSS. Upgrade anyway. 6.0.8 is the matching 6.0 line. 6.1 was release candidate on that day. This page stays on 5.2 LTS because that is the 5.x you can still patch.
Prove the template and the hatch
You are not walking an exploit. You are proving the engine still escapes a string you already own, and the corpus has no silent opt-out on request data.
from django.template import Context, Engine
from django.test import SimpleTestCase
engine = Engine(autoescape=True)
class BioEscapeTests(SimpleTestCase):
def test_bio_stays_text(self):
tmpl = engine.from_string("<p>{{ bio }}</p>")
html = tmpl.render(Context({"bio": "<em>x</em>"}))
self.assertIn("<em>x</em>", html)
self.assertNotIn("<em>", html)
Grep the four names this page used:
rg -n "\\|safe|mark_safe|autoescape off|SafeString|json.dumps\\(.*mark_safe|OPTIONS\\[.autoescape.\\]\\s*=\\s*False" \
--glob '!venv' --glob '!.venv' --glob '!static'
A hit on |safe or mark_safe needs a human to say where the HTML came from. A hit that turns autoescape off in TEMPLATES is a review even if the files are .txt. Then open one admin changelist that shows a URL and confirm 5.2.17 or newer is what you deployed.
If you already apply the escape filter by hand, leave autoescape on anyway. The 5.2 language page says escape will not double-encode a value the engine already escaped. Turning the engine off so you can “control it yourself” is how one new template forgets the filter. Prefer the default, plus the test above, plus the grep.
Questions we keep getting
Does autoescape fix href and unquoted attributes?
No. Quote attributes. Validate a URL before you emit href. The 4 August 2026 admin fix is that second check, not a new entity table.
When is |safe acceptable?
When the string is HTML your code produced and you can name the sanitizer. Never on request.GET, request.POST, or a field you did not sanitize on write.
Is json_script a replacement for escapejs?
For passing structured data into the page, yes. escapejs is only a quoted JavaScript string. Prefer json_script plus JSON.parse on textContent.



