Skip to content

Template autoescaping

Severity: highApplies to: Flask 2.xApplies to: Flask 3.1.3Applies to: Jinja2 3.1.6Facts last verified 2026-08-13 against Flask 3.1.3 · Jinja2 3.1.6

Flask enables Jinja autoescaping by file extension. From Flask.select_jinja_autoescape:

if filename is None:
return True
return filename.endswith((".html", ".htm", ".xml", ".xhtml", ".svg"))

Five extensions. .j2 is not one of them, and neither is .jinja — the two conventional extensions for Jinja templates.

The same template body, the same variable, three filenames:

templates/page.html -> '<p>&lt;b&gt;x&lt;/b&gt;</p>' autoescape=True
templates/page.j2 -> '<p><b>x</b></p>' autoescape=False
templates/mail.txt -> 'Hello <b>x</b>' autoescape=False

page.html and page.j2 contain the identical string <p>{{ v }}</p>. One escapes and one does not, and nothing in the application distinguishes them.

There is no warning, no configuration error and no visible difference until the value contains markup. A codebase that names its templates index.html.j2 — a common convention, since it tells editors the output format and the templating engine — has autoescaping off everywhere.

the fix
from jinja2 import select_autoescape
app = Flask(__name__)
app.jinja_env.autoescape = select_autoescape(
enabled_extensions=("html", "htm", "xml", "xhtml", "svg", "j2", "jinja"),
default_for_string=True,
default=True, # escape by default, opt out per template
)

default=True inverts the rule: everything is escaped unless a template opts out with {% autoescape false %}. That is the safer default, and it means adding a template with a new extension does not silently create a hole.

verify it workedRun this in: framework cli
Terminal window
# which template extensions does this application actually use?
find . -path ./node_modules -prune -o -type f \
\( -name '*.j2' -o -name '*.jinja' -o -name '*.txt' -o -name '*.html*' \) -print \
| sed 's/.*\.\([a-z0-9]*\)$/\1/' | sort | uniq -c | sort -rn

Anything in that list other than html, htm, xml, xhtml and svg is a template rendering without escaping. Then check the live setting rather than the assumption:

Terminal window
python3 -c "
from app import app
for name in ('a.html','a.j2','a.jinja','a.txt','a.html.j2'):
print(f'{name:12}', app.select_jinja_autoescape(name))
"

a.html.j2 is the instructive row — it ends in .j2, so it is not escaped, despite containing .html.

|safe and Markup are the deliberate opt-outs

Section titled “|safe and Markup are the deliberate opt-outs”

Autoescaping is not the only lever. {{ value|safe }} disables escaping for that expression, and markupsafe.Markup(value) marks a string as pre-escaped anywhere it is used.

Both are legitimate and both are worth grepping for, because they are how escaping gets turned off one value at a time in a codebase that otherwise looks correct:

Terminal window
grep -rn '|safe' templates/ | head -20
grep -rn 'Markup(' --include='*.py' . | head -20

Each hit should be a value you produced, not one that came from a request or a database row a user can write to.

Escaping is HTML-context aware only in the sense that it escapes HTML metacharacters. A value interpolated inside a <script> block or into an event handler attribute is still a hole, because the escaping produces valid HTML that is invalid as a JavaScript string boundary.

<!-- escaped, and still wrong -->
<script>var user = "{{ name }}";</script>

Pass data to JavaScript as JSON, which escapes for that context:

<script>var user = {{ name|tojson }};</script>

The same applies to a value used as a URL — escaping does not stop javascript: from being a scheme.

before you ship this

Turning on escaping for templates that previously had it off changes rendered output, and the breakage is visual rather than an error: markup you intended to render starts appearing as literal <b> on the page.

Every place that was relying on the old behaviour needs |safe, and finding them means looking at rendered pages rather than at logs — nothing throws.

The manageable sequence is one extension at a time. Add j2 to the list, deploy, walk the pages that use those templates, add |safe where the escaping was load-bearing, then move to the next extension. Flipping default=True for everything at once on a large template tree is how you get a bad afternoon.

Email templates are the ones to check by eye — a .txt body that starts rendering &amp; in place of & is a regression your users see and your tests do not.

  • Template injection — the worse failure, where the template source itself is user input
  • Debug mode — the other place rendered output reaches a user unfiltered