Safeguard
Application Security

Preventing XSS in Django applications

Django escapes template output by default, but mark_safe() and format_html() misuse routinely reopen the exact XSS holes auto-escaping was built to close.

Safeguard Research Team
Research
6 min read

Django's template engine has auto-escaped variable output by default since the feature matured in Django 1.0, converting <, >, ', ", and & into HTML entities every time a template renders {{ variable }}. That default alone has quietly prevented an enormous class of reflected and stored cross-site scripting bugs for close to two decades. Yet Django also ships three well-documented escape hatches — the {% autoescape off %} block tag, the |safe template filter, and the django.utils.safestring.mark_safe() function — and all three exist precisely to let developers opt back into raw HTML output when they know better. The trouble is that "when you know better" is a narrow, easy-to-misjudge condition, and static analysis tooling has built entire rule categories around catching when it's misjudged: Bandit's B703 rule and Ruff's S308 rule both fire specifically on mark_safe() calls, because security reviewers keep finding them wrapped around unsanitized user input. This piece walks through how Django's escaping actually works, the specific patterns that break it, and the documented safer alternative — format_html() — that lets you build dynamic HTML without reopening the hole auto-escaping was designed to close.

How does Django's auto-escaping actually work?

Auto-escaping is a property of the template engine, not of individual views, and it applies at render time to any value interpolated with {{ }} syntax. Per Django's official documentation, the five characters escaped are < becomes &lt;, > becomes &gt;, ' becomes &#x27;, " becomes &quot;, and & becomes &amp;. This happens after all template logic runs, immediately before output, so it applies uniformly regardless of whether the value came from a database field, a form input, or a computed expression. Critically, autoescaping is a template-layer control — it does nothing for HTML you construct in Python code with string concatenation or f-strings and then hand to the template as an already-built fragment, and it does nothing for content rendered outside the template engine entirely, such as raw HttpResponse bodies built by hand. Understanding that boundary is the first step to understanding where teams get burned: escaping protects {{ var }}, but it cannot protect a string that already told Django "trust me, I'm safe."

What does mark_safe() actually do, and why is it dangerous?

mark_safe() wraps a string in Django's SafeString type, which is a signal to the template engine to skip escaping that value entirely on output. Per the official docs, it should be applied only to strings the developer has already validated or generated from trusted, sanitized sources — never directly on unsanitized user input. The dangerous pattern shows up constantly in real codebases as mark_safe(f"<div>{user_comment}</div>") or the older %-formatting equivalent: the surrounding HTML is safe, but the interpolated variable is not, and wrapping the whole concatenated string in mark_safe() blesses the attacker's payload along with the template. Because SafeString instances propagate — a SafeString concatenated with another string can still behave as marked-safe depending on the operation — this pattern is easy to introduce accidentally deep inside a helper function, far from the template that ultimately renders it, which is exactly why Bandit and Ruff both added dedicated static checks for any mark_safe() call site rather than trying to reason about what's inside it.

Where does {% autoescape off %} and |safe typically get misused?

Both mechanisms accomplish the same thing as mark_safe() from inside a template instead of from Python, and they show up in similar risky spots. The {% autoescape off %}...{% endautoescape %} block disables escaping for every variable rendered inside it, which is convenient when a template needs to render several already-sanitized HTML fragments but becomes a liability the moment anyone adds a new, unescaped variable inside that block later without noticing the surrounding tag. The |safe filter does the same on a single value and is commonly applied to fields that "shouldn't" contain HTML — a username, a search query echoed back, a product title — right up until a user submits one that does. Two other spots practitioners flag repeatedly, per Django community security guidance and Semgrep's published Django XSS detection rules: custom template tags that return mark_safe()-wrapped strings, and Django admin list_display callables, which render their return values unescaped by design and are frequently overlooked because admin pages feel lower-risk than public-facing ones.

What's the documented safer alternative for building dynamic HTML?

Django's own documentation recommends django.utils.html.format_html() as the safe way to assemble an HTML string from dynamic parts, and its design directly targets the mark_safe(f"...") failure mode. format_html() behaves like Python's str.format(), but every argument passed in is escaped before insertion — only the literal template string itself, which the developer controls and does not derive from user input, is trusted as-is. So format_html("<div>{}</div>", user_comment) escapes user_comment automatically, while mark_safe(f"<div>{user_comment}</div>") does not escape it at all — the two look superficially similar but produce opposite security outcomes. For building up a larger fragment from several pieces, Django also provides format_html_join(), which applies the same per-argument escaping across a sequence of values, avoiding the temptation to fall back on a raw loop that concatenates and then wraps the whole result in a single mark_safe() call at the end.

Is escaping alone sufficient, or do you need more layers?

Django's own "Security in Django" documentation treats escaping-by-default as necessary but not sufficient, and explicitly recommends layering a Content-Security-Policy on top rather than treating template output as the only control point. A CSP header restricting script-src to specific origins means that even if an escaping mistake or a mark_safe() misuse slips a script tag into rendered output, the browser refuses to execute inline or third-party script it wasn't told to trust — turning a successful injection into a policy violation instead of a working exploit. This defense-in-depth framing matters because escaping is a per-template-variable decision made by whoever wrote that line of code, while a CSP is a single application-wide setting that catches mistakes nobody anticipated. Django doesn't ship CSP enforcement in core, but pairing template auto-escaping with a django-csp-style middleware and periodic manual review of every mark_safe(), |safe, and {% autoescape off %} call site in a codebase is the combination Django's documentation and the wider practitioner community converge on.

How Safeguard helps

Because mark_safe() and format_html() calls look nearly identical in a diff but carry opposite security guarantees, this is exactly the kind of pattern that benefits from automated source-to-sink tracing rather than a manual grep before every release. Safeguard's SAST engine analyzes Python source — Django included — tracing untrusted input from a source like a request parameter through to a dangerous sink, flagging cases where data reaches a mark_safe() call or an {% autoescape off %} block without passing through sanitization first, each finding mapped to its CWE and OWASP category with the full dataflow trace attached. That trace is what turns "someone used mark_safe() somewhere in this 40,000-line codebase" into a specific, reviewable finding a developer can act on in minutes.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.