SSTI: server-side template injection explained
How SSTI differs from XSS, why a template engine sandbox is not a security boundary, and how to test, fix and detect this class of vulnerability.
- AUTHOR
- Karol Rapacz / CEO of Breachroad · OSCP · PNPT
- PUBLISHED
- 25 July 2026
- READING TIME
- 16 min read
- TOPIC
- Penetration Testing and AppSec
Template engines look innocent because they are associated with page layout. In reality most popular engines are full expression languages with access to application objects, attributes, methods and — indirectly — the runtime environment. When user input reaches a template as code rather than as a value, you get SSTI: server-side template injection. Consequences range from reading configuration to executing commands on the server.
The difference from XSS is fundamental and worth keeping in mind throughout. XSS executes in the victim’s browser and is constrained by the browser security model. SSTI executes inside the server process, with its privileges, its environment variables and its network access. The same input character can produce a minor XSS in one place and full service takeover in another.
Where it comes from
Almost always from one pattern: the application builds the template out of data instead of passing data into an existing template. In code it looks like string concatenation of template content, string formatting, or a call to a render function whose argument is a string partly derived from a request, a database row or a user-editable configuration file.
The places where this pattern appears are predictable. Email personalisation, where an administrator can type content containing {{name}} fields. Report and invoice generators with a configurable header. CMS platforms that let an editor insert an “HTML block”. Notifications and alerts with templates defined in an admin panel. Per-customer branding in multi-tenant applications. Admin panels with a live template preview. They share one trait: someone deliberately wanted users to supply templates, and treated it as a feature rather than as attack surface.
A second source is less obvious: data that passes through a template twice. A value enters the template as a safe variable, the result is stored, and that result is later rendered again in a different context. The first render is correct; the second interprets what was previously data.
Why a sandbox is not enough
Some engines offer a restricted mode: no access to certain attributes, an allowlist of functions, imports blocked. That raises the bar, but the history of these mechanisms is unambiguous — new sandbox escapes appear regularly, because the engine operates on a rich graph of language objects. One path is enough: an attribute leading to a base class, a method returning an object outside the list, a reflection mechanism, a filter invoking an arbitrary getter.
The practical conclusion is not “sandboxes are useless” but rather: a sandbox is an obstacle, not a boundary. The boundary is the process, the container, the system user and the network. If a design assumes users supply templates, isolation must be built at a level where escaping the sandbox changes nothing.
Engines also differ in philosophy. The logic-less family — templates without expressions, offering only substitutions and simple sections — does not execute code by design and is the only sensible choice for user-supplied content. Engines with full expressions are convenient for developers and dangerous as an input format.
What it actually gives an attacker
The impact depends on the engine and on what is within the process’s reach, but the sequence is usually the same.
First, reading the render context: variables passed to the template, the configuration object, application settings. API keys and connection strings surface at this stage already, because keeping them in a configuration object is the norm.
Then reading the environment: process variables, and with them the secrets injected at container start. In the cloud this extends to the instance metadata service and therefore to temporary role credentials — this is the path by which SSTI turns into cloud account compromise.
Next, network operations performed from the server, meaning full SSRF from inside the internal network.
Finally, code execution, if the engine allows it. On engines with reflection this usually comes down to finding one path to a process constructor.
Note that the first two stages are enough for a serious incident. You do not need to run a command to lose your keys.
Testing methodology
Testing for SSTI starts with the question does input reach a template as code, not with sending unusual characters into every field.
Step one is context discovery. Ask where users can supply content that is rendered later: profile fields displayed in messages, an organisation name in an invoice header, notification templates, export configuration, descriptions visible to others. The more “administrative” a feature is, the higher the chance somebody reached for a full engine.
Step two is telling SSTI apart from XSS and from plain substitution. The key observation is that the engine evaluates expressions. If an arithmetic construct comes back as a computed result rather than as text, you have server-side evaluation. If it comes back literally, it is substitution and the story ends at output encoding.
Step three is identifying the engine from behaviour and error messages. Different engines react differently to unclosed constructs and format exceptions differently. That information matters because it defines real reach — from “we can add two numbers” to “we can walk the language object graph”.
Step four covers blind cases where the rendered output never returns to the tester: emails, asynchronously generated documents, queued reports. There, confirmation comes from an out-of-band channel — controlled observation of whether the service made a network request or whether processing time changed. This requires the system owner’s consent and care, because a document carrying a payload lands in real recipients’ inboxes.
Step five is establishing reach without escalation. A test report does not need server takeover to demonstrate criticality. Reading a single configuration value the tester should not have access to is sufficient proof and is safer for the client’s environment than executing commands.
Throughout, the rule stands that active attempts are made only within an agreed scope, on test accounts and preferably on a non-production environment with reproducible state.
Fix patterns
Data as context, never as template. This is the proper fix and it suffices in most cases. The template is a developer artefact: it lives in the repository, passes code review and goes through deployment. User data is passed as variables. Concatenating a template with data belongs to the same family of mistakes as building SQL by gluing strings together.
When users must supply templates, choose a logic-less engine and treat the list of available variables as a contract. An editor gets {{ customer_name }} and {{ amount }}, not access to the order object with its entire relation graph. Filters and helper functions should be an explicit, short list.
Render isolation. The process rendering user templates should run separately: no secrets in environment variables, no cloud credentials, no access to the metadata service, outbound traffic blocked, time and memory limits enforced, filesystem read-only. Then even a successful engine sandbox escape leaves the attacker in a process where nothing valuable exists.
Resource limits are part of security, not only performance. A template with a nested loop can exhaust memory and CPU faster than almost any other vector, and denial of service in an invoice generator hurts as much as a leak.
Code review with a concrete pattern. It pays to have a static analysis rule that flags render calls taking a string instead of a template file. This is one of those rules with very few false positives and very high value.
Filtering characters at a WAF is not a fix. Engine syntax is rich and the same expression can be written many ways. An edge rule may slow down scanning, but it does not change the fact that the application executes supplied code.
The prompt template connection
The same design error returns in applications built on language models. A prompt template assembled by concatenating user content has exactly the same problem structure: data is promoted to the role of instructions. The consequences differ — not code execution but hijacked model behaviour — yet the design conclusion is identical. User content belongs in a clearly marked data field, not pasted where the system expects commands.
If an application renders prompts through a template engine, both cautions apply at once: the template is a developer artefact, and the interpolated values are data.
Detection in production
SSTI leaves traces if the application logs template engine exceptions. A rise in template parsing errors over a short window signals that somebody is probing syntax — users do not generate such errors in bursts.
The second signal is render time. A sudden increase in document generation time points at loops or expensive expressions.
The third is outbound traffic from the rendering process. If the architecture assumes the renderer never talks to the network, every such connection is a high-value alert with almost zero noise.
The fourth is unusual values in fields that flow into templates. Monitoring for characters typical of engine syntax in profile fields is not a control, but it works well as a detection signal.
Responding to a confirmed vulnerability
Finding SSTI in a live system differs from most application bugs in one respect: you must assume the render context was read. Even if the proof stopped at evaluating an arithmetic expression, the vulnerability itself granted access to the configuration object and process variables.
That dictates the order of actions. First, reduce exposure: disable the feature that accepts templates, or switch it to a mode where content is substituted rather than executed. Then rotate everything that was within the process’s reach: API keys, database connection details, secrets signing sessions and tokens, credentials for external services. Rotating the session signing secret matters most, because without it an attacker retains the ability to mint their own tokens long after the fix.
In parallel, review logs for outbound traffic from the rendering component and for calls to the cloud metadata service. If such connections occurred, the incident scope includes cloud role credentials and they must be revoked.
The design part comes last: establishing why user content reached an expression-evaluating engine in the first place. In most cases the answer is “because that was the quickest way to add personalisation”, and the proper fix is swapping in a logic-less engine and moving rendering into an isolated process — exactly what is described above, only performed after the incident instead of before.
Checklist
Templates come only from the repository and user data is passed as context. No code path renders a string built from request data. User-supplied content is rendered by a logic-less engine with an allowlist of variables. The rendering process is isolated, without secrets, cloud credentials or outbound traffic. Rendering has time, memory and depth limits. Static analysis flags string rendering. Engine exceptions are logged and monitored. A test environment allows confirming the vulnerability without escalation.
Conclusion
SSTI is an architectural mistake, not a missing filter. It appears the moment somebody decides a template may come from a user, and it stays invisible until somebody checks whether the engine evaluates expressions. The fix is cheap when taken at design level — data as context, logic-less for user content, an isolated renderer. It is expensive when attempted through a list of forbidden characters, because such a list is never complete.
Primary sources: PortSwigger Research — Server-Side Template Injection, CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine, OWASP — Injection Prevention Cheat Sheet.


