Server-side template injection (SSTI) happens when user input becomes part of a template's source instead of a value in its context, so the template engine evaluates what the user wrote. In Jinja2 that can lead to code running on the server; in Django's more restricted template language it exposes whatever objects the context carries. The rule that prevents both is one line long: template text is code, so it comes from your repository, and user input goes in the context. This article shows where the two meet by accident, what each engine allows once they do, and what to do when users really do need to write templates.
SSTI is classified as CWE-1336 and belongs to OWASP's A05:2025 Injection. The class was mapped out in public by James Kettle's 2015 research, Server-Side Template Injection, which remains the reference, and the OWASP testing guide has a procedure for it.
Template and context are different things
Every engine has two inputs. The template is a small program: text with expressions, loops and conditions. The context is data: the values those expressions read. The engine compiles the first and substitutes the second.
from flask import Flask, render_template_string, request
app = Flask(__name__)
@app.get("/hello")
def hello():
name = request.args.get("name", "there")
return render_template_string("<h1>Hello {{ name }}</h1>", name=name)
Here the template is a constant and name is context. Whatever the visitor sends, including {{ 7*7 }}, is rendered as literal text and HTML-escaped. This is the correct form.
SSTI is the same handler with the value moved one argument to the left:
@app.get("/hello-wrong")
def hello_wrong():
name = request.args.get("name", "there")
return render_template_string(f"<h1>Hello {name}</h1>")
Now the f-string builds the template, so the visitor's text is compiled. A name of {{ 7*7 }} renders 49, which is the standard, harmless way the flaw is confirmed.
Autoescaping does not help here. It protects the page from HTML in a context value, which is cross-site scripting. It has no bearing on what the engine evaluates.
Where the two meet by accident
Few developers set out to render user input as a template. It arrives through features that feel like text handling:
- A greeting or subject line built with an f-string or
+, then passed torender_template_string,jinja2.TemplateorEnvironment.from_string. Flask's own reference describesrender_template_stringas rendering a template from a source string, and Jinja'sfrom_stringsays it loads a template from a source string. Both treat their first argument as code. - Templates stored in the database. Editable notification emails, page fragments, report headers. The request that saves the text and the job that renders it are in different places.
- Two-pass rendering. A template is rendered once to produce another template (for example to fill in a tenant's branding), and the result is rendered again with the user's data. Any user value included in the first pass becomes code in the second.
- Error and "not found" pages that include the requested path in a message that is then rendered as a template.
- Format strings.
user_text.format(obj)is not a template engine, and it has the same shape: a format string can read attributes of its arguments ({0.attribute}), so a user-supplied format string can read whatever the objects passed to it expose. Usestring.Templatefor user text.
What Jinja2 allows
Jinja2 expressions can read attributes, index into objects and call methods. That is what makes the engine pleasant to use, and it is why a user-written expression is serious: starting from any object in scope, an expression can walk Python's object model to reach classes and functions the template was never given. The public research linked above documents the path in detail. For a defender, the conclusion is enough: in an unsandboxed Jinja2 environment, user-controlled template source should be treated as the ability to run code as the application.
Mako and Tornado templates go further by design, since their expressions are plain Python.
What Django templates allow
Django's template language is deliberately smaller. It cannot call a function with arguments and cannot evaluate arbitrary Python. The documentation states that variable names "may not start with an underscore" (template language), which closes the attribute walk Jinja2 payloads rely on, and that the engine will not call a callable that has alters_data = True, which model delete() and save() methods get automatically (template API).
That is a real reduction in risk, and it is not the same as safe. A user-written Django template can still:
- read every attribute and call every argument-less method of every object in the context, including ones you did not mean to show (
{{ user.email }},{{ order.customer.address }}, anything reachable by following relations); - call argument-less methods that change state and were not marked
alters_data; - use any loaded tag or filter, including
{% include %}to pull in other templates, and{% debug %}to dump the context whenDEBUGis on; - consume CPU and memory with nested loops.
So for Django the question to ask about a user-authored template is "what is in the context, and what can be reached from it?"
from django.template import Context, Template
def render_subject_wrong(user_text: str, order) -> str:
# user_text is compiled: it can read anything reachable from `order`
return Template(user_text).render(Context({"order": order}))
def render_subject(order) -> str:
# the template is ours; the order number is a value
return Template("Your order {{ number }} has shipped").render(
Context({"number": order.number})
)
When users must write templates
Some products need this: customers editing their own notification emails, for example. In order of preference:
1. A substitution-only format. If users only need to place values, string.Template does that and nothing else: $name placeholders, no attribute access, no expressions.
from string import Template
def render_notification(user_template: str, values: dict) -> str:
return Template(user_template).safe_substitute(values)
2. A sandboxed engine with a plain-data context. When users need loops and conditions, Jinja provides SandboxedEnvironment, which intercepts access to attributes, method calls, operators and string formatting. Pass dictionaries, lists, strings and numbers, never model instances or request objects.
from jinja2.sandbox import SandboxedEnvironment
_env = SandboxedEnvironment(autoescape=True)
def render_user_template(source: str, data: dict) -> str:
return _env.from_string(source).render(**data)
Treat the sandbox as a reduction of risk that needs upkeep. The Jinja project has shipped sandbox fixes more than once, including CVE-2024-56326, CVE-2024-56201 and CVE-2025-27516 (listed in the OSV database), and the maintainers fixed each. Keep Jinja2 current, render user templates with a time limit, and cap output size.
3. Render somewhere that has nothing to lose. For full template power, render in a separate process or container with no credentials, no network and no application objects, and pass the result back as text.
How to find it in a codebase
Search for the calls that compile text: render_template_string(, Template(, from_string(, Engine.from_string, mako.template.Template(, tornado.template.Template(. For each, ask one question: is the first argument a constant from your repository? An f-string, a concatenation, a .format() call or a value loaded from the database in that position is a finding to trace.
Linters and static analysis rules do this well when the non-constant argument is visible at the call. The harder cases are the ones from the list above: template text saved in one request and rendered by a job, a fragment assembled across three helpers, a render whose output goes into an email so no response ever shows the result. In those, the value's origin and the render call are far apart.
What runtime evidence adds
es-python, the EyalSec Python runtime, follows untrusted data through the program as it runs and checks it at the operation itself: here, the call that turns text into a template. Untrusted data in the template source is reported as ssti jinja2 (for Environment.from_string, which jinja2.Template and Flask's render_template_string both use), ssti django, ssti mako or ssti tornado, each carrying the ssti impact tag. Because the check is on the template text, the correct form, a constant template with untrusted values in its context, produces no SSTI event (what the event looks like).
Three details help when reading these events:
- The event fires whether or not the input contained template syntax, because the flaw is the flow, not one particular value. When EyalSec can prove the untrusted text carried nothing the engine would read as syntax, the event is suppressed: stored, and out of the default view.
- A user-authored template passed to a
SandboxedEnvironmentis still reported, since the user is still writing template code. If that is a product feature, record the decision with a rule for that call site. - With the db_rows source on, template text read back from the database is followed, so a stored template rendered by a background job is reported with the stored value as its source.
An event is evidence that untrusted text reached the compiler, with the value, where it entered and the call path. It is for you to judge what that template could reach. Under a Raise rule the program gets an error at that call instead of a rendered page. The model is described on How EyalSec works, and cost on pricing.
Summary
| Situation | Do this |
|---|---|
| Any render call | first argument is a constant; user input goes in the context |
| Users place values in text | string.Template(...).safe_substitute(...) |
| Users need logic | SandboxedEnvironment, plain-data context, current Jinja2, time and size limits |
| Django user templates | minimal context of plain values; no model instances |
| Stored templates | treat the stored text as user input at the point of rendering |
If you want to see which render calls on your own services receive untrusted text, you can book a live demo.