Output Encoding
The fix for XSS
SQL injection is fixed by parameterized queries. XSS is fixed by output encoding: converting special characters in untrusted data into a safe representation before inserting them into the output.
The key insight: different contexts require different encoding. HTML, JavaScript, URLs, and CSS each have different special characters, and each needs its own encoding.
HTML context
When inserting untrusted data into HTML content (between tags), encode HTML special characters:
function escapeHtml(input: string): string {
return input
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
} Before encoding: <script>alert('XSS')</script>
After encoding: <script>alert('XSS')</script>
The browser renders this as visible text, not as a script tag. The user sees the literal string <script>alert('XSS')</script> on the page. The script does not execute.
Apply it to the vulnerable route:
route.get("/notes/:id/view", {
resolve: (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const note = db
.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?")
.get(c.params.id, user.id) as any;
if (!note) return Response.json({ error: "Not found" }, { status: 404 });
// SAFE: HTML-encoded
const html = `<html><body><h1>${escapeHtml(note.title)}</h1><p>${escapeHtml(note.body)}</p></body></html>`;
return new Response(html, { headers: { "content-type": "text/html" } });
},
}); Attribute context
When inserting data into an HTML attribute, use the same HTML encoding plus always quote the attribute:
<!-- WRONG — unquoted attribute -->
<div data-name="${escapeHtml(name)}"></div>
<!-- CORRECT — quoted attribute -->
<div data-name="${escapeHtml(name)}"></div> Without quotes, the attacker can break out of the attribute with a space and inject new attributes like onmouseover=alert(1).
JavaScript context
When inserting data into JavaScript code (e.g., inside a <script> tag), HTML encoding is not enough. You need JSON encoding:
// WRONG — HTML encoding does not help inside <script>
const html = `<script>var data = "${escapeHtml(input)}";</script>`;
// CORRECT — JSON encoding produces a safe JavaScript string
const html = `<script>var data = ${JSON.stringify(input)};</script>`; JSON.stringify escapes quotes, backslashes, and control characters, producing a valid JavaScript string literal. It also wraps the value in quotes.
[!TIP] The safest approach is to avoid inserting untrusted data into
<script>tags entirely. Instead, put data in adata-attribute (HTML-encoded) and read it from JavaScript:<div id="app" data-username="${escapeHtml(name)}"></div> <script> const name = document.getElementById("app").dataset.username; </script>
URL context
When inserting data into a URL, use encodeURIComponent:
// WRONG — unencoded user input in URL
const link = `<a href="/search?q=${query}">Search</a>`;
// CORRECT — URL-encoded
const link = `<a href="/search?q=${encodeURIComponent(query)}">Search</a>`; encodeURIComponent encodes characters that have special meaning in URLs (&, =, ?, #, spaces, etc.).
For the href attribute itself, also check the scheme. An attacker could use javascript:alert(1) as a URL:
function isSafeUrl(url: string): boolean {
try {
const parsed = new URL(url, "http://localhost");
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
} The encoding rule
Encode output based on where it goes, not where it comes from. The same data might need different encoding in different contexts:
| Context | What to encode | How |
|---|---|---|
| HTML content | <, >, &, ", ' | escapeHtml() |
| HTML attribute | Same as above, plus always quote | escapeHtml() + quotes |
| JavaScript | Quotes, backslashes, control chars | JSON.stringify() |
| URL parameter | &, =, ?, #, spaces | encodeURIComponent() |
| CSS | Rarely needed in APIs | Avoid inserting user data into CSS |
API responses and JSON
If your API returns JSON (which ours does for most routes), XSS is not a direct concern for the API response. The browser does not execute JSON as HTML. XSS becomes a concern when your API also serves HTML pages, or when a frontend framework renders API data as HTML.
The escapeHtml function is most important for the HTML-rendering routes we added (the /notes/:id/view endpoint and the search page).
Exercises
Exercise 1: Add escapeHtml and apply it to the note view route. Try the <script>alert('XSS')</script> title again. You should see the text rendered literally, not as a script.
Exercise 2: Try <img src=x onerror=alert(1)>. Does escapeHtml prevent it? (Yes — the < and > are encoded.)
Exercise 3: Create a note with the title " onclick="alert(1). Without quotes on the attribute, this would inject an event handler. Verify that quoting the attribute blocks this.
Why is HTML encoding insufficient when inserting data into a JavaScript context?