Skip to main content
Security

Shopify Liquid Escaping: escape, json, and Safe Output

Choose the right Shopify Liquid output filter for HTML text, attributes, rich text, and JSON, with a complete section and adversarial test cases.

4 min read
ShopifyLiquidEscapingSecurity

Escaping is contextual. A value that is safe as visible HTML text is not automatically safe inside JSON, a CSS rule, or a URL. Shopify Liquid provides filters such as escape and json, but no single filter can make arbitrary data correct in every output context.

This guide builds a small featured-product note and tests it with awkward characters. The section demonstrates four deliberate choices: plain settings are escaped in HTML, a URL setting is escaped in its attribute, trusted rich-text output is rendered in a neutral container, and a value intended for JSON is serialized with json before it is escaped for an HTML data attribute.

Prerequisites and threat model

Use a development theme and understand basic section settings. The example deals with content entered by a user who can edit the theme. It does not turn theme settings into an authentication boundary, and it does not make arbitrary third-party HTML safe.

Before selecting a filter, answer two questions:

  1. What type of value is this: plain text, platform-generated rich text, URL, image, object, or untrusted markup?
  2. Where will it be inserted: HTML text, an HTML attribute, JSON, JavaScript, CSS, or a URL component?

Shopify's official escape filter documentation describes escaping HTML-special characters. The json filter documentation explains serialization and its behaviour for objects and quoted strings.

Static HTML input

The original design is simple:

<aside class="product-note" data-label="Featured & limited">
  <p>Editor's pick</p>
  <h2>Everyday travel mug</h2>
  <p>Use it at home or on the move.</p>
  <a href="/products/travel-mug">View the travel mug</a>
</aside>

The ampersand, apostrophe, product choice, and rich description all need to survive conversion. Concatenating raw setting values into this markup would be fragile.

Complete section with context-aware output

Create sections/product-note.liquid:

{% style %}
  #shopify-section-{{ section.id }} .product-note {
    max-width: 44rem;
    padding: 2rem;
    border: 1px solid currentColor;
    border-radius: 0.75rem;
  }
{% endstyle %}

{% assign selected_product = section.settings.product %}

<aside
  class="product-note"
  data-label-json="{{ section.settings.eyebrow | json | escape }}"
  {% if section.settings.heading != blank or selected_product != blank %}aria-labelledby="ProductNoteHeading-{{ section.id }}"{% endif %}
>
  {% if section.settings.eyebrow != blank %}
    <p>{{ section.settings.eyebrow | escape }}</p>
  {% endif %}

  {% if section.settings.heading != blank %}
    <h2 id="ProductNoteHeading-{{ section.id }}">
      {{ section.settings.heading | escape }}
    </h2>
  {% elsif selected_product != blank %}
    <h2 id="ProductNoteHeading-{{ section.id }}">
      {{ selected_product.title | escape }}
    </h2>
  {% endif %}

  {% if section.settings.body != blank %}
    <div class="rte">{{ section.settings.body }}</div>
  {% endif %}

  {% if section.settings.link_label != blank and section.settings.link != blank %}
    <a href="{{ section.settings.link | escape }}">
      {{ section.settings.link_label | escape }}
    </a>
  {% elsif selected_product != blank %}
    <a href="{{ selected_product.url | escape }}">
      View {{ selected_product.title | escape }}
    </a>
  {% endif %}
</aside>

{% schema %}
{
  "name": "Product note",
  "settings": [
    {
      "type": "text",
      "id": "eyebrow",
      "label": "Eyebrow",
      "default": "Editor's pick"
    },
    {
      "type": "text",
      "id": "heading",
      "label": "Heading"
    },
    {
      "type": "richtext",
      "id": "body",
      "label": "Text",
      "default": "<p>Use it at home or on the move.</p>"
    },
    {
      "type": "product",
      "id": "product",
      "label": "Product"
    },
    {
      "type": "text",
      "id": "link_label",
      "label": "Custom link label"
    },
    {
      "type": "url",
      "id": "link",
      "label": "Custom link"
    }
  ],
  "presets": [
    {
      "name": "Product note"
    }
  ]
}
{% endschema %}

Plain text in HTML

For a text setting rendered between tags, use escape:

<h2>{{ section.settings.heading | escape }}</h2>

If an editor enters Mugs < Bottles & Flasks, the browser displays those characters as text instead of treating the angle brackets and ampersand as markup. Escaping does not delete ordinary characters; it converts HTML-special characters to the appropriate entities.

The same rule applies to product titles when they are inserted as text. A product object is platform data, but its title can still contain &, quotes, or other characters meaningful to HTML.

Plain text in HTML attributes

An attribute adds another parsing boundary. This is correct for a normal string:

data-label="{{ section.settings.eyebrow | escape }}"

The complete example deliberately stores a JSON string instead:

data-label-json="{{ section.settings.eyebrow | json | escape }}"

