How it shows up in Python
Template engines draw a hard line between the template, which is code, and the context, which is data. SSTI is crossing that line, and in Flask it usually takes one f-string:
from flask import request, render_template_string
@app.get("/hello")
def hello():
name = request.args.get("name", "there")
return render_template_string(f"<h1>Hello {name}</h1>")
The visitor's name is now template code. A name of {{ 7*7 }} renders 49, which is the usual way the bug is confirmed. From there, an expression can walk from any object in the template's reach to the interpreter's internals, and in Jinja2 that ends in running arbitrary code on the server. Mako and Tornado templates go further still: their expression syntax is plain Python, so there is nothing to escape from.
The fix is to keep the template constant and pass the value as context. The engine then treats it as data and escapes it for HTML:
from flask import request, render_template_string
@app.get("/hello")
def hello():
name = request.args.get("name", "there")
return render_template_string("<h1>Hello {{ name }}</h1>", name=name)
The same rule holds for every engine. jinja2.Template(text), Environment.from_string(text), django.template.Template(text), mako.template.Template(text) and tornado.template.Template(text) all compile their argument as code, so none of them should ever see request data. Django's template language is more restricted than Jinja2's, but an injected Django template can still read whatever objects its context exposes.
Some products do need user-authored templates: a SaaS that lets customers edit notification emails, for example. For those:
from string import Template
def render_notification(user_template: str, values: dict) -> str:
return Template(user_template).safe_substitute(values)
string.Template only substitutes $name placeholders, with no attribute access and no expressions. If customers need loops and conditions, use Jinja2's SandboxedEnvironment, expose plain data rather than application objects, and treat the sandbox as a reduction of risk rather than its removal.
Why it is hard to see
- Template text that arrives later. The template is stored in the database (an editable email or page fragment) and rendered by a background job. The request that planted it and the code that renders it are in different places, and neither looks dangerous alone.
- Blind rendering. When the rendered result is emailed or written to a file instead of returned in the response, a dynamic scanner sends its probe and sees no difference in the reply.
- Assembled across functions. One helper builds a fragment, another wraps it, a third renders it. Rules that look for a non-constant argument at the render call lose the thread across the helpers.
- Not HTTP at all. Template text read from a queue message, an uploaded archive or a partner API never passes a web application firewall.
What the es-python event looks like
es-python reports untrusted data reaching the call that turns text into a template: ssti jinja2 for Environment.from_string (which jinja2.Template and Flask's render_template_string both use), ssti django, ssti mako and ssti tornado for those engines' Template constructors. All four carry the ssti impact tag, and with data from the network the event is graded Critical. Because the check is on the template text, a template rendered with untrusted values in its context, the fixed version above, produces no SSTI event.
The event fires whether or not the input contained template syntax, because the flaw is the flow, not this particular value. When EyalSec can prove the untrusted text carried nothing the engine would read as syntax, the event is suppressed: stored, but out of the default view. User-authored templates passed to a SandboxedEnvironment are still reported, since the sandbox limits damage but the attacker still writes template code; if that is a product feature, record it with a rule for that call site.
Python code that reads an attribute whose name came from outside, getattr(obj, name), is reported as getattr and carries the same ssti tag, because attribute walks are how injected templates reach code. Most such calls are ordinary serializers reading a field name, so EyalSec suppresses every getattr event whose name is not one of the interpreter's introspection attributes, and keeps the rest.
Under a Raise rule, the program gets a RuntimeError at that call instead of a rendered page.
How to fix and prevent it
- Keep request data out of template source: store templates in files or constants and pass values as context.
- Search for
render_template_string,from_stringand directTemplate(constructors, and check where their first argument comes from. - For user-authored templates, prefer
string.Template; if you need logic, use a sandboxed environment with plain data only. - Give templates the values they display, not application objects (models, the request, settings).
- Keep autoescaping on. It does not stop SSTI, but it stops the same flaw from also being cross-site scripting.
Classification
SSTI is CWE-1336, Improper Neutralization of Special Elements Used in a Template Engine, a child of CWE-94, Code Injection. CWE-1336 is not itself on the OWASP mapping list for 2025, but its parent CWE-94 is, under A05:2025 Injection.
Questions people ask
The questions that come up on forums and in reviews about this class, each answered in a few sentences.
Is Jinja2 safe from template injection?
Jinja2 is safe when the template is yours and the user's data is passed in the context: render_template("page.html", name=name) autoescapes the value and never evaluates it. The flaw appears when user input becomes part of the template text itself, through render_template_string() on a formatted string or Template(user_text), because the engine then evaluates the attacker's expressions on the server. Jinja2's SandboxedEnvironment limits the damage but still runs attacker-written template code.
What is the difference between XSS and SSTI?
XSS runs attacker script in another user's browser, after the page is delivered; SSTI runs attacker expressions on the server, while the page is being built, and from there can usually reach Python objects and execute code. Autoescaping prevents XSS by escaping values in the output and does nothing for SSTI, because the injected text is never a value. Tell them apart by where the input lands: the context or the template.
How does es-python detect SSTI?
It reports untrusted data reaching the call that turns text into a template, Environment.from_string for Jinja2 and the Template constructors of Django, Mako and Tornado, with the ssti impact tag and a Critical grade when the data came from the network. A template rendered with untrusted values in its context produces no event, because the check is on the template text. When the text provably carried nothing the engine reads as syntax the event is suppressed, and a Raise rule raises a Python RuntimeError instead of rendering.