Handlebars Shows Raw HTML or Escaped Entities? A Diagnostic Guide to {{ }} vs {{{ }}}
Handlebars printing raw tags or escaped entities? Diagnose whether {{ }}, {{{ }}}, SafeString helpers, or pre-escaped data is the cause, and fix it without opening an XSS hole.
08 Mar 2026, 23:16 UTC

The symptom you're seeing
One of two things is wrong in your rendered page. Either you see literal text like <div> or visible tags such as <b>hi</b> printed as text where you expected real markup — or the opposite: unescaped user HTML is rendering as live markup where you expected safe, escaped text. Both symptoms come from the same mechanism: Handlebars' escaping rules around {{ }}, {{{ }}}, and Handlebars.SafeString. The fix is almost never "turn escaping off globally"; it's finding which expression or helper is on the wrong side of the rule.
This guide applies to the JavaScript Handlebars library (handlebars 4.x). Server-side ports for Java, .NET, or Rust have similar concepts but different APIs.
Cause table
| Symptom | Likely cause | Where to look |
|---|---|---|
HTML shows as text (<b> visible) | {{value}} escaping as designed | Template delimiters |
Entities show literally (<) | Data was pre-escaped upstream, then escaped again | Data source / API / database |
| User HTML renders as live markup | {{{value}}} or a SafeString bypassing escaping | Template or custom helper |
| Helper output is escaped unexpectedly | Helper returns a plain string | Helper return type |
Check 1: Which delimiters does the expression use?
Open the template and find the offending expression. The rule is simple:
{{value}}— HTML-escapes the value.<becomes<. This is the safe default.{{{value}}}— outputs the value raw, no escaping. Intended for trusted HTML only.
Confirm the behavior with a minimal reproduction. In a browser console or Node script with Handlebars loaded:
const tpl = Handlebars.compile('Escaped: {{v}} | Raw: {{{v}}}');
console.log(tpl({ v: 'hi' }));
// Escaped: <b>hi</b> | Raw: hiIf the left side shows tags as text and the right side renders bold, escaping is working exactly as documented — the question becomes which behavior your template actually needs.
Check 2: What does the helper return?
If the expression uses a custom helper like {{formatBody content}}, the delimiter isn't the whole story. A helper that returns a plain string gets escaped (with {{ }}); a helper that returns new Handlebars.SafeString(...) bypasses escaping:
Handlebars.registerHelper('trusted', s => new Handlebars.SafeString(s));
Handlebars.registerHelper('untrusted', s => s); // plain string: escapedGrep your codebase for SafeString and for {{{. For each hit, ask: is the data source user-controlled? If yes and there's no sanitization step, you've found a stored or reflected XSS vector, not just a display bug.
Check 3: Is the data pre-escaped upstream?
Double-escaping — seeing literal < on the page — means the value already contained entities before Handlebars touched it. Common sources: a CMS that stores HTML-encoded content, an API that escapes before serializing, or a previous template pass. Inspect the raw value (log it, or view the JSON response directly) rather than guessing from the rendered page. If the data arrives as &lt;div&gt;, the fix belongs at the source: store or transmit the raw text and let Handlebars do the single, correct escaping pass.
Fixes mapped to findings
- Trusted HTML showing as text: change
{{value}}to{{{value}}}, or have the helper return a SafeString — only after confirming the content is sanitized or fully trusted. - User input rendering as markup: change
{{{value}}}to{{value}}}immediately. If you genuinely need to allow some user HTML, sanitize it first with a library such as DOMPurify, then mark the sanitized result safe. Never SafeString raw user input. - Double-escaped entities: decode or stop pre-escaping at the data source. Do not "fix" this with triple-stash in the template — that re-opens the XSS hole for any other value flowing through the same field.
Escalation: when the checks don't explain it
If delimiters, helper return types, and data all look correct but output is still wrong, widen the investigation:
- Confirm the engine. Mustache and other lookalike templates have different escaping rules. Verify the page is actually compiled by Handlebars.
- Check precompile/runtime mismatch. Precompiled templates must be built with a Handlebars version compatible with the runtime loaded in the browser. A mismatch can throw or render incorrectly; compare the compiler version in your build pipeline against the runtime version in your bundle.
- Audit globally. Run a search for every
{{{andSafeStringusage and classify each by data source. This doubles as a security review.
Verify any fix by re-running the minimal reproduction above with your real data and confirming the rendered HTML in the DOM inspector — not just the page's visual appearance, which can hide entity problems.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.