Skip to main content
Every ${ } is escaped for the exact place it lands rather than by one general-purpose rule. This page lists which contexts get which treatment, with hostile values rendered and the real output printed, plus the three places where nothing is escaped. The full threat model, with the enforcing function named for every property, is in the security model. This page is the practical summary.
Every output below is what the compiler emits. slurp build then minifies, which may drop quotes and decode entities that are unambiguous in position. The two forms parse identically.

HTML text

Five characters are encoded: & < > " '.
With review.body set to <script>alert('xss')</script>:
It renders as visible text. / is left alone: it has no meaning in HTML text, and encoding it would corrupt every URL and date string that passes through. This is compile-time, always on, and there is no opt-out short of {html}.

Attribute values

An attribute value gets the same five characters plus a backtick, more than a double-quoted attribute strictly needs. The extra characters mean a value cannot break out even if the surrounding quoting is later changed.

URL attributes

href src xlink:href poster formaction action cite background data ping srcset are checked for a script scheme. A javascript:, data: or vbscript: value collapses to empty rather than the attribute being dropped, so the markup shape does not change:
With link set to javascript:alert(1):
Detection strips control characters, Unicode whitespace, zero-width characters and bidi controls before matching, so padding the scheme with invisible characters does not get past it. srcset is on the list but is checked only at its leading scheme, and a srcset value is a comma-separated list of candidates. A scheme in the second or later candidate is not caught. Those URLs are fetched as images rather than executed, so it is a resource-load question, but do not treat srcset as validated. The bound Alpine forms are checked too. On :href or x-bind:src the attribute value is a JavaScript expression rather than a URL, so an interpolated slot arrives wrapped in quotes as a string literal. Slurp unwraps one layer of literal before testing the scheme, so this is neutralised the same way a plain href is:
Only a whole string literal is unwrapped. A binding whose value is a concatenation, an identifier or a ternary is left exactly as written, because its value is not knowable at compile time and guessing would break ordinary bindings. So :href="'/p/' + slug" renders untouched. An expression like that cannot carry an attacker-chosen scheme in the leading position.
For a URL that a merchant supplies through an editing form, prefer the link schema setting kind, which validates at the point the value is saved rather than at render. See Sections and blocks.

JavaScript-evaluated attributes

