# SQL Injection & XSS, Explained

> The two classic injection holes share one root cause - user input getting treated as code - and one cure: keep data as data. Learn the mental model, then how to close SQL injection with parameterized queries and XSS with context-aware output encoding.


---

# SQL Injection & XSS, Explained

You've heard both names a hundred times - they sit near the top of every security checklist - and you've
probably nodded along without ever being shown what's *actually* going wrong underneath. That's the gap
this guide closes. The reassuring part: SQL injection and Cross-Site Scripting are not two unrelated
monsters. They're the *same* bug wearing two costumes, and once you see the shared cause, both fixes stop
feeling like memorized rules and start feeling obvious.

The one idea: an injection bug happens when something you *meant* as plain data - a username, a comment, a
search term - gets handed to a machine that reads it as **instructions** instead. A database that reads your
input as SQL. A browser that reads your input as HTML and JavaScript. The cure, in both cases, is the same
sentence: **keep data as data, so it can never be mistaken for code.**

We'll explain each hole the way a careful teammate would: enough of the mechanism to truly understand it,
then the real, production-grade fix.

## How to read this

- **Want to actually understand why these bugs exist?** Read in order. Phase 1 installs the one mental model
  both holes share; Phases 2 and 3 each take one hole and close it for good.
- **Already know the theory and just need the fix?** Jump to [Phase 2: SQL Injection](02-sql-injection.md)
  for parameterized queries, or [Phase 3: Cross-Site Scripting (XSS)](03-cross-site-scripting.md) for
  output encoding and CSP.

## The phases

1. **[The One Bug Underneath Both: Mixing Data with Code](01-the-one-bug-underneath-both.md)** - the unifying
   mental model. Injection is what happens when input is read as code; the fix is always "keep data as data."
2. **[SQL Injection](02-sql-injection.md)** - how a string-built query lets input rewrite the query's
   meaning, what an attacker gets, and the real fix: parameterized queries / prepared statements.
3. **[Cross-Site Scripting (XSS)](03-cross-site-scripting.md)** - how untrusted input rendered into a page
   runs as script in other people's browsers, the damage it does, and the real fix: context-aware output
   encoding, auto-escaping templates, and a Content-Security-Policy.

> This guide is the focused, two-holes-one-model deep dive. The broader catalog of web vulnerabilities -
> broken access control, misconfiguration, vulnerable dependencies, and the rest - lives in
> [The OWASP Top 10](/guides/owasp-top-10). If you want to brush up on what a `SELECT ... WHERE` query
> even is before Phase 2, see [Querying Basics: SELECT & WHERE](/guides/querying-basics-select-where).


---

# The One Bug Underneath Both: Mixing Data with Code

Security guides usually hand you SQL injection and XSS as two separate chores: memorize this fix for the
database, memorize that fix for the web page, move on. That's why they never stick - they look unrelated, so
they feel like two more spells to keep straight.

They're not unrelated. They are the *same* bug. Once you see the shared cause, you'll be able to *predict*
both fixes instead of recalling them, and you'll start spotting the same shape in places this guide never
even mentions.

## The one idea: data should never be able to become code

Every program constantly juggles two different kinds of strings:

- **Code** - instructions the machine *executes*. SQL statements. HTML and JavaScript a browser runs.
- **Data** - values the program just *handles*. A username someone typed. A search term. A comment.

When you write a program, *you* write the code. Your users supply the data. That line - your instructions
vs. their values - is supposed to be a wall.

**An injection bug is a hole in that wall.** It happens when a value the user supplied gets fed to some
interpreter - a database, a browser - in a way that lets the value *escape* its role as data and get read as
*code* instead. The user stops being someone who fills in a blank and becomes someone who can rewrite your
program's instructions.

