Secure Code/Explainer
SQL injection and XSS: how they work and how to stop them
SQL injection and cross-site scripting share one root cause: data that ends up being treated as code. Parameterised queries, context aware encoding and a Content Security Policy close that gap.
sha256("/secure-code/injection-xss-defence/")Injection is one of the oldest bug classes on the web and still one of the most damaging. The pattern is always the same: a program builds a query, a command or a page by joining fixed text with input from outside, and the input is shaped so that part of it is read as instructions instead of data. Once you see that pattern, the defences follow naturally, because they all keep code and data apart.
How SQL injection works
Imagine a search that builds its SQL by joining strings. The database receives one piece of text and cannot tell which part the developer wrote and which part the user typed. A carefully chosen value can close the quoted string and add conditions of its own, which can expose, change or delete far more data than the page was meant to touch.
// Vulnerable: user input becomes part of the SQL text
$sql = "SELECT id, name FROM customers WHERE email = '" . $_GET['email'] . "'";Escaping quotes by hand looks like a fix but fails at the edges: unusual character sets, numeric fields without quotes, the one field somebody forgot. The reliable fix is never to build SQL from input at all.
Parameterised queries in PHP and Python
A parameterised query, also called a , sends the SQL with placeholders and the values separately. The database parses the structure first and then treats each value strictly as data, whatever characters it contains.
$pdo = new PDO($dsn, $dbUser, $dbPassword, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false, // native prepared statements where the driver supports them
]);
$stmt = $pdo->prepare('SELECT id, name FROM customers WHERE email = :email');
$stmt->execute(['email' => $email]);
$customer = $stmt->fetch(PDO::FETCH_ASSOC);Two settings in that example matter. Exceptions make a failed query visible instead of quietly returning false, and switching off emulated prepares lets the database itself keep query and values apart. For MySQL, also set the character set in the DSN, for example charset=utf8mb4.
import sqlite3
conn = sqlite3.connect("shop.db")
row = conn.execute(
"SELECT id, name FROM customers WHERE email = ?",
(email,),
).fetchone()The placeholder style depends on the driver: ? for sqlite3, %s for psycopg and the common MySQL drivers. With %s it is easy to slip into Python string formatting by mistake. The value always goes in the second argument to execute(), never into the string itself.
Placeholders work for values only. Table names, column names and keywords such as ASC cannot be parameterised. If users choose a sort column, map their choice to a fixed list in code and fall back to a default for anything else.
SORT_COLUMNS = {"name": "name", "newest": "created_at"}
column = SORT_COLUMNS.get(requested_sort, "created_at")
sql = f"SELECT id, name FROM customers ORDER BY {column}" # only values from the dict reach the SQLORMs help, until you bypass them
Object relational mappers (ORMs) such as Doctrine, Eloquent, SQLAlchemy or the Django parameterise queries for you, which removes most SQL injection. The risk returns through the escape hatches: raw query methods, string fragments passed to where or orderBy, and query builders fed with column names straight from the request. Search your code for raw SQL calls and review each one like hand-written SQL.
How cross-site scripting works
Cross-site scripting (XSS) is injection into a web page. If a comment, a search term or a profile name is placed into HTML without encoding, the browser can interpret part of it as markup or script. That script runs with the rights of your site in the victim’s browser: it can read the page, act as the user and send data elsewhere. Stored XSS sits in the database and reaches every visitor, reflected XSS travels in a link, and happens entirely in client-side JavaScript.
The defence is context aware : encode data for the exact place where it lands. Encoding for the wrong context does not protect.
- HTML body: HTML entity encoding.
- HTML attribute: quotes around the value plus attribute encoding.
- URL parameter: percent-encoding, for example with
encodeURIComponent()in JavaScript. - JavaScript: pass data as JSON, for example in a data attribute that your script reads, rather than pasting it into a script block.
Template engines with automatic escaping, such as Twig, Blade or JSX in React, encode for the HTML context by default; with Jinja, make sure autoescaping is switched on. Learn the escape hatches of your engine, because that is where XSS comes back: filters that mark output as safe, raw output tags and properties such as dangerouslySetInnerHTML.
Dangerous sinks in the browser
In client-side code the risk sits in sinks: properties and functions that turn strings into markup or code. innerHTML, outerHTML, insertAdjacentHTML, document.write, eval and string arguments to setTimeout are the usual suspects. Prefer the text-only alternatives, which never parse markup.
const comment = await loadComment(id);
// Unsafe sink: the string would be parsed as HTML
// el.innerHTML = comment;
// Safe: the string is always treated as text
el.textContent = comment;If users really must submit HTML, for example through a rich text editor, run it through a maintained HTML sanitiser before display, keep that library updated and do not modify the HTML after sanitising it.
Content Security Policy as a second layer
A (CSP) is a response header that tells the browser which sources of script, style and other content are allowed. A strict policy blocks inline scripts and scripts from unknown hosts, so an injection that slips through often cannot run. It is a safety net, not a replacement for encoding: a policy that allows 'unsafe-inline' in script-src gives little protection against XSS.
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'Roll a new policy out with Content-Security-Policy-Report-Only first, fix what it reports, then enforce it. Pages that need inline scripts can use a fresh random nonce per response instead of 'unsafe-inline'.
Validation is not escaping, and a word on commands
Input validation checks that data has the expected shape: an email address looks like one, a quantity is a positive integer. It is useful and stops much nonsense early, but it cannot replace encoding, because perfectly valid data can still be dangerous in another context. The name O’Brien is valid input and still breaks a concatenated SQL string.
Command injection follows the same logic with the operating system shell. Avoid the shell entirely when a library can do the job. If you must run a program, pass the arguments as a list so that no shell parses them, and check values against an allow-list.
import subprocess
# Arguments as a list and no shell=True: the file name is never parsed by a shell
subprocess.run(["gzip", "--keep", "--", filename], check=True)Before you ship
0 of 6 done
Your ticks stay in this browser.
Sources and further reading
- OWASP SQL Injection Prevention Cheat Sheetcheatsheetseries.owasp.org
- OWASP Cross Site Scripting Prevention Cheat Sheetcheatsheetseries.owasp.org
- PHP manual: PDO::preparephp.net
- Python documentation: sqlite3docs.python.org
- MDN: Content Security Policy (CSP)developer.mozilla.org
General information, checked on 31 October 2026. Test changes on one system first and keep a way back. Found something outdated? Tell the editors.