Native on* handlers and the Alpine attributes are evaluated as JavaScript by the browser, and entity escaping alone does not protect them: the HTML parser decodes character references before the JavaScript engine reads the value, so &#39; is a ' again at exactly the layer that matters. Slurp escapes each slot according to where it lands in the author’s own text. Inside an author-written string literal, the JavaScript metacharacters are backslash-escaped:
With payload set to ');alert(1);(':
The browser decodes the entities, the backslashes remain, and the value stays a single string. In code position there is nothing to escape into, because ; and ( have no JavaScript string escape. The value is encoded as a JSON literal instead, which is self-delimiting whatever it contains:
Choosing a filter here, and the compile errors around it, are covered in Scripts and attributes.

style attributes

CSS has no escaping that works in every grammatical position, so the structural characters are removed from a slot in style, :style or x-bind:style: ; { } ( ) " ' \ @ * < > and NUL.
With path set to a);color:red;x:url(b:
The injected declaration is gone. : and / survive because neither opens a declaration on its own and both are needed by the legitimate shape. A <style> body is a raw text element, so ${ } there is not interpolated at all and emits as literal characters. There is no way to put a value into a stylesheet body.

srcdoc

An iframe srcdoc holds an entire document: the attribute parser decodes it once and then the result is parsed as HTML, which undoes attribute escaping completely. So the value is run through the same allowlist sanitizer that {html expr sanitize} uses, at the layer the iframe will actually see:
With doc set to <p>Hi <em>there</em></p><script>alert(1)</script><a href="javascript:alert(1)" target="_blank">x</a>:
The script is gone, the javascript: href is gone, the markup survives, and the anchor picked up rel="noopener noreferrer" on the way through. This applies to the plain attribute. The bound form (:srcdoc) is a JavaScript expression, so the rules in the previous section cover it instead.

Raw HTML: {html} and sanitize

{html expr} is the only way to emit unescaped markup. It asserts that the value is trusted:
Everything came through, script included. Use this only for HTML your own application produced. {html expr sanitize} runs the same input through an allowlist:
The allowlist is the contract: anything not on it is stripped, including tags that do not exist yet. script, iframe, form, style and similar tags are absent from it rather than being special-cased, and every on* handler disappears because no attribute outside the list is ever kept. Permitted URL schemes are http, https and mailto. The full tag and attribute lists are in the security model. Two things sanitize does not do:
  • img, source and picture survive with src, so sanitized content can still load an off-origin image. That is a tracking beacon: it leaks the reader’s IP address and a referrer to whoever wrote the content.
  • class and id survive, so content can adopt your stylesheet’s classes and collide with element IDs your own scripts query.
If no HTML at all should appear, use a plain ${ } rather than a sanitizer.

The three places nothing is escaped

Each one carries the same rule: only put values the theme itself controls here.

attr={ expr } on a JavaScript-evaluated attribute

The brace form carries no surrounding author text, so there is no quote context to read and the renderer does not guess. The value gets attribute escaping only, which is inert in an attribute the browser will evaluate:
With handler set to ');alert(1);(':
The browser decodes that back to ');alert(1);(' and hands it to Alpine. This form is for composing handlers out of theme-written expressions, so it is not escaped. Use the quoted form for anything carrying data. srcdoc is the one exception: it is sanitized no matter who composed it, because the value becomes a whole document.

{html expr} without sanitize

Covered above. Nothing is escaped.

{html expr} as an attribute spread

The spread form forwards whole attributes:
With attrs set to class="x" onclick="alert(1)" x-show="open":
The onclick was dropped, which looks like a sanitizer and is not one. The name gate rejects native on* handlers (including the :onclick and x-bind:onclick aliases) plus x-html, x-data, x-init and x-effect, and that is all it can do: x-show survived, and Alpine evaluates x-show as JavaScript. An allowlist that admits :disabled="loading" cannot also exclude :title="alert(1)", because they are the same construct. What the gate buys is narrowing, not safety. Never route untrusted data into an attribute spread.

Untrusted templates

Rendering a template from an untrusted source divides into engine guarantees and host responsibilities. What the engine guarantees. A template is data transformed into markup: no code runs at render time, there are no callable functions, and rendering performs no network request and reads no file the host did not hand it. Every ${ } is escaped for its context. The budgets on iteration, depth, output size and filter memory bound what any single render can cost. A template author who wants to inject script has to go through one of the three unescaped forms above, all of which are greppable. What the host has to do.
Several rules live in the compiler’s security walk rather than in the parser: an unfiltered ${ } in a <script> body, a false | js claim, env.SLURP_SECRET_*, and request.* or {redirect} outside middleware. The WASM validate export does not run that walk, so a tool calling it will report a file clean that slurp build then rejects. An authoring tool, publish gate or CI check should call validate_full.
The compiler never reads the process environment. env is an ordinary context root that the host populates, exactly like product or site. The compiler refuses a template that names env.SLURP_SECRET_*, which catches an author naming a secret the obvious way, and it cannot catch a host that put a secret in the context under a different key. If you populate env from the process environment, copy across an explicit allowlist.
render and render_dev do not run the security walk. The render-time backstops still fire, so a <script> body slot and a false | js claim are still caught, but the middleware and secret-env rules are not.
Rendering is also total: missing data becomes the empty string, budgets truncate rather than aborting, and unknown editor settings are dropped rather than rejected. An untrusted template is therefore safe to render in a shared service, and a badly wrong template still returns a 200. Build with --verbose and read Common mistakes.

Summary

Next

Scripts and attributes

| js versus | json, and the errors around them.

Security model

The threat model, the image optimizer and dev server, and the known limits.