A company name like Smith & Jones <Holdings> is valid data and invalid markup. Interpolate it straight into an HTML template and the renderer parses the angle brackets as a tag, so the text content comes out as Smith & Jones with Holdings silently gone. Nothing throws. Escape every value on the way into the markup.
Escape the values, not the template
const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
function escapeHtml (value) {
if (value === null || value === undefined) return ''
return String(value).replace(/[&<>"']/g, char => ESCAPES[char])
}
function renderRow (item) {
return `<tr>
<td>${escapeHtml(item.description)}</td>
<td>${escapeHtml(item.amount)}</td>
</tr>`
}Pass that a description of Consulting & <strong>design</strong> for "Acme" and an amount of 1200 and you get markup the renderer cannot misread:
<tr>
<td>Consulting & <strong>design</strong> for "Acme"</td>
<td>1200</td>
</tr>Three details in that function matter more than they look. The character set is the five that change meaning inside markup, and the ampersand has to be first in the map so you are not escaping your own entities twice. The String() call means a number, a Date, or a Decimal from your database coerces instead of throwing on replace. And the null guard means a field nobody filled in renders as an empty cell rather than the literal word undefined in a document that is already in a customer's inbox.
Check what your template engine already does
If you render through an engine, the risk is usually the opt-out rather than the default. In Handlebars, values returned by {{expression}} are HTML-escaped and the triple-stash {{{expression}}} produces raw output. In Nunjucks, autoescape defaults to true, and the safe filter turns it off for one expression. Jinja is the exception worth knowing about: a bare Environment() has autoescaping off, and Jinja's own docs say autoescaping is not enabled by default, so an inherited template may be interpolating raw values right now. Turn it on explicitly.
from jinja2 import Environment, select_autoescape
env = Environment(autoescape=select_autoescape(
enabled_extensions=('html', 'xml'),
default_for_string=True,
))
template = env.from_string('<td>{{ description }}</td>')
print(template.render(description='Consulting & <strong>design</strong>'))
# <td>Consulting & <strong>design</strong></td>Two caveats
First, escape exactly once. If an upstream layer already escaped the value, escaping it again turns Smith & Jones into Smith &amp; Jones, and the reader sees the entity printed as literal text in the finished PDF. This is the failure that gets shipped, because it looks fine in your JSON and only shows up on the page. Pick the one layer that owns escaping and let every other layer pass values through untouched.
Second, escaping a text node is not the same as making an attribute safe. Escaping quotes does stop a value from breaking out of an attribute, but it does not touch the URL scheme, because none of those five characters appear in a string like javascript:alert(1). That value survives escaping byte for byte and is still a live URL once it lands in an href. Anywhere a template interpolates into href or src, check the scheme against an allowlist such as https and mailto as a separate step.
Back to All Questions