Safeguard
Application Security•

How do you prevent cross-site scripting in Python web apps?

Turn on template autoescaping, review every safe filter, and encode by context for attributes, URLs and script data. Examples for Django and Jinja.

Safeguard Security Team
4 min read

Prevent cross-site scripting in Python by rendering every variable through a template engine with autoescaping on, then treating each escape hatch (the safe filter, Markup, autoescape off) as a reviewed exception. Autoescaping covers HTML text. Attributes, URLs and script data need extra care, and user-authored HTML needs a sanitizer.

What do Django and Jinja do by default?

They differ, and the difference bites teams that move between them.

Django escapes the output of every variable tag by default. Five characters change: less-than, greater-than, single quote, double quote and ampersand. A name containing a script tag renders as inert text.

Plain Jinja does the opposite. Its documentation states that the default configuration is no automatic escaping, so the application decides. Flask and similar frameworks configure it for you, but a standalone Jinja environment used to build emails or reports may be wide open. When autoescaping is on, everything is escaped except values explicitly marked safe.

If you use Jinja outside a framework, check the environment setting first. That one line decides whether every template is safe or none is.

Where do the escape hatches live?

Both engines give you ways to switch escaping off, and every one is a spot where XSS returns.

  • Django: the safe filter, the autoescape off tag, and mark_safe in Python code.
  • Jinja: the safe filter and wrapping a string in Markup.

Django's documentation adds three cautions worth memorizing. String literals typed in a template are treated as already safe. Disabling autoescape in a base template affects every child template that inherits it. And block.super is not escaped again, because it was escaped in the parent.

Jinja has a quieter trap: a value that is escaped but not marked safe is escaped again, producing double-escaped text. Developers then "fix" the display problem by adding the safe filter, which removes protection. If you see this pattern in a diff, ask why the data was escaped before the template.

Is autoescaping enough?

Not for every context. Autoescaping handles HTML text and quoted attributes. OWASP's cheat sheet lists spots with no safe encoding at all: inside script or style elements, HTML comments, attribute or tag names, and event handler code.

Constructed example. A profile page wants to show the user's theme color and a website link:

<div style="color: {{ user.color }}">
<a href="{{ user.website }}">Website</a>

The color value is inside a CSS context. Allow only values from a fixed list. The link value is a URL: HTML escaping will not stop a javascript: scheme. Parse it in Python and allow only http and https before rendering.

For data that scripts need, Jinja's tojson filter produces output that is safe in HTML, but the docs warn that inside double-quoted attributes you should use single quotes or the forceescape filter. Prefer a JSON script block or a data attribute read with textContent in the browser.

What about user-authored HTML?

If you accept rich text, encoding would show raw tags, so use a sanitizer. OWASP recommends a maintained sanitizer, such as DOMPurify on the client, and warns against editing the HTML after sanitizing. Sanitize last, store the result, and keep the library patched.

What to do

  1. Confirm autoescaping is on for every template environment, including email and report generators.
  2. Search the codebase for safe, mark_safe, Markup and autoescape off. Require a comment explaining each hit.
  3. Never place user data inside script blocks, style blocks, comments or event handlers.
  4. Validate URL schemes before putting a user URL in href or src.
  5. Allowlist values for CSS properties.
  6. Sanitize rich text with a maintained library as the final step.
  7. Add a content security policy as defense in depth and test it in staging.
  8. Keep Django, Jinja and sanitizer packages patched, and scan them on every pull request.

Safeguard's dependency scanning can flag vulnerable template and sanitizer packages in pull requests, so step 8 does not depend on someone remembering.

Quick check

Does Flask escape templates automatically?

The Jinja documentation does not cover Flask, so check Flask's own documentation for the exact rules. Standalone Jinja does not escape by default, so never assume.

Is the safe filter ever acceptable?

Yes, for content you control or have already sanitized, such as a stored HTML fragment you generated. Never use it on raw user input.

Sources

Last reviewed: 18 October 2026.

Never miss an update

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

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.