```text
  THE WALL THAT SHOULD HOLD                THE HOLE (injection)

  ┌─────────────┐                          ┌─────────────┐
  │  your code  │  ← you write this        │  your code  │
  ├─────────────┤                          ├─────────────┤
  │ user input  │  ← stays "just a value"  │ user input  │ ─┐ input crosses the
  └─────────────┘                          └─────────────┘  │ line and is read
        the interpreter sees a clear              the interpreter can't tell  │ as CODE
        boundary: code here, data there          where code ends and the     │
                                                 user's value begins  ◄──────┘
```

📝 **Terminology - "interpreter."** Anything that takes a string and acts on its meaning: a SQL database
reading a query, a web browser reading HTML, a shell running a command. Injection is always "untrusted input
reaching an interpreter as code." Different interpreter, different name - same bug.

## Why this keeps happening: the convenient, dangerous shortcut

Both holes are born the same way - by building a piece of code by **gluing strings together**, with user
input glued right in the middle:

```text
   "fixed instructions"  +  user_input  +  "more instructions"
   └──── your code ────┘    └─ their ─┘    └─── your code ───┘
                            └ value, but the interpreter only
                              sees one long string ──────────┘
```

When you concatenate like this, the boundary between *your* instructions and *their* value exists only in
your head. By the time the interpreter receives the string, that boundary is gone - it's just one run of
characters. If the user's value contains characters that *mean something* to the interpreter (a quote that
ends a SQL string, a `<` that starts an HTML tag), those characters do their job, and the value reshapes the
code around it.

That's the whole disease. SQL injection is this shortcut feeding a database. XSS is this shortcut feeding a
browser.

## The two costumes

The same bug, told twice:

| | SQL injection | Cross-Site Scripting (XSS) |
|---|---|---|
| The interpreter | Your database | A visitor's web browser |
| Code it confuses input for | SQL commands | HTML / JavaScript |
| What goes wrong | Input changes what the query *does* | Input becomes script the page *runs* |
| Who gets hurt | Your data (read, changed, deleted) | Your users (their session, their browser) |
| The shape of the fix | Send code and data on *separate channels* | *Encode* input so it can't be read as markup |

Two interpreters, two costumes - but look at the bottom row. Each fix is the same instinct applied to a
different interpreter: **stop letting the user's value be read as code.** For the database, you do that by
handing it the query and the values *separately*, so it never even tries to parse the value as SQL. For the
browser, you do it by *encoding* the value so the characters that mean "this is markup" arrive as harmless
text instead.

💡 **Key point - the one sentence to carry into both phases.** *Keep data as data.* You are never trying to
guess and block "bad input." You're making it structurally impossible for input to be treated as code in the
first place. Blocklists ("strip out the word `DROP`", "remove `<script>`") fail because attackers have
endless ways to write the same thing; keeping data on its own channel can't be tricked, because the
interpreter is never invited to parse the value as code at all.

⚠️ **Gotcha - "I'll just validate the input and I'm safe."** Input validation (rejecting an email that has
no `@`, capping a length) is good hygiene and worth doing. But it is *not* the fix for injection, and leaning
on it as the fix is how people get burned. Validation asks "does this look reasonable?" - a question with no
reliable answer for free-text fields like names, comments, or search terms, where `O'Brien` and `<3` are
perfectly legitimate. The real fixes in Phases 2 and 3 don't depend on predicting bad input; they keep the
data/code boundary intact no matter what the input contains.

## Why this saves you later

Hold this one model and the rest of the guide reads like consequences, not commandments: Phase 2's
"parameterize your queries" and Phase 3's "encode on output" are the same instinct, aimed at two different
interpreters. And the next time you wire *any* input into *any* interpreter - a shell command, an LDAP
filter, a file path - you'll feel the same alarm: keep data as data.

## Recap

1. Programs juggle **code** (instructions a machine runs) and **data** (values it just handles). The line
   between them is a wall.
2. **Injection is a hole in that wall** - user input reaching an interpreter in a way that lets it be read as
   *code* instead of *data*.
3. The usual cause is **building code by gluing strings together** with user input in the middle, which
   erases the boundary before the interpreter ever sees it.
