${ } 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:& < > " '.
review.body set to <script>alert('xss')</script>:
/ 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:
link set to javascript:alert(1):
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.link
schema setting kind, which validates at the point the value is saved rather than
at render. See Sections and blocks.
JavaScript-evaluated attributes
Nativeon* 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 ' 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:
payload set to ');alert(1);(':
; and ( have no
JavaScript string escape. The value is encoded as a JSON literal instead, which
is self-delimiting whatever it contains:
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.
path set to a);color:red;x:url(b:
: 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:
doc set to <p>Hi <em>there</em></p><script>alert(1)</script><a href="javascript:alert(1)" target="_blank">x</a>:
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:
{html expr sanitize} runs the same input through an allowlist:
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,sourceandpicturesurvive withsrc, 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.classandidsurvive, so content can adopt your stylesheet’s classes and collide with element IDs your own scripts query.
${ } 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:
handler set to ');alert(1);(':
');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:
attrs set to class="x" onclick="alert(1)" x-show="open":
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.
Call validate_full, not validate
Call validate_full, not validate
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.Keep secrets out of the render context
Keep secrets out of the render context
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.Do not use a WASM render as the production render
Do not use a WASM render as the production render
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.--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.