json first serializes the value and includes the JSON quotes. escape then protects that JSON string while it sits inside a double-quoted HTML attribute. Browser code can read element.dataset.labelJson and pass it to JSON.parse. Reversing the filter order would serialize HTML entities rather than the original value.

Avoid inventing data-* attributes when ordinary visible HTML is enough. This example exists to show the two contexts clearly.

JSON output is not HTML escaping

The json filter converts a Liquid string or object into JSON. It handles JSON quoting rules; you should not add another pair of JavaScript quotes around its output. For example:

{{ selected_product.handle | json }}

is a JSON value, while this is a different HTML-text operation:

{{ selected_product.handle | escape }}

Do not treat json as a general sanitizer for HTML. Likewise, escape does not produce structurally valid JSON. When embedding a larger JSON payload, prefer a well-defined serialization path and parse it as data rather than constructing executable JavaScript with string concatenation. Be aware that Shopify documents some product inventory fields as omitted from product-object JSON output.

Rich text is intentionally different

The richtext setting is platform-generated HTML. Escaping the entire value would display literal tags and remove formatting:

{{ section.settings.body | escape }}

Instead, render it in a neutral container:

<div class="rte">{{ section.settings.body }}</div>

This is not permission to output arbitrary request parameters, metafield HTML, or third-party API strings without review. The trust and content type of the source matter. If you only need plain copy, use a plain setting type and escape it.

URLs and unsafe schemes

The section uses Shopify's url setting rather than a plain text field. That gives the editor a destination picker and a more constrained value. Escaping the result protects attribute syntax, but escaping alone does not decide whether a destination is trustworthy. Never convert an arbitrary string into a clickable URL without scheme and source validation.

HTML conversion should also remove inline event handlers such as onclick and reject executable javascript: URLs. Those are behaviour injection points, not content settings. HTML2Liquid's converter strips or rejects those patterns, but generated output still needs manual review.

Expected output with difficult characters

Set the eyebrow to Editor's "featured" pick & guide. The visible paragraph should display the exact punctuation. The rendered source encodes the HTML-special characters, while data-label-json decodes at the DOM layer to a valid JSON string that JSON.parse returns as the original text.

Set the heading to <strong>Not markup</strong>. Because this is a plain text setting, the literal angle-bracket text should display; it must not create a strong element. Add bold formatting only through a setting type and output context designed for rich text.

Common failure cases

  • Double-escaped text: applying escape to content that has already been intentionally encoded can display entity text such as &amp;.
  • Raw text setting becomes markup: add escape at the HTML output boundary.
  • JSON parsing fails: do not wrap json output in extra quotes; check the surrounding HTML or script context.
  • JSON stored in an attribute breaks: serialize with json, then escape for the attribute as a separate step.
  • Rich-text tags appear visibly: do not escape a trusted Shopify richtext setting as a whole.
  • Nested paragraphs: do not wrap rich text in p.
  • Unsafe link remains clickable: use an appropriate Shopify setting and validate externally sourced URLs; HTML escaping is not a URL allowlist.
  • Raw HTML setting is requested: reconsider the content model. Most section content can be represented by structured settings and blocks.

Adversarial test plan

In a development theme, enter these values one at a time into plain text fields:

Mugs < Bottles & Flasks
Editor's "featured" pick
<strong>Not markup</strong>
"><span>attribute boundary</span>

Then verify:

  1. The exact characters display as text where expected.
  2. No unexpected element appears in the DOM.
  3. The data-label-json value passes JSON.parse in the console.
  4. Rich text still supports editor-created paragraphs and links.
  5. Empty product and link states do not create broken anchors.
  6. Keyboard focus remains visible on the selected destination.
  7. shopify theme check reports no syntax errors for the file.

Manual verification checklist

  • Every output value has an identified type and destination context.
  • Plain text uses escape at the HTML boundary.
  • JSON values use json without hand-built quoting.
  • JSON placed in an HTML attribute is also escaped for that attribute.
  • Trusted rich text uses a neutral container rather than a paragraph wrapper.
  • Links come from an appropriate URL source and have descriptive labels.
  • Inline handlers, scripts, and executable URL schemes are absent.
  • Difficult characters have been tested in the rendered DOM.

For the nested-markup case, read Avoiding nested paragraphs with Shopify rich text. The Liquid objects guide explains common object output, and the cheatsheet provides short filter reminders.

Conclusion

Safe Liquid output begins by naming the context. Use escape for plain values crossing into HTML, json for JSON serialization, both filters in the correct order when JSON crosses an HTML-attribute boundary, and direct output only for content types intentionally designed to contain trusted markup. Then test with characters that challenge the parser instead of relying on normal marketing copy.

Found this helpful?

Share it with your network!

Ready to Convert HTML to Liquid?

Try our free HTML to Liquid converter and build your Shopify themes faster.

Try HTML2Liquid Now