Preventing Server-Side Template Injection (SSTI)
Server-Side Template Injection happens when an application takes user-controlled input and evaluates it as part of a template rather than as data passed into a template. Template engines like Jinja2, Twig, Freemarker, Handlebars, and Nunjucks are miniature programming languages: they resolve variables, call methods, and traverse object graphs. When an attacker controls the template source, they are not injecting markup — they are writing code that the engine runs on the server with the application’s privileges. The result is usually remote code execution, and from there, access to environment variables, cloud credentials, and internal services.
The tell is deceptively simple. If submitting an arithmetic expression causes the response to contain its computed result, the input is being evaluated as template syntax. From that foothold, attackers pivot through the language’s object model to reach a code-execution primitive. This guide covers the vulnerable patterns across the major engines, the object-traversal escalation that turns a math bug into RCE, and the layered fixes: removing runtime compilation of user input, sandboxing the engine, and rendering with user data as context only. It is part of the Injection Attack Prevention guide within Vulnerability Patterns & Web Mitigation Strategies. Because the two are frequently confused, contrast it with XSS mitigation, which addresses the browser side of untrusted rendering.
Prerequisites
- A server-rendered application using Jinja2, Twig, Freemarker, Handlebars, or Nunjucks
- Ability to locate every call site where templates are compiled or rendered
- A test client capable of submitting arbitrary strings to template-adjacent inputs
- Access to the engine’s sandbox and autoescape configuration
Expected Outcomes
- No code path compiles a template from user-controlled input at runtime
- The engine runs sandboxed with attribute traversal and dangerous globals blocked
- User values reach templates exclusively as named context variables
- Arithmetic and object-traversal probes render as inert literal text
Step 1: Remove Template Compilation From User Input
The root cause of every SSTI is a runtime compile of attacker-controlled text. The fix that eliminates the entire vulnerability class is to never call the engine’s “compile this string” function with user input. In Jinja2 that function is from_string; in Nunjucks it is renderString or compile; in Handlebars it is Handlebars.compile; in Twig it is a runtime-created ArrayLoader template; in Freemarker it is new Template(...) from a request value.
The diagram contrasts the safe path — a precompiled template rendered with a data context — against the unsafe path where a user string flows into the engine and reaches code execution.
The vulnerable pattern in Jinja2 compiles a request value directly. An attacker who submits an object-traversal payload reaches os.system:
from flask import request
from jinja2 import Environment
env = Environment()
# VULNERABLE: the user controls the template source, so any expression runs.
# A payload such as the greeting field below is evaluated on the server:
# {{ cycler.__init__.__globals__.os.popen('id').read() }}
def render_greeting():
tpl = env.from_string("Hello " + request.args["name"]) # SSTI
return tpl.render()
The fix is to keep the template static and pass the name as data:
# SAFE: the template is a fixed literal; the user value is context only.
def render_greeting():
tpl = env.from_string("Hello {{ name }}") # constant source, no user text
return tpl.render(name=request.args["name"])
The same rule applies everywhere. In Nunjucks, replace nunjucks.renderString(userInput, ctx) with nunjucks.render('greeting.njk', ctx) loading from a file. In Handlebars, precompile templates at build time and never call Handlebars.compile on request data. In Freemarker, load templates through a TemplateLoader rooted at a trusted directory rather than constructing a Template from a request string.
Step 2: Sandbox the Engine and Restrict the Object Model
If a feature genuinely requires user-authored formatting — email templates, report layouts — you cannot rely solely on Step 1, so add a sandbox that blocks the traversal an attacker needs to escalate. The Jinja2 escalation works by walking Python’s object model from any accessible object to its class, then to subclasses or globals, and finally to os:
# Attacker payloads submitted as a template body traverse to code execution:
# {{ ''.__class__.__mro__[1].__subclasses__() }}
# {{ cycler.__init__.__globals__.os.popen('id').read() }}
# {{ request.application.__globals__.__builtins__.__import__('os').popen('id').read() }}
A SandboxedEnvironment denies access to underscore-prefixed attributes and other dangerous operations, which breaks the traversal chain:
from jinja2.sandbox import SandboxedEnvironment
from jinja2 import StrictUndefined, select_autoescape
# Sandboxed environment: attribute access to dunder members is blocked,
# StrictUndefined turns silent lookups into errors, autoescape guards output.
env = SandboxedEnvironment(
autoescape=select_autoescape(["html", "xml"]),
undefined=StrictUndefined,
)
# Remove anything from the global namespace that could reach the OS.
for danger in ("range", "namespace", "cycler", "joiner"):
env.globals.pop(danger, None)
Prefer a logic-less engine when the use case allows it. Mustache and strict Handlebars evaluate variable substitutions and simple sections but expose no method calls, no arithmetic, and no attribute traversal, so there is no object model to walk. In Freemarker, set Configuration.setNewBuiltinClassResolver to TemplateClassResolver.SAFER_RESOLVER (or ALLOWS_NOTHING_RESOLVER) to block the ?new and .api built-ins that reach arbitrary classes. In Twig, avoid enabling the sandbox’s _self and disable the filter/map callbacks on untrusted input.
Sandboxes have escaped before, so treat this as a second layer behind Step 1, never as the primary control. A sandbox reduces the severity if input unexpectedly reaches the engine; it does not license accepting user templates by default.
Step 3: Render With Data as Context Only
The durable architecture separates two things that SSTI conflates: the template (trusted, authored by developers, precompiled) and the context (untrusted user data, passed as named variables). Keep autoescape on so the values are HTML-encoded on output, defending the same render against XSS.
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
# Templates come only from a trusted directory, compiled once at startup.
env = Environment(
loader=FileSystemLoader("/app/templates"), # never a user-controlled path
autoescape=select_autoescape(["html", "xml", "j2"]),
undefined=StrictUndefined,
)
def render_invoice(user):
tpl = env.get_template("invoice.html.j2") # fixed, trusted template
# Every user value is a named context variable, never template syntax.
return tpl.render(
customer_name=user["name"],
line_items=user["items"],
total=user["total"],
)
Two rules keep this safe over time. The loader must be rooted at a trusted directory so an attacker cannot supply a template path (a related bug, template path traversal, that also leaks arbitrary files). And the template name passed to get_template must itself never be user-controlled — parameterize which report to render through a server-side allowlist of template names, exactly as you would allowlist a sort column:
ALLOWED_TEMPLATES = {"invoice": "invoice.html.j2", "receipt": "receipt.html.j2"}
def render_document(doc_type: str, ctx: dict) -> str:
name = ALLOWED_TEMPLATES.get(doc_type)
if name is None:
raise ValueError("Unknown document type") # reject, do not concatenate
return env.get_template(name).render(**ctx)
With this shape, there is no code path where user input becomes template syntax and no way to select an arbitrary template file.
Verification
Submit evaluation and traversal probes to every input that flows toward a rendered response and confirm they come back as literal text.
# 1. Arithmetic probe: the response must contain the literal, not the product 49.
curl -s 'http://localhost:5000/greet?name=%7B%7B7*7%7D%7D'
# The %7B%7B7*7%7D%7D is the URL-encoded double-brace 7*7 expression.
# Expected: the page shows the braces and 7*7 verbatim, NOT 49.
# 2. Object-traversal probe must render inert, never a subclasses list.
curl -s --data-urlencode "name={{''.__class__.__mro__}}" http://localhost:5000/greet
# Expected: the literal string echoed/escaped, no Python type output.
# 3. Static grep for runtime compilation of request data.
grep -rnE 'from_string|renderString|Handlebars\.compile|new Template\(' src/ \
| grep -iE 'req|request|body|query|param'
# Expected: zero results, or only reviewed constant-string call sites.
A hardened application echoes both probes as inert, autoescaped text and produces no matches in the grep. If probe 1 returns 49, input is being evaluated as template syntax and you have a live SSTI.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Arithmetic payload renders as its computed result | User input is compiled as template source | Move to a precompiled template and pass the value as a context variable (Step 1) |
| Traversal payload returns a Python subclasses list | Unsandboxed engine compiling user text | Remove the runtime compile; add SandboxedEnvironment as a second layer |
| Autoescape is on but SSTI still fires | Autoescape encodes output, not template source | Autoescape cannot stop SSTI; eliminate the runtime compile of input |
| Arbitrary file contents appear in output | Template name or loader path is user-controlled | Root the loader at a trusted directory and allowlist template names |
| Freemarker payload reaches a Java class | Default class resolver permits ?new |
Set the resolver to SAFER_RESOLVER or ALLOWS_NOTHING_RESOLVER |
Common Implementation Mistakes
Frequently Asked Questions
How is SSTI different from cross-site scripting?
They occur on opposite sides of the trust boundary. XSS runs attacker script in the victim’s browser, bounded by the browser sandbox and the same-origin policy. SSTI runs attacker expressions inside the template engine on your server, with the application’s own privileges. A successful SSTI in Jinja2 or Freemarker typically escalates to remote code execution and access to server-side secrets, which is far more severe than reflected XSS. The two are easy to confuse because both involve untrusted input in rendering, but the remediation is different — see XSS mitigation for the browser side.
Does turning on autoescape stop SSTI?
No. Autoescape HTML-encodes the output of an expression, which is the correct defence against XSS, but SSTI happens when the attacker controls the template source rather than a rendered value. When input reaches the engine as template syntax, the expression is evaluated before any output escaping applies. Keep autoescape on for its XSS benefit, but understand it does nothing against a template string an attacker can author — only removing the runtime compile does that.
Is it ever safe to let users supply templates?
Almost never, and only with heavy precautions. If a feature truly requires user-authored formatting, use a logic-less engine such as Mustache that exposes no method calls or object traversal, or run a strictly sandboxed environment that blocks dunder access and dangerous globals — and even then, treat any sandbox escape as a critical finding. The safe default is unambiguous: users supply data, developers supply templates, and the two never swap roles.
Related
- Injection Attack Prevention — the parent guide covering every interpreter boundary from SQL to LDAP to templates
- Cross-Site Scripting (XSS) Mitigation — the browser-side counterpart to server-side template evaluation
- Vulnerability Patterns & Web Mitigation Strategies — the parent index for the full web vulnerability taxonomy