SQL injection, command injection and cross-site scripting sound like three different problems. They are one problem wearing three uniforms. A program glues untrusted text into something that another program will parse as instructions, and the parser has no way to tell which part was meant as data. This chapter shows where that boundary sits, how ordinary text slips across it, and the handful of fixes that close it for good.
Common mix-up: injection is not fixed by "cleaning" input. Stripping apostrophes breaks the surname O'Brien and still leaves places where no apostrophe is needed. The fix lives at the exact point where data meets an interpreter: bind it as a parameter, pass it as a separate argument, or encode it for the precise spot in the page where it lands. Checking input is a useful second layer, never the first.
Before you start: no networking background needed. It helps to know that a web page is just text a server sends and your browser parses; if the request and response cycle is hazy, TCP & UDP on networks.jelia.nyc sets the scene.
Computers are full of small languages. A database speaks SQL. A Unix shell speaks its own command language, with pipes, wildcards and variables. A browser reads HTML, and inside the HTML it reads JavaScript, CSS and URLs, each with its own grammar. A directory server reads LDAP filters. A template engine reads its own little syntax of curly braces. Every one of these is an interpreter: a program that takes a string and decides what it means.
The first thing any interpreter does is tokenize. It walks the text one character at a time and cuts it into pieces: this is a keyword, this is a name, this is an operator, and this, between two quote marks, is a literal value that should be taken exactly as written. Those pieces are then arranged into a structure, a parse tree, and the structure is what actually runs. Quote marks, angle brackets, semicolons, spaces and backslashes are the characters that tell the tokenizer where one piece ends and the next begins. They are the interpreter's punctuation.
Injection happens when a program builds the text for an interpreter by gluing strings together, and one of the glued pieces came from somebody else. The program's author pictured the user's text sitting quietly in its slot. The tokenizer sees no slots, only characters. If the user's text contains the interpreter's punctuation, the tokenizer obeys it: a quote mark closes the literal early, a semicolon ends the command, an angle bracket opens a tag. Data has crossed the line and become part of the code. MITRE files the whole family under CWE-74, "improper neutralization of special elements in output used by a downstream component," which is a precise if unlovely way of saying the same thing.
Two points follow, and the rest of the chapter rests on them. First, the problem does not need an attacker. A customer called O'Brien will break a concatenated SQL query on day one, and a forum post about the <b> tag will turn a concatenated page bold. Attackers simply choose their characters on purpose. Second, the punctuation is different for every interpreter, and sometimes for different places inside one interpreter, which is why no single "sanitize" function can ever be right everywhere.
Concatenation sends code and data down one channel and leaves the parser to guess where one ends. Parameterization uses two channels, so the guess never happens.
| Interpreter | Its punctuation | Weakness | The structural fix |
|---|---|---|---|
| SQL database | quotes, ;, --, keywords | CWE-89 | Parameterized queries; allow-lists for names |
| OS shell | space, ; | & $ ` > <, wildcards | CWE-78 | No shell: run the program with an argument array |
| Browser (HTML/JS/URL/CSS) | < > & " ', backslash, % | CWE-79 | Auto-escaping templates; context-aware encoding; CSP as a backstop |
| LDAP filter | * ( ) \ and NUL | CWE-90 | Library filter builders; RFC 4515 escaping |
| Template engine | {{ }}, {% %}, ${ } | CWE-1336 | User text only ever as template data, never as template source |
| HTTP headers, log files | carriage return, line feed | CWE-93, CWE-113, CWE-117 | Reject or encode line breaks; structured logging |
The tempting fix is to find the dangerous characters and put a backslash in front of them, or double them up. It can work, in exactly one context, for exactly one interpreter, configured exactly one way. The trouble is everything around that.
Here is the pattern that causes it, in a few lines of Python. It looks harmless, and with the name Smith it works.
# Vulnerable: the user's text becomes part of the SQL source name = request.form["name"] sql = "SELECT id FROM users WHERE name = '" + name + "' AND active = 1" cur.execute(sql)
Type O'Brien and the query text becomes … name = 'O'Brien' AND active = 1. The database tokenizes it, finds a string 'O', a stray word Brien, and an unterminated string that swallows the rest. The page crashes with a syntax error. A person choosing their characters deliberately can do more than crash it: they can rearrange the query so it asks a different question, reads other tables or, with a sufficiently privileged account, changes data. We do not need to show how; it is enough to see that the input decides the structure.
The fix is the parameterized query, also called a prepared statement or bind variable. You write the SQL once, with a placeholder where each value goes, and hand the values over separately.
# Fixed: the SQL is a constant; the value travels on its own
name = request.form["name"]
cur.execute("SELECT id FROM users WHERE name = ? AND active = 1", (name,))
The database parses the constant text first and fixes its shape: one placeholder, of type text, in the comparison. The value then arrives through a separate part of the database protocol, already labelled as data. It is never tokenized, so it cannot contain punctuation in any meaningful sense. An apostrophe in it is just an apostrophe. Every mainstream platform has this: PreparedStatement in Java, SqlParameter in .NET, ? or %s or :name placeholders in Python's database drivers, $1 in PostgreSQL clients. The placeholder style differs; the principle does not.
Three gaps remain, and they catch good teams.
ASC and DESC are structure. If users choose a sort column, map their choice through an allow-list in your code ({"newest": "created", "price": "total"}) and only ever insert one of your own strings.raw() and extra() in Django, text() in SQLAlchemy, FromSqlRaw in Entity Framework, native queries in JPA. Every one of them supports bind parameters; every one of them also accepts a string built with an f-string. OWASP's 2025 entry calls out ORM search parameters for exactly this reason.# The ORM escape hatch, used badly
session.execute(text(f"SELECT * FROM orders WHERE note LIKE '%{term}%'"))
# Same feature, used with a bind parameter
session.execute(text("SELECT * FROM orders WHERE note LIKE :pat"), {"pat": f"%{term}%"})
Finally, assume one query somewhere will be wrong anyway, and limit what it could do. The account your web application uses to connect should have least privilege: read and write the tables it needs, no ability to drop tables, read other applications' schemas, or run operating-system commands through database extensions. A reporting page can connect as a read-only user. This turns a potential catastrophe into a contained bug.
This is a real tokenizer for a small SQL-like language, running in this page. Pick a query template, type into its slot, and switch between three ways of building the query. It never touches a database; it reports what the parser would see. Watch the token count. When it changes, your input has changed the query's structure.
Key: each box is one token, labelled with its kind. An orange bar under a box means its characters came from your input; a dashed outline with ✕ means input that escaped its literal and became structure; a green bar marks the slot as the template intended. Try O'Brien in all three modes, then the number and sort-column templates with hand-escaping.
It scans left to right. Whitespace separates tokens and is dropped. A single quote starts a string literal that runs to the next single quote, where two quotes in a row ('') count as one literal quote, which is the SQL standard's escape. If the end of the text arrives first, the string is unterminated and the token is an error. Runs of letters become names, or keywords if they are on its short keyword list; digits become numbers; ? is a placeholder; =, <, > and friends are operators; and two hyphens start a comment that runs to the end of the line.
The structure test compares the kinds of tokens in your query with the kinds in the same template filled with a harmless sample. If the lists match and your input sits inside exactly one literal token that covers all of it, the structure is intact. Otherwise it reports how many tokens appeared or vanished. Real database parsers are far bigger, but they start in the same place, and this is the step where injection happens.
Programs often need to run other programs: resize an image, convert a document, count the lines in an upload. The easy way in almost every language is a function that takes one string and hands it to a shell, such as system() in C and PHP, os.system or shell=True in Python, exec in Node's child_process. The shell then tokenizes that string with its own rules, and the shell's punctuation is generous: spaces split arguments; ;, && and | chain or pipe commands; $( ) and backticks run commands inside commands; * and ? expand to file names; > redirects output.
# Vulnerable: one string, parsed by /bin/sh
filename = upload.name
os.system("wc -l " + filename)
Give it a file called my report.txt and it counts lines in two files, my and report.txt, neither of which exists. That is the innocent version of the bug. Any shell punctuation in the name is obeyed the same way, which is how a file name becomes a second command.
The fix is not a cleverer string. It is to stop using a shell at all. Every operating system can start a program directly, given the program's path and a list of arguments. No shell is involved, so there is no shell tokenizer, and each list item reaches the program as exactly one argument, spaces and semicolons included.
# Fixed: no shell; each list item is exactly one argument subprocess.run(["wc", "-l", "--", filename], check=True)
Notice the --. Even without a shell, the program itself parses its arguments, and most programs treat anything starting with a hyphen as an option. A file named -n would be read as a flag. The double hyphen is the widely used convention for "options end here; everything after is a file name." OWASP calls this argument injection, a close cousin of command injection, and the defense is the same in spirit: pass values in positions where they can only be values.
Better still, ask whether you need another program. Counting lines, resizing images and zipping files all have libraries in every mainstream language. A library call takes typed arguments and has no command language to inject into. When an external program is unavoidable, combine the argument array with an allow-list on what the value may look like (an upload ID that is 32 hex characters, say, mapped to a file path your code builds) and run it under an account that can touch nothing else.
subprocess documentation, for example, has a dedicated section on security considerations..bat or .cmd file on Windows runs it through the command interpreter, which has its own, different quoting rules. Avoid passing untrusted values to batch files.eval(), a scripting engine or an expression language from user text. CWE-94 sits at number 10 in the 2025 Top 25. The fix is never to evaluate built strings; parse data with a data parser (JSON, not eval).A web page is a program that the server writes and your browser runs. When the server writes a visitor's comment, a search term or a display name into that program without encoding it, the browser's HTML parser decides what the text means, exactly as the SQL tokenizer did. A comment that mentions <b> turns the rest of the page bold. A comment carrying markup that loads script lets one visitor run code inside every other visitor's page, with that page's access to their account. That is cross-site scripting, or XSS, and it is the most reported weakness in the CWE data year after year. What a script running in your origin can then do with sessions and cookies belongs to the next chapter, Sessions, cookies, CSRF & CORS; here we stop it getting in.
XSS comes in three shapes, named for where the untrusted text travels.
The fix point differs: server-side templates for reflected and stored XSS, the page's own JavaScript for DOM-based XSS, which may never reach the server at all.
Reflected XSS bounces off the server: a search page that prints "No results for …" with the query pasted in. Stored XSS waits in the database for every future reader, which makes it the most damaging. DOM-based XSS happens entirely in the browser, when the page's own JavaScript takes something it read, from the address bar, a message from another window or local storage, and passes it to a sink that parses HTML or code.
// Vulnerable: the browser parses the comment as markup li.innerHTML = "<b>" + author + "</b>: " + comment;
// Fixed: build elements; text goes in as text, never parsed
const b = document.createElement("b");
b.textContent = author;
li.append(b, ": " + comment);
Modern frameworks do this for you. React, Angular, Vue, Svelte and server-side engines such as Jinja2, Razor, Thymeleaf and Go's html/template encode every value they insert, by default. That default is the single biggest reason XSS is rarer in new code than it used to be. The danger is in the deliberately named escape hatches that switch it off, and in code review they deserve the same attention as a raw SQL query:
innerHTML, outerHTML, insertAdjacentHTML and document.write in plain JavaScript;dangerouslySetInnerHTML in React, v-html in Vue, bypassSecurityTrustHtml in Angular, {@html} in Svelte;|safe and mark_safe in Django and Jinja, Html.Raw in Razor;eval, new Function, and setTimeout with a string.When a feature genuinely needs user-supplied HTML, such as a rich-text editor, encoding is the wrong tool because it would show the tags as text. Use a well-maintained HTML sanitizer that parses the markup and keeps only an allow-list of harmless tags and attributes (DOMPurify is the widely used one in browsers), and keep it updated. Never write your own with regular expressions.
Here is the wrinkle that makes XSS subtle. A single HTML page contains several languages nested inside each other, and the browser switches parser depending on where it is. Text between tags is read by the HTML parser. A value inside an attribute is read by the same parser in a different mode, and the quote that surrounds it matters. Inside a <script> element, the JavaScript parser takes over, and the HTML parser only watches for the closing tag. Inside an href, the URL parser reads the value after the attribute is decoded. Inside a style, the CSS parser does.
Each of those parsers has different punctuation, so each needs a different encoder: a function that rewrites the special characters into a form the parser reads as plain data. HTML uses entities like <. JavaScript strings use backslash escapes like \x3c. URLs use percent-encoding like %3C. CSS uses backslash hex like \3c . The OWASP cross-site scripting cheat sheet gives a rule per context. Use the wrong one and two things can go wrong: the reader sees garbage, or, worse, the value still breaks out because the encoder protected the wrong characters.
| Context | Example slot | Encoder | Note |
|---|---|---|---|
| HTML text | <p>…</p> | HTML entities for & < > " ' | What auto-escaping templates do by default |
| HTML attribute | value="…" | Entities, ideally &#xHH; for all non-alphanumerics | Always quote attributes; unquoted ones end at a space |
| JavaScript string | const v = "…" | \xHH for all non-alphanumerics | HTML entities are not decoded inside script; better to pass data via a JSON data attribute |
| URL parameter | href="/s?q=…" | Percent-encoding, then it sits in an attribute | Never let users supply a whole URL without checking its scheme |
| CSS value | style="color: …" | \HH hex escapes | Prefer an allow-list of values; CSS is a poor place for user data |
Type one untrusted string. The instrument writes it into five places on a page, using the encoder you choose for each, then runs the decoders the browser would run in that spot to work out two things: does the value stay inside its slot, and does the reader see what was typed? The markup is shown as text and nothing is ever rendered or executed. Start with "Right encoder everywhere," then try "HTML-encode everywhere," the most common half-fix.
"Breaks out" means a character reached the parser as punctuation: it ended the attribute, opened a tag, closed the string or added a URL parameter. "Garbled" means the value stayed contained but the decoder for that spot does not undo the encoder you used, so readers see escape codes. "Wrong encoder" warns that the result is only fine for this particular input.
Some slots are read by two parsers in a row. A URL in an href is first decoded as an HTML attribute (entities become characters), then the result is parsed as a URL. So HTML-encoding an ampersand in a URL parameter does nothing for the URL: & becomes & again before the URL parser sees it, and the ampersand starts a new parameter. The instrument models that order: attribute decoding first, then the URL or CSS decoder.
Script elements are the reverse surprise. The HTML parser treats a script's contents as raw text and does not decode entities, so " inside a JavaScript string stays as six characters. Worse, the HTML parser is still scanning for the text that closes the script element, so a value containing it ends the script regardless of JavaScript quoting. That is why the JavaScript encoder escapes < and / too, and why the cleanest pattern is to keep data out of scripts entirely: put it in a data- attribute or a JSON block and read it with JSON.parse.
Even a careful team will one day ship a page with one unencoded value. Content Security Policy (CSP), defined by the W3C's CSP Level 3 specification, is the browser feature that limits what that mistake can do. The server sends a response header listing which scripts, styles, images and frames the page is allowed to use. The browser enforces it, refusing to run anything else, and can send a report when it blocks something.
The most effective form is a strict CSP built on nonces. For every response, the server generates a fresh random value, puts it in the header and adds it as an attribute on each script tag it wrote on purpose. The CSP3 specification asks for at least 128 bits from a cryptographically secure random generator, new each time. A script that an injection bug slipped into the page cannot know the value, so the browser refuses it. A hash source does the same for a fixed inline script by listing the SHA-256 of its exact contents.
Content-Security-Policy: script-src 'nonce-{fresh random value}' 'strict-dynamic';
object-src 'none'; base-uri 'none'
'strict-dynamic' says that a script trusted through its nonce may load further scripts, which makes nonces practical with modern bundlers and tag managers; browsers that support it then ignore host allow-lists and 'unsafe-inline' in the same directive, leaving those as fallbacks for very old browsers. object-src 'none' shuts the door on plugin content, and base-uri 'none' stops an injected <base> tag from quietly redirecting every relative script URL on the page.
Older policies are often allow-lists of hosts: scripts from our own site and from cdn.example.com. They are easy to write and hard to make safe, because they trust every file on those hosts, including old library versions and endpoints that echo back caller-supplied JavaScript. Two keywords undo most of CSP's value: 'unsafe-inline' allows any inline script, including the injected one, and 'unsafe-eval' allows code built from strings.
Be clear about the limits. CSP does not fix the bug: injected markup can still deface the page, inject a convincing fake login form, or leak data through injected links and images unless other directives restrict them. It does nothing for SQL or command injection, which happen on the server. Deploy it in report-only mode first (Content-Security-Policy-Report-Only) to learn what would break. Pair it with Trusted Types where supported, a newer browser feature that makes DOM sinks like innerHTML refuse plain strings, so DOM-based XSS shows up as an error in testing instead of a hole in production.
Paste a policy, or pick a sample. The checker parses the directives the way a browser does, works out which one governs scripts, and flags the common weaknesses described above. It looks only at the text you give it.
If injection is about dangerous characters, why not refuse them at the door? Partly you should. Allow-list validation, which says what a value must look like rather than what it must not, catches junk early and narrows what can go wrong later. A quantity is a positive integer below 1,000. A country code is two letters from a known list. An order ID matches a fixed pattern. A sort column is one of three names. When a field has a shape like that, enforce it, on the server, every time.
But most interesting fields have no such shape. Names contain apostrophes and hyphens. Addresses contain ampersands. A comment about programming will contain angle brackets. If validation rejected every character any interpreter considers special, it would reject legitimate customers, and it would still be wrong, because the same value later flows into a context with different punctuation. Validation cannot know where data will end up; output encoding and parameterization act at the moment it gets there.
Blocklists, lists of forbidden words or patterns, are worse. They fail open: anything the list did not anticipate gets through, and interpreters have many equivalent ways to say the same thing. They also cause the very bugs they target, by mangling text in ways nobody intended. The OWASP Input Validation cheat sheet is blunt about it: denylisting is a mistake-prone, easily bypassed approach and should not be the main defense. The same goes for web application firewalls in front of an application: useful for buying time against a known bug, never a substitute for fixing the code.
When you do validate, do it on the value your code will actually use. Decode percent-encoding and Unicode normalization first (to a single canonical form), then check, then never decode again. Checking a value and then transforming it afterwards lets an encoded form slip past the check and turn into something else later. This matters most for file paths, where the related weakness is path traversal (CWE-22), and for URLs, where checking the scheme against an allow-list of https: and mailto: stops a user-supplied link from being a script URL.
Directory searches use filters such as (uid=jsmith). Glue a user name into the filter and characters like *, ( and ) change what the filter matches. Use your LDAP library's filter builder, or escape values as RFC 4515 specifies (each special character becomes a backslash and two hex digits), and bind with an account that can only read what the search needs.
Template engines are interpreters too, and many can reach into the host language. The bug is passing user text in as the template, for example letting users customize an email by writing template syntax, instead of as data rendered by a fixed template. If users must design templates, use a logic-less engine or a sandboxed mode with no access to objects, and treat that feature as code execution in your threat model.
HTTP headers and many text protocols end each line with a carriage return and line feed. A value containing those two characters, written into a header such as a redirect location or a cookie, ends the header early and lets the rest read as new headers. Modern frameworks reject line breaks in header values; if yours does not, reject them yourself.
Write a user-supplied value containing a newline into a plain-text log and it can forge a whole extra log line that looks like it came from your system, or break the parser your monitoring relies on. Encode line breaks and control characters, or better, log structured records (JSON fields) so a value can never become a new entry. Chapter 17, Logs, detection & ATT&CK, is why this matters: your logs are evidence.
Injection is unusually findable, because the dangerous patterns are visible in source code. Use several layers of tooling, because each sees what the others miss.
innerHTML or dangerouslySetInnerHTML unless a reviewer approves an exception, or Trusted Types enforced in the browser during tests.raw(), text(), innerHTML, |safe, v-html…): is each value bound or sanitized, and is there a comment saying why the hatch is needed?-- before positional values, least-privileged account?data- attribute or JSON instead?innerHTML, eval or a raw-query call.\x escapes in JavaScript, percent-encoding in URLs.'strict-dynamic' sources; nonce length and freshness) and Trusted Types.