- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You wired up a Go web handler. User submits a comment. You render
it back into a page with html/template. Someone posts
<script>steal()</script> as their comment, and when the page
loads, nothing runs. The tag shows up as literal text. You didn't
call an escape function. You didn't sanitize anything. It just
worked.
That is html/template doing its job. It is one of the few
XSS defenses in any language that is on by default and understands
where your data lands. Most template engines make you remember to
escape. Go's makes you work to turn escaping off. The trouble is
that turning it off is easy, and a lot of real code does it by
accident.
text/template and html/template are not the same package
First thing that trips people up: Go ships two template packages
with nearly identical APIs.
import "text/template"
import "html/template"
text/template does no escaping at all. It substitutes values as
raw strings. html/template wraps the same engine and adds
context-aware escaping on top. The function signatures match, the
{{.Field}} syntax matches, so you can swap one import for the
other and everything still compiles. That is the danger. Import
text/template for something that renders HTML, and you have shipped
an XSS hole that builds clean and passes tests.
Rule one: if the output is HTML, the import is html/template.
Nothing else in the code tells you which one you got. Check the
import line.
Context-aware means it escapes differently per position
The clever part is not that html/template escapes. It is that it
escapes based on where the value sits in the document. The same
value gets encoded differently depending on whether it lands in HTML
text, an attribute, a URL, or a <script> block.
package main
import (
"html/template"
"os"
)
func main() {
const page = `
<p>{{.}}</p>
<a href="/search?q={{.}}">link</a>
<a href="{{.}}">click</a>
<script>var x = {{.}};</script>`
t := template.Must(template.New("p").Parse(page))
t.Execute(os.Stdout, `javascript:alert("x")`)
}
Feed it a javascript: payload and the same input comes out four
different ways:
<p>javascript:alert("x")</p>
<a href="/search?q=javascript%3aalert%28%22x%22%29">link</a>
<a href="#ZgotmplZ">click</a>
<script>var x = "javascript:alert(\"x\")";</script>
Four positions, four encodings. HTML entity escaping in the
paragraph, where the value is harmless text. URL query escaping
inside the href query string. JS string escaping inside the
<script>. And the bare href="{{.}}" got replaced with
#ZgotmplZ because the engine could not prove the value was a safe
URL scheme, so it refused to emit it. The parser tracks its position
in the document as it walks the template and picks the encoder that
matches. You do not annotate anything.
That ZgotmplZ sentinel is worth remembering. When you see it in
rendered output, it means the escaper blocked a value it judged
unsafe in a URL context, most often a javascript: scheme. It is
the engine telling you it stopped something.
Footgun one: template.HTML turns escaping off
The escaping is bypassable by design, because sometimes you really do
have trusted HTML to inject. The bypass is a set of named string
types in the html/template package: template.HTML,
template.JS, template.URL, template.CSS, and a few more. A
value of one of those types is treated as pre-trusted for its
context and emitted without escaping.
package main
import (
"html/template"
"os"
)
func main() {
t := template.Must(
template.New("x").Parse(`<div>{{.}}</div>`))
raw := `<img src=x onerror=alert(1)>`
t.Execute(os.Stdout, raw) // escaped
os.Stdout.WriteString("\n")
t.Execute(os.Stdout, template.HTML(raw)) // NOT escaped
}
Output:
<div><img src=x onerror=alert(1)></div>
<div><img src=x onerror=alert(1)></div>
Same bytes, same template. The first is inert text. The second is a
live XSS payload, because wrapping the string in template.HTML told
the engine "trust me, this is safe HTML." The engine believed you.
The type is not the problem. Rendering a rich-text field, a Markdown
body you already sanitized, an admin-authored snippet, those are real
uses. The problem is template.HTML(userInput). The moment a
template.HTML (or template.JS, or template.URL) conversion sits
on the same line as anything a user controls, the auto-escaping for
that value is gone and you own the safety yourself.
If you sanitize before wrapping, do it with a real HTML sanitizer
like bluemonday, not a
hand-rolled string replace. Then wrap the sanitizer's output, never
the raw input.
Grep your codebase for template.HTML(, template.JS(, and
template.URL(. For each hit, trace the argument back to its source.
If it reaches user input without a sanitizer in between, that is a
bug.
Footgun two: building HTML with string concatenation
This is the more common one, and it is subtle because the code looks
like it is still using the safe package.
html/template can only escape what it parses as a template. If you
build markup by gluing strings together and hand the finished string
to the engine, the engine sees one opaque blob. It never gets the
chance to escape the user portion, because by the time it looks, the
user portion is already fused into the surrounding tags.
// BROKEN: user data concatenated into the template text
func render(comment string) string {
src := "<p>" + comment + "</p>"
t := template.Must(template.New("c").Parse(src))
var b strings.Builder
t.Execute(&b, nil)
return b.String()
}
Here comment is part of the template source, not the data passed
to Execute. If comment contains {{, you get a parse error or
worse. If it contains <script>, it becomes part of the parsed
template structure and there is no data value for the engine to
escape. The auto-escaper protects the {{.}} action holes. This code
has no action hole for the user data. It put the data in the wall,
not the window.
The same mistake wearing a different coat:
// ALSO BROKEN
tmpl := fmt.Sprintf(`<p>%s</p>`, userComment)
t := template.Must(template.New("c").Parse(tmpl))
fmt.Sprintf builds the template string from user input. Same
disease. The fix is to keep the template static and pass the user
value as data, so it flows through an action and gets escaped:
// CORRECT: template is static, user value is data
func render(comment string) (string, error) {
t := template.Must(
template.New("c").Parse(`<p>{{.}}</p>`))
var b strings.Builder
if err := t.Execute(&b, comment); err != nil {
return "", err
}
return b.String(), nil
}
The template literal is a constant. The user's comment arrives
through Execute as the dot value, lands in the {{.}} action, and
the context-aware escaper does its work. Now <script> comes back as
<script>.
The principle: templates are code, data is data. The template string
should be a compile-time constant (or loaded from a trusted file),
never assembled from request input. Every dynamic value belongs in an
action, passed through Execute, so the parser can see it as a value
and escape it for its position.
Parse your templates once, at startup
A related habit that keeps you honest: parse templates once when the
program starts, not per request.
var tmpl = template.Must(template.ParseFiles(
"templates/comment.html",
))
func handler(w http.ResponseWriter, r *http.Request) {
comment := r.FormValue("comment")
tmpl.Execute(w, comment) // escaped, per context
}
template.Must wraps a parse call and panics if it fails, which is
what you want at startup: a broken template should crash the process
on boot, not on the first request that hits it. Parsing per request
also tempts you toward the concatenation footgun, because it puts the
Parse call right next to the request data. Keep parse at init, keep
Execute in the handler, and the user value has only one path in:
through the data argument, where it gets escaped.
What the escaper does not cover
Auto-escaping handles the standard positions well. It does not make
you invulnerable, and a couple of gaps are worth naming so you do not
assume more than it gives.
An unquoted attribute is a real risk. html/template escapes for it,
but the safe move is to quote your attributes in the template so the
engine has a clear boundary to escape against. It also cannot save an
attribute whose value is a javascript: URL you forced through with
template.URL — that is the bypass doing exactly what you told it.
And content inside a <style> block or an inline style attribute
follows CSS rules; feeding user input there without care is its own
category. The engine escapes for CSS context, but complex CSS values
deserve the same suspicion as complex URLs.
None of this is a reason to distrust the package. It is the same
message as everything above: the defense is strong exactly where you
let the engine see the value as data in a position it understands.
Concatenation and the template.HTML family are the two ways to take
that visibility away.
The three-line checklist
Before a Go HTML handler ships, three greps:
- Confirm the import is
html/template, nottext/template, on anything that emits HTML. - Grep for
template.HTML(,template.JS(,template.URL(. Trace every argument to its source. User input without a sanitizer is a bug. - Grep for template source built with
+,fmt.Sprintf, orstrings.BuilderbeforeParse. The template text must be static; user data goes throughExecute.
Get those three right and Go's escaper covers the common XSS surface
for free. Get any of them wrong and the auto-escaping is off for that
value, silently, with no error and no warning.
If this was useful
The escaping in html/template is one of those pieces of the
standard library that rewards knowing exactly how it works: the
context tracking, the ZgotmplZ sentinel, and the trusted-string
types all make more sense once you have seen the parser's state
machine. The Complete Guide to Go Programming goes into the stdlib
and the runtime at that depth. Hexagonal Architecture in Go is
about keeping rendering at the right boundary, so user input never
reaches a template except as data through a port you control.

Top comments (0)