4. SQL injection and XSS are the *same* bug aimed at two interpreters: the **database** and the **browser**.
5. The cure for both is one sentence: **keep data as data** - make it structurally impossible for input to
   become code, rather than trying to guess and filter "bad" input.

Now let's take the first interpreter - the database - and watch the wall come down, then build it back up
properly.


---

# SQL Injection

Here is the first interpreter from Phase 1: your database. It speaks SQL, and SQL is *code* - `SELECT`,
`WHERE`, `DROP` are all instructions it executes. SQL injection is what happens when user input meant as a
*value* in a query gets read as more of that code.

You already know the cause (gluing input into a string) and the cure (keep data as data) from Phase 1. This
phase makes both concrete: watch a normal query turn into a different one, see what that hands an attacker,
then build the query the right way.

> ⏭️ Shaky on what a `SELECT ... WHERE` query is or how `WHERE` filters rows? A quick read of
> [Querying Basics: SELECT & WHERE](/guides/querying-basics-select-where) will make this phase land harder.

## How a query loses control of its own meaning

Picture a login form. The app takes the username someone typed and looks them up. The tempting way to write
that is to build the SQL string by concatenation:

```text
   query = "SELECT * FROM users WHERE username = '" + input + "'"
            └──────────── your code ───────────────┘  └input┘ └┘
                                                       glued straight in
```

Type the username `alice` and you get exactly the query you intended:

```sql
SELECT * FROM users WHERE username = 'alice'
```

*What just happened:* The database sees `'alice'` as a text value inside the quotes, compares it against the
`username` column, and returns Alice's row - working as designed, because the input behaved like plain data.

Now an attacker types a single quote instead of a username - the exact character that *ends a text value in
SQL*:

```sql
SELECT * FROM users WHERE username = '' OR '1'='1'
```

*What just happened:* The leading `'` closed your `username` string early. Everything after it -
`OR '1'='1'` - landed *outside* the quotes, so the database read it as **more SQL**. And `'1'='1'` is always
true, so `WHERE` now matches *every* row. The query you wrote to fetch one user just returned the whole
table - the boundary between your code and their data only ever existed in your head.

⚠️ **Gotcha - the danger isn't the quote character, it's that input reached the parser as code.** Don't
conclude "so I'll just strip out quotes" - that's a blocklist, and blocklists lose: numeric contexts need no
quote at all, databases have different escape/comment syntax, and attackers have decades of tricks for
smuggling the same meaning past a filter. The hole is that the value got parsed as SQL at all. Close *that*,
and you stop playing whack-a-mole forever.

## What this actually costs you

That "always true" trick is the gentle version. In the wild, the same mechanism lets an attacker:

- **Read data they should never see** - other users' rows, password hashes, private records.
- **Change data** - flip their own account to admin, alter balances, rewrite records.
- **Destroy data** - depending on how the app connects, delete a table outright.

This is consistently rated among the most damaging web vulnerabilities because the payoff is your *entire
database*. SQL injection sits inside the **Injection** category of [The OWASP Top 10](/guides/owasp-top-10).

🪖 **War story - "Exploits of a Mom."** The xkcd comic (#327) has a mother name her son
`Robert'); DROP TABLE Students;--`. A school's system glues that name into a SQL statement, the `'` and `)`
close the intended command, `DROP TABLE` runs, and the student records are gone. A joke, but a real bug - the
name was data, and the system let it become code.

## The fix: parameterized queries (a separate channel for values)

The cure is the Phase 1 sentence made literal. Instead of one string mixing your SQL with their value, you
hand the database **two separate things**:

1. The SQL, with a **placeholder** where the value goes (often `?` or `$1` or `:name`).
2. The actual value, passed alongside - separately.

The database compiles the SQL *first*, placeholder standing in for "a value goes here" - the structure is
locked. *Then* it slots the value in as **pure data**, never re-parsing it as SQL. There's no string for the
attacker's quote to break out of, because code and value were never glued together.

📝 **Terminology - parameterized query / prepared statement.** Used interchangeably. A *prepared statement*
is SQL sent with placeholders so the database can plan it once; a *parameterized query* is any query where
values pass through placeholders instead of concatenation. Either way: **SQL and values travel on separate
channels.**

```mermaid
flowchart LR
  subgraph hole["CONCATENATION (the hole)"]
    c1["SQL + value → one string"] --> c2["DB parses the whole string as SQL<br/>- value can redraw the query"]
  end
  subgraph fix["PARAMETERIZED (the fix)"]
    p1["SQL with ?"] --> p3["DB compiles structure first"]
    p2["value"] --> p4["slotted in as pure data<br/>- never parsed as SQL"]
  end
