Skip to content

Server-side template injection

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

There are two different things people mean by “user input in a template”, and only one of them is this page.

A user-supplied value passed into a template is escaped, assuming the extension is on the autoescape list. That is the ordinary case and it is handled.

A template whose source is built from user input is not protected by anything, because escaping applies to values and this is code.

render_template_string("{{ x }}", x="<script>alert(1)</script>")
# -> '&lt;script&gt;alert(1)&lt;/script&gt;' value escaped, correct
render_template_string("{{ 7*7 }}")
# -> '49' source evaluated
render_template_string("{{ config.SECRET_KEY }}")
# -> 'REAL-SECRET-VALUE-abc123' the actual key

The first line is autoescaping working. The second and third are the same function with the payload moved from the argument to the template, and there is no escaping concept that applies — Jinja was asked to render that expression and it did.

The third line is why this is rated high rather than medium. Flask puts config in the template context by default, so a template injection is one expression away from SECRET_KEY, and a key is one step from forging any session.

Where the template source comes from user input

Section titled “Where the template source comes from user input”

Rarely as directly as render_template_string(request.args["t"]). The realistic shapes are:

# a message with the user's name interpolated BEFORE rendering
render_template_string(f"<p>Hello {user.name}, welcome back</p>")
# an admin-editable email body or notification template stored in the database
render_template_string(row.body_template, order=order)
# a "dynamic page" feature where content is authored in the CMS
render_template_string(page.content)

The first is the common accident: an f-string that builds the template before Jinja ever sees it, so user.name becomes part of the source. A display name of {{ 7*7 }} is the whole proof of concept.

The second and third are deliberate features, and the people authoring those templates are trusted-ish rather than trusted — an admin account is one phishing away from being an attacker’s account.

the fix
from flask import render_template
# pass values as arguments; never build the source
return render_template("greeting.html", name=user.name)

If you genuinely need user-authored templates, render them in a sandbox rather than the application environment:

from jinja2.sandbox import SandboxedEnvironment
sandbox = SandboxedEnvironment(autoescape=True)
def render_user_template(source: str, **ctx) -> str:
return sandbox.from_string(source).render(**ctx) # no app config in ctx

Two things are doing work there. SandboxedEnvironment blocks attribute access that reaches into Python internals, which is what turns SSTI into code execution. And passing an explicit context means config is simply absent — the sandbox raises the bar, and not handing over the config removes the prize.

Treat the sandbox as a hardening layer rather than a guarantee. It has had escapes, and a feature that lets users write templates is a feature that needs its blast radius kept small regardless.

verify it workedRun this in: shell
Terminal window
# every call that takes a template SOURCE rather than a name
grep -rn 'render_template_string\|from_string\|Template(' --include='*.py' . | head -20

Each hit needs its first argument traced to a literal. If the argument is an f-string, a % format, a + concatenation or a database column, that is the finding.

The f-string case is worth its own pass, since it is the one that looks innocent:

Terminal window
grep -rnE 'render_template_string\(\s*f["'"'"']' --include='*.py' . | head

Then probe a running instance wherever user-controlled text is echoed back — a display name, a bio, a comment, an order note. Set the value to {{7*7}} and look for 49:

Terminal window
curl -s https://app.example.com/profile | grep -o '49'

49 where you wrote {{7*7}} is a confirmed injection. Nothing appearing means the value was treated as data, which is correct.

It is the standard probe because it is unambiguous and harmless. A literal {{7*7}} coming back means the string was treated as data; 49 means it was evaluated. Nothing is read, written or executed either way, so it is safe to run against a system you are responsible for.

Escalation from there is well documented and not something this page needs to detail. The point of the check is the one bit of information: evaluated, or not.

before you ship this

Replacing render_template_string with render_template means the template has to exist as a file, which is a real change if the current design stores templates in the database for non-developers to edit.

Do not remove that capability without a replacement — the feature will come back, and it will come back as the same call. The sandbox above is the intended path: it keeps the feature and narrows what a template can reach.

Moving to a sandbox breaks templates that used anything it blocks, which for user-authored content is usually nothing, and for templates written by your own team can be quite a lot. Render the existing corpus through the sandbox in a test before switching:

for row in db.session.query(Template).all():
try:
sandbox.from_string(row.body).render(**sample_ctx)
except Exception as e:
print(row.id, type(e).__name__, e)

That prints exactly which stored templates will start failing.