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.

High impact5 min readDevelopersUpdated 31 October 2026

Fingerprint of this page: 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.

phpAnti-example: do not build SQL like this
// 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.

phpPHP with PDO
$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.

pythonPython with sqlite3 (other DB-API drivers work the same way)
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.

pythonAllow-list for identifiers
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 SQL

ORMs 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.

jsWriting user content into the page
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.

httpResponse header, a starting point for a site without inline scripts
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.

pythonRunning a program without a shell
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

  1. OWASP SQL Injection Prevention Cheat Sheetcheatsheetseries.owasp.org
  2. OWASP Cross Site Scripting Prevention Cheat Sheetcheatsheetseries.owasp.org
  3. PHP manual: PDO::preparephp.net
  4. Python documentation: sqlite3docs.python.org
  5. 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.

FAQ

Questions readers ask

Does an ORM make SQL injection impossible?

No, but it makes it much rarer. Standard ORM queries are parameterised; raw SQL methods, string fragments in conditions and column names taken from the request are not. Review every place where your code leaves the ORM’s normal query builder.

Is input validation enough to stop XSS?

No. Validation checks the shape of data, while XSS depends on where the data is rendered. Validate on input, then encode or sanitise for the exact output context, and add a Content Security Policy as a safety net.

Does a Content Security Policy replace output encoding?

No. A CSP reduces the damage when an injection slips through, but policies have gaps, especially with inline scripts or broad host lists. Encoding fixes the cause, the CSP limits the consequences.

Are stored procedures safe from SQL injection?

They are safe when they take parameters and do not build dynamic SQL inside. A stored procedure that joins its arguments into a query string is just as vulnerable, only harder to spot.

Keep reading

More from Secure Code