```

Same login lookup, done right. (Python's `sqlite3` here; every mainstream language and driver has the
identical pattern - only the placeholder character changes.)

```console
>>> username = "' OR '1'='1"          # the exact attack from before
>>> cur.execute(
...     "SELECT * FROM users WHERE username = ?",   # placeholder, not concatenation
...     (username,)                                 # value passed separately
... )
>>> cur.fetchall()
[]
```

*What just happened:* SQL and value traveled on separate channels. The database looked for a user whose
username is *literally the string* `' OR '1'='1` - quotes and all. No such user exists, so it returned
nothing. Nothing was blocked or sanitized; the attack *never had a chance to be code*.

⚠️ **Gotcha - placeholders are for values, not for structure.** Parameters fill in *values* (a username, an
id, a price). You cannot parameterize a table name, a column name, or the `ASC`/`DESC` in an `ORDER BY` -
those are structure, compiled before values arrive. If users must influence structure (say, which column to
sort by), never concatenate raw text - map their choice against an **allowlist** of values you control and
reject anything else.

## ORMs and query builders help - but know what they're doing

An **ORM** (SQLAlchemy, Django's ORM, Prisma, ActiveRecord) or query builder lets you express queries in
your language and parameterizes underneath. Idiomatic ORM code is safe by default.

The catch is the escape hatch: every ORM has a "drop to raw SQL" feature for queries it can't express, and
the moment you use it, you're back to writing SQL by hand - and back on the hook for parameterizing it. Build
that raw string by concatenation and the ORM's protection does nothing for you.

```text
   ORM normal query        →  parameterized for you   ✅ safe
   ORM raw-SQL escape hatch →  YOUR responsibility     ⚠️ parameterize it yourself
```

💡 **Key point.** No exceptions worth remembering: **never assemble SQL by concatenating user input.** Use
parameterized queries everywhere - directly, or via an ORM - and treat the raw-SQL escape hatch as the one
place you must consciously parameterize by hand.

## Recap

1. SQL injection happens when **user input glued into a query string** is read as SQL, changing what the
   query *does*.
2. The classic tell is a value that **closes a quote** and adds clauses like `OR '1'='1'` - but the real
   problem is input reaching the parser as code at all, so **don't rely on filtering characters.**
3. The damage is your whole database: data **read, changed, or destroyed** - the core of the OWASP
   **Injection** risk.
4. **The fix is parameterized queries / prepared statements:** SQL (with placeholders) and values travel on
   **separate channels**, so values are always data, never SQL.
5. **ORMs parameterize for you** on the normal path - the **raw-SQL escape hatch is your responsibility**.
   Placeholders are for *values*; gate structural choices with an **allowlist**.

Same model, second interpreter: now let's hand untrusted input to a *browser* and watch the identical bug
wear its other costume.

Watch it animated: [SQL injection](/explainers/SQLInjection.dc.html)


---

# Cross-Site Scripting (XSS)

Same bug, second interpreter. In Phase 2 the interpreter was your database and the code was SQL. Here the
interpreter is **a visitor's web browser**, and the code is **HTML and JavaScript**. Cross-Site Scripting is
what happens when input meant as *text on a page* gets read by the browser as *markup and script* instead.

The cruel twist: with SQL injection the attacker hits *your* data. With XSS, the attacker's code runs in
**other users' browsers** - the script you accidentally served executes with *their* logged-in session. The
victim isn't you; it's your user, trusting your site.

## How text on a page turns into running script

Take any page that echoes user input back to other people: a comment, a display name, a search term in
"results for ___." Drop that input straight into the HTML by concatenation - the exact Phase 1 shortcut -
and you have the hole.

```text
   page = "<p>Comment: " + input + "</p>"
           └──── your code ───┘ └in┘ └code┘
                                glued into HTML the browser will parse
