Shopify developers often assume Liquid gives them automatic XSS protection. That assumption is where trouble starts.
Liquid does help, but only in very specific contexts. The moment you move data from HTML text into attributes, JavaScript, JSON, URLs, or raw HTML blocks, the safety story changes fast. I’ve seen plenty of themes that look clean at first glance and still leave enough room for script injection through product data, metafields, cart attributes, or section settings.
If you build or review Shopify themes, you need to think in output contexts, not just “escaped vs unescaped”.
Why XSS still happens in Liquid
Liquid templates render server-side, which is good. But the browser decides how the final output is interpreted. A value that is harmless in one place can become dangerous in another.
A few common sources of attacker-controlled or semi-trusted data in Shopify themes:
product.titleproduct.descriptioncollection.descriptionarticle.contentsearch.termscustomer.name- line item properties
- cart attributes
- theme settings
- metafields
- query-string values used by client-side JavaScript
The mistake I see most often is treating all of these as plain text.
The most basic rule
Use the right escaping for the output context.
That means:
- HTML text node: use
escape - HTML attribute: use
escape - JavaScript string or object literal: use
json - URLs: validate and encode carefully
- Raw HTML: only allow trusted, sanitized content
If you remember one thing, make it this: escape is not a universal XSS fix.
Safe output in normal HTML
This is the easy case. If you’re placing dynamic data between tags, escape it.
<h1>{{ product.title | escape }}</h1>
<p>{{ shop.name | escape }}</p>
Without escaping:
<h1>{{ product.title }}</h1>
If product.title somehow contains this:
"><script>alert(1)</script>
the browser may interpret the injected markup depending on where it lands and how surrounding HTML is structured.
Using escape turns dangerous characters into entities:
"><script>alert(1)</script>
That renders as text, not code.
Attribute context is where themes get sloppy
Attributes are a different parsing context. If user-controlled data breaks out of quotes, you’ve got a problem.
Unsafe:
<div class="product-card" data-title="{{ product.title }}">
...
</div>
Safer:
<div class="product-card" data-title="{{ product.title | escape }}">
...
</div>
This matters even more for attributes like alt, title, value, and aria-label.
<input
type="text"
name="q"
value="{{ search.terms | escape }}"
aria-label="{{ search.terms | escape }}"
>
If you skip escaping here, an attacker may inject a quote and create a new attribute:
" onfocus="alert(1)
That turns your innocent input into an event-handler gadget.
Don’t put Liquid values directly into inline JavaScript
This is one of the easiest ways to create XSS in a Shopify theme.
Unsafe:
<script>
const productTitle = "{{ product.title }}";
</script>
If product.title contains quotes, backslashes, or </script>, that script block can break in ugly ways.
Use json instead:
<script>
const productTitle = {{ product.title | json }};
</script>
That produces a valid JavaScript string literal with proper escaping.
Same rule for objects:
<script>
window.productData = {
id: {{ product.id | json }},
title: {{ product.title | json }},
vendor: {{ product.vendor | json }}
};
</script>
Or better, emit a JSON blob and parse it later:
<script type="application/json" id="ProductData-{{ product.id }}">
{{ product | json }}
</script>
Then in JavaScript:
const el = document.getElementById(`ProductData-${productId}`);
const productData = JSON.parse(el.textContent);
I prefer this pattern because it separates data from code. Less fragile, easier to audit.
escape is not enough inside JavaScript
People do this and think they’re safe:
<script>
const customerName = "{{ customer.name | escape }}";
</script>
That is still the wrong encoder. HTML escaping does not make a value safe for JavaScript parsing.
Use:
<script>
const customerName = {{ customer.name | json }};
</script>
If data enters JavaScript, json should be your default move in Liquid.
Be very careful with raw HTML output
Some Shopify fields are expected to contain HTML, like product descriptions, article content, or rich text settings. Rendering those fields is normal, but you need to understand the trust boundary.
Typical usage:
<div class="product-description">
{{ product.description }}
</div>
This is only acceptable if the content is sanitized by the platform and only trusted users can edit it. The moment you render untrusted HTML from metafields, app output, or custom content pipelines, you can end up shipping stored XSS.
The biggest foot-gun is explicitly disabling escaping:
{{ some_value | raw }}
Or building HTML with concatenation:
{{ '<div>' | append: customer.note | append: '</div>' }}
If customer.note is not trusted and sanitized, you’ve created your own injection sink.
My rule: if I can’t clearly explain who controls the HTML and how it gets sanitized, I don’t render it as HTML.
URL-based injection is underappreciated
Themes often generate links from settings, metafields, or dynamic data.
Unsafe:
<a href="{{ settings.promo_link }}">Shop now</a>
If that value can be set to javascript:alert(1), escaping won’t save you. HTML escaping does not neutralize dangerous URL schemes.
Safer pattern:
{% assign promo_link = settings.promo_link | strip %}
{% if promo_link contains '://' or promo_link startswith '/' %}
<a href="{{ promo_link | escape }}">Shop now</a>
{% endif %}
Liquid validation options are limited, so I usually recommend keeping link sources constrained to trusted admin-controlled settings or Shopify objects that represent actual URLs.
For JavaScript-generated URLs, use the browser URL API and reject anything except expected protocols:
function safeHref(input) {
try {
const url = new URL(input, window.location.origin);
if (url.protocol === 'http:' || url.protocol === 'https:') {
return url.href;
}
} catch (_) {}
return '/';
}
Then:
ctaLink.href = safeHref(themeSettings.promo_link);
DOM XSS still matters in Shopify themes
Even if your Liquid output is perfect, the frontend JavaScript can ruin it.
I still find code like this in themes:
document.querySelector('.search-term').innerHTML = new URLSearchParams(location.search).get('q');
That’s DOM XSS. The source is the query string; the sink is innerHTML.
Use textContent instead:
document.querySelector('.search-term').textContent =
new URLSearchParams(location.search).get('q') || '';
Another common example is injecting line item properties into HTML templates:
propsContainer.innerHTML += `<li>${name}: ${value}</li>`;
Safer:
const li = document.createElement('li');
li.textContent = `${name}: ${value}`;
propsContainer.appendChild(li);
Liquid security and DOM security are part of the same problem.
Theme settings can become stored XSS
Theme editors often treat settings as trusted because only admins can change them. That’s not always enough. Stored XSS against admin users is still serious, especially in multi-user stores or app-heavy environments.
Unsafe section template:
<div class="announcement">
{{ section.settings.message }}
</div>
If message is plain text, escape it:
<div class="announcement">
{{ section.settings.message | escape }}
</div>
If it’s a rich text field, render it knowingly and avoid reusing it inside attributes or scripts.
This is where theme schema design matters. If a setting only needs text, define it as text and render it as escaped text. Don’t give yourself a rich HTML problem when plain content would do.
Good patterns for passing data to JS
I like these two patterns.
Pattern 1: JSON in a script tag
<script type="application/json" id="CartData">
{{ cart | json }}
</script>
const cartData = JSON.parse(document.getElementById('CartData').textContent);
Pattern 2: Escaped data attributes for small values
<button
class="quick-add"
data-product-id="{{ product.id | escape }}"
data-product-title="{{ product.title | escape }}">
Quick add
</button>
const btn = document.querySelector('.quick-add');
console.log(btn.dataset.productTitle);
For large objects, use JSON. For tiny values, data attributes are fine.
Add CSP as backup, not as an excuse
A Content Security Policy can reduce XSS impact, especially if you remove inline scripts and event handlers. For Shopify themes, CSP can be awkward depending on platform constraints and app behavior, but it’s still worth understanding. If you need implementation details, https://csp-guide.com is a solid reference.
A strong CSP helps when someone eventually misses an output encoding bug. And somebody always does.
A quick review checklist
When I review a Liquid template, I ask:
- Is this value rendered in HTML text, attribute, JS, JSON, or URL context?
- Does the filter match that context?
- Is any untrusted value entering
<script>? - Is any untrusted value entering
href,src, or form actions? - Is raw HTML being rendered? Who controls it?
- Does frontend JavaScript use
innerHTML,outerHTML, orinsertAdjacentHTMLwith dynamic data? - Are section settings or metafields treated as more trusted than they really are?
Safe vs unsafe cheatsheet
HTML text:
{{ value | escape }}
HTML attribute:
data-name="{{ value | escape }}"
JavaScript string:
const value = {{ value | json }};
JSON blob:
<script type="application/json">{{ object | json }}</script>
Unsafe raw HTML:
{{ value }}
Unsafe JS interpolation:
<script>const value = "{{ value }}";</script>
Unsafe DOM injection:
el.innerHTML = userInput;
Safer DOM update:
el.textContent = userInput;
Shopify Liquid is not uniquely dangerous. It just encourages a false sense of safety because server-side templates feel controlled. They are, right up until dynamic content crosses into the wrong browser context. That’s where XSS bugs show up, and that’s exactly where a careful theme developer earns their keep.