Architecting Secure Output in Handlebars: Escaping Defaults and Controlled Raw Markup
Handlebars escapes by default with {{ }} but allows raw output via {{{ }}}. This guide shows how to enforce a secure-by-default architecture: restrict triple-stash to audited SafeString helpers, define trust boundaries, and add operational checks that catch misuse before production.
19 Jul 2025, 22:36 UTC

Requirements: Secure-by-Default HTML Rendering
The primary requirement when rendering dynamic data in HTML templates is preventing cross-site scripting (XSS). Handlebars meets this by escaping every value interpolated with the double-stash syntax {{expression}} by default. Characters <, >, &, ", and ' are converted to their corresponding HTML entities (<, >, &, ", '). This behavior is automatic and cannot be disabled per-expression; it is the baseline for all template renderings targeting HTML.
Smallest Suitable Design: Reserve Triple-Stash for Audited Trusted Markup
The triple-stash syntax {{{expression}}} bypasses escaping entirely. The smallest safe design restricts {{{ }}} to a narrow, audited set of sources:
- Server-side helpers that return
Handlebars.SafeStringafter generating markup from trusted, non-user-controlled data. - Partials or components whose entire content is produced by application code (e.g., a CMS-rendered fragment that has already been sanitized).
- No direct interpolation of request parameters, database fields populated by users, or third-party API responses.
All other data — usernames, comments, search queries, product descriptions — must use {{ }}. This rule keeps the attack surface minimal and reviewable.
Trust and Data Boundaries
Define three explicit categories in your codebase:
| Category | Source Examples | Required Syntax |
|---|---|---|
| Untrusted | Request body, query string, user-generated content, external APIs | {{expression}} (escaped) |
| Trusted Markup | Server-rendered components, sanitized CMS output, SafeString helpers | {{{expression}}} (raw) |
| SafeString Helpers | Custom helpers returning new Handlebars.SafeString(html) | {{{helperName}}} (raw, but helper guarantees safety) |
A helper that returns SafeString must internally validate or sanitize its input. For example, a statusBadge helper should accept only a predefined enum of status values, never free-text.
Operational Checks
- Automated escaping verification: In your test suite, render a template with
{{''}}and assert the output contains<script>alert(1)</script>. - Triple-stash audit: Search the codebase for
{{{(three opening braces). Every occurrence must have a code-review comment linking to the helper or partial that supplies trusted markup. - SafeString contract tests: For each helper returning
SafeString, write a test that passes a malicious payload (e.g.,'<img src=x onerror=alert(1)>') and verifies the helper either rejects it or neutralizes it before wrapping inSafeString. - Contextual escaping gaps: Default escaping protects HTML body content. It does not protect unquoted attribute values (
<div id={{id}}>), JavaScript contexts (<script>var x = '{{val}}';</script>), or CSS contexts. Use context-aware helpers or a sanitizer like DOMPurify for those cases.
Failure Modes
- Accidental triple-stash on user data: A developer changes
{{comment}}to{{{comment}}}to "fix" double-escaped entities from a legacy system. This opens XSS if the legacy data ever contains markup. - SafeString with unsanitized input: A helper returns
new Handlebars.SafeString(userInput). The helper author assumes upstream validation, but the validation is removed in a refactor. - Non-HTML output targets: Generating JSON, CSV, or plain-text emails with the same templates. Escaping HTML entities in JSON strings produces invalid JSON (e.g.,
"instead of\"). The fix is a separate template set or a compile-time flag (Handlebars.compile(source, { noEscape: true })), but this removes XSS protection entirely for that render path.
Conditions That Would Change the Design
- Adopting a framework that enforces contextual auto-escaping (e.g., React's JSX, Angular's sanitization) — Handlebars escaping becomes a secondary defense.
- Migrating to a strict Content Security Policy (CSP) with
script-src 'self'and no'unsafe-inline'— reduces impact of any escaped payload that slips through, but does not replace escaping. - Switching template engine — the trust-boundary discipline (explicit raw-output allowlist) remains valid regardless of syntax.
Concrete Example: Comment Thread with Trusted Badge
const Handlebars = require('handlebars');
// Trusted helper: only allows known status values
Handlebars.registerHelper('statusBadge', function(status) {
const allowed = ['Active', 'Pending', 'Archived'];
if (!allowed.includes(status)) {
throw new Error(`Invalid status: ${status}`);
}
return new Handlebars.SafeString(`${status}`);
});
const templateSource = `
Author: {{author}}
Text: {{text}}
Status: {{{statusBadge status}}}
`;
const template = Handlebars.compile(templateSource);
const data = {
author: 'Alice',
text: 'Check this: ',
status: 'Active'
};
console.log(template(data));
// Output:
//
// Author: Alice
// Text: Check this: <script>alert(1)</script>
// Status: Active
//
Verification: run the snippet and confirm the script tags appear escaped in the Text paragraph while the badge renders as actual HTML.
Limitations
- Handlebars escaping is HTML-entity only. It does not protect against DOM-based XSS via
innerHTMLassignment of a template result that already contains raw markup from{{{ }}}. - No built-in support for JSON, URL, or CSS contextual escaping. You must implement or import helpers for those contexts.
- Global
noEscapeoption disables protection for the entire template; prefer per-template compilation with explicit context.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.