```

Type a normal comment, `Nice article!`, and the browser renders exactly what you intended:

```html
<p>Comment: Nice article!</p>
```

*What just happened:* The browser parsed `<p>` as markup and the comment as text inside it - fine, because
the input behaved like plain data.

Now an attacker leaves a "comment" that's actually a `<script>` tag:

```html
<p>Comment: <script>/* attacker's JavaScript runs here */</script></p>
```

*What just happened:* The browser doesn't know your `<p>` was intended and the `<script>` wasn't - it's all
one HTML string to the parser. It sees a real `<script>` element and **runs the JavaScript inside it**, for
*every visitor who loads that comment*. The boundary between "markup I wrote" and "text the user typed"
existed only in your head, exactly like the SQL case.

📝 **Terminology - stored vs. reflected XSS.** Input saved and served to everyone who views the page (like
that comment) is **stored XSS** - the worst kind, hitting every visitor automatically. Input that bounces
straight back in a single response (a search term echoed into results, reached via a crafted link) is
**reflected XSS** - it hits whoever follows the link. Same root cause, same fix.

## What this actually costs your users

JavaScript running in your page can do anything *your own* JavaScript could do for that user:

- **Steal the session** - read cookies/tokens the page can access and send them to the attacker, who then
  logs in *as the victim*. No password needed.
- **Act as the victim** - change their email, post on their behalf, drain an account.
- **Deface or phish** - show a fake login form and harvest credentials, running on your real, trusted domain.

XSS appears in [The OWASP Top 10](/guides/owasp-top-10) under the same **Injection** category as SQL
injection - because it's the same bug, pointed at the browser.

## The fix: context-aware output encoding

The cure is the Phase 1 sentence again - *keep data as data* - applied at the moment input gets written into
a page. A browser only gets one stream (the HTML), so unlike a database you can't send code and values
separately. Instead you **encode** the value: transform characters that *mean something* to the HTML parser
into harmless equivalents that *display* as those characters but can't act as markup.

```text
   <   becomes   &lt;
   >   becomes   &gt;
   &   becomes   &amp;
   "   becomes   &quot;
   '   becomes   &#x27;
```

📝 **Terminology - output encoding / escaping.** Converting characters so an interpreter treats them as data,
not syntax. HTML-encoding `<` to `&lt;` means the browser *shows* a less-than sign instead of *starting a
tag*.

The attacker's comment, encoded on the way into the page:

```html
<p>Comment: &lt;script&gt;/* attacker's JavaScript */&lt;/script&gt;</p>
```

*What just happened:* `<` and `>` arrived as `&lt;` and `&gt;`, so the browser had no real `<script>` element
to run - it just *displayed* the text, literally, as a harmless (if weird-looking) comment. The input never
became code, because the characters that would have made it code arrived as data.

⚠️ **Gotcha - encode on OUTPUT, in the right CONTEXT, and treat all input as hostile.** Two traps:

- **Output, not input.** Encode when you *render* the value, not when you *receive and store* it. The same
  stored value might land in HTML on one page, a JavaScript string on another, or a URL on a third - each
  needs *different* encoding. Encode once on input and you've guessed wrong for some of those contexts.
- **Context matters.** HTML-encoding suits text between tags, but a value inside an HTML attribute, a
  `<script>` block, a URL, or CSS each has its *own* dangerous characters and encoding rules. Putting user
  input directly inside a `<script>` tag or an `onclick=` handler is especially dangerous - pass data into
  JavaScript through a properly-encoded data attribute or a JSON endpoint instead.

Treat every piece of input as hostile - every form field, URL parameter, header, and value read back out of
your own database (stored XSS means your database is now a delivery mechanism). "Where did this come from?"
is the wrong question; "am I encoding it for where it's going?" is the right one.

## Let your templates do it: auto-escaping

You should almost never hand-encode character by character. Modern template engines **auto-escape** by
default - write `{{ comment }}` (React's JSX, Jinja, Django templates, Handlebars, Razor) and the engine
HTML-encodes the value before it hits the page. The common path is safe, the same pattern as ORMs in Phase 2.

The danger is the escape hatch. Every engine has a "render this as raw HTML, don't escape it" feature for the
rare case you truly need it - React's `dangerouslySetInnerHTML`, the `|safe` filter, `v-html`, `innerHTML`.
The name `dangerouslySetInnerHTML` is a warning: hand raw, unescaped user input to one of these and you've
reopened the hole auto-escaping was closing.

```text
   template {{ value }}        →  auto-escaped for you      ✅ safe
   raw-HTML escape hatch       →  YOUR responsibility       ⚠️ never feed it raw user input
```

If you genuinely must allow *some* user-supplied HTML - a rich-text comment with bold and links - don't
hand-roll it. Run the input through a well-maintained, allowlist-based **HTML sanitizer** library (such as
DOMPurify) that permits a known-safe set of tags and strips everything else. Hand-written "strip the bad
tags" filters are blocklists, and you already know how those end.

## Defense-in-depth: a Content-Security-Policy

Encoding is the fix. A **Content-Security-Policy (CSP)** is the seatbelt you wear in case a bug slips through
anyway - a second wall, not a replacement for the first.

📝 **Terminology - Content-Security-Policy (CSP).** An HTTP response header telling the browser which sources
of script, style, and other content it's allowed to load and run for your page. The browser enforces it. A
well-tuned policy can refuse inline scripts and scripts from origins you didn't approve - so even if an
attacker injects a `<script>`, the browser declines to execute it.

```text
   Content-Security-Policy: default-src 'self'
                            └ only load/run resources from my own origin;
                              block inline scripts and third-party script by default
```

A strict CSP can turn a successful injection into a non-event, which is why it's worth deploying. But it's
genuinely fiddly to get right without breaking your own site, and a loose policy gives little protection.
Treat it as **defense-in-depth layered on top of correct output encoding**, never an excuse to skip encoding.

💡 **Key point.** XSS is closed at the **output** boundary: encode every untrusted value for the **context**
it's rendered into, and let an auto-escaping template engine do it for you. Add a CSP as a backstop. Same
instinct as Phase 2: keep data as data so it can never be run as code.

## Recap

1. XSS happens when **untrusted input rendered into a page** is read by the browser as **HTML/JavaScript**
   and runs - in *other users'* browsers, with *their* session.
2. **Stored XSS** hits every viewer (worst); **reflected XSS** bounces back via a crafted request. Same
   cause, same fix.
3. The damage lands on your users: **session/token theft, acting as the victim, defacement and phishing** -
   part of the OWASP **Injection** family.
4. **The fix is context-aware output encoding:** transform markup characters into harmless equivalents at
   render time, matching the context (HTML, attribute, script, URL).
5. **Encode on OUTPUT, not input; treat all input as hostile** - including values from your own database. Let
   an **auto-escaping template engine** do it by default, and never feed the raw-HTML escape hatch user input
   (sanitize with an allowlist library if you must allow some HTML).
6. **Add a Content-Security-Policy as defense-in-depth** - a backstop, never a substitute for encoding.

Both holes closed, same move from Phase 1: keep data as data. For the wider landscape of web risks beyond
these two, head to [The OWASP Top 10](/guides/owasp-top-10).

Watch it animated: [cross-site scripting](/explainers/XSS.dc.html)
