Security for Developers
Injection: SQL Injection, Command Injection and How to Stop Them
A real SQL injection on a tiny login, then the fix with parameterised queries. Also command injection, NoSQL injection and the rule behind all of them: never mix data into code.
On this page
- Words used in this lesson
- The one idea behind all injection
- SQL injection: a login that lets anyone in
- The fix: parameterised queries
- Other defences, as extra layers
- Command injection: when the data goes to a shell
- NoSQL injection: the data is an object, not a string
- Other places injection hides
- The rule: never mix data into code
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: injection happens when data from a user is mixed into code that another program runs, so the program can no longer tell where the data ends and the instructions begin. The fix is always the same idea: keep data and code apart. For SQL, use parameterised queries. For commands, do not use a shell. For objects, check the types.
In lesson 0 we said: everything from outside is untrusted. This lesson shows what goes wrong when we forget it, and it is the first real attack in the series. We build a tiny login with SQLite, break it on purpose, look at the exact SQL that was sent, and then fix it. After that we do the same for operating system commands and for NoSQL-style queries. At the end you will see that all of them are the same mistake wearing different clothes.
You need basic JavaScript and a little SQL. If SQL is new, the SQL and Postgres series is a good start. The next lesson, XSS, CSRF, clickjacking and headers, is about the same mistake happening inside the browser.
What I ran / checked. Everything ran on Linux with Node.js v22.22.0 and the built-in node:sqlite module (SQLite 3.50.4, which prints an "experimental" warning that I hid with --no-warnings). The SQL attacks are against a tiny in-memory database in my own script, nothing else. The command injection test ran real shell commands on my own sandbox machine, which runs as the user root, so the word root in one output is just my sandbox user. Sources I read (ideas only, written in my own words): the OWASP Top 10:2025 page for A05 Injection; the OWASP Cheat Sheet Series pages SQL Injection Prevention, Query Parameterization, Injection Prevention and OS Command Injection Defense. Could not reach: owasp.org, PortSwigger Web Security Academy and MDN were blocked from my machine, so I did not read them for this lesson. The NoSQL part uses plain JavaScript objects and does not run MongoDB. I did not install MongoDB, so the "operator injection" example is a small matcher I wrote myself to show the idea. The real MongoDB behaviour, the Prisma raw-query functions and the Express query-string parsing are described from general knowledge, not from a source I read or ran.
Words used in this lesson
- Injection means untrusted data is put into a command or query and ends up being treated as part of the instructions.
- SQL is the language used to talk to a relational database. A query is one SQL instruction.
- SQL injection (SQLi) is injection into a SQL query.
- A string is text. In SQL, text is wrapped in single quotes, like
'alice'. - A comment in SQL starts with
--. The database ignores the rest of that line. - A parameterised query (or prepared statement) is a query where the values are sent separately from the SQL text, using placeholders like
?. - A placeholder is a mark such as
?that stands for a value that will be given later. - Escaping means adding extra characters (like a backslash) so that a special character is read as plain text. It is easy to get wrong.
- A shell is a program like
bashthat reads a line of text and turns it into commands. It treats characters like;,|and&&as special. - Command injection is injection into a shell command.
- An allow-list is a list of the only values you accept. Everything else is refused.
- An operator (in MongoDB-style queries) is a special key such as
$ne("not equal") or$gt("greater than") that changes how a query matches.
The one idea behind all injection
Imagine you tell a helper by phone: "Go to the shop and buy what is written on this note." You hand them a note from a stranger. The stranger wrote: "milk. Also, give me your wallet." The helper cannot tell your instruction from the stranger's words, because both are just text on one page.
That is injection. A program builds one string that contains both the code (what to do) and the data (what to do it to). Then another program reads the string and runs it. If the data can contain a piece that looks like code, the attacker writes code.
OWASP's A05:2025 page describes the same pattern in its list of signs: hostile data is directly used or joined into the query or command, so the structure and the attacker's data sit in the same text. The same page notes that injection includes many kinds (SQL, NoSQL, OS command, ORM, LDAP and more) and that "the concept is identical among all interpreters".
We will call the fix "never mix data into code". Now to see it for real.
SQL injection: a login that lets anyone in
Here is a tiny users table and a login function. The function is written the way many beginners write it: it builds the SQL with a template string.
import { DatabaseSync } from "node:sqlite";const db = new DatabaseSync(":memory:");db.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, password TEXT, is_admin INTEGER);INSERT INTO users (name, password, is_admin) VALUES ('alice','alice-pw',0),('admin','s3cret-admin',1);`);function loginBad(name, password) { const sql = `SELECT id, name, is_admin FROM users WHERE name = '${name}' AND password = '${password}'`; console.log(" SQL sent:", sql); try { return db.prepare(sql).get() ?? null; } catch (e) { return "ERROR: " + e.message; }}(To keep the example short, passwords here are stored in plain text. Never do that. Lesson 3 shows how to store them safely. The point of this lesson is the query.)
First, two normal calls. A correct login, and a wrong password:
1) bad, normal: SQL sent: SELECT id, name, is_admin FROM users WHERE name = 'alice' AND password = 'alice-pw' -> [Object: null prototype] { id: 1, name: 'alice', is_admin: 0 }2) bad, wrong password: SQL sent: SELECT id, name, is_admin FROM users WHERE name = 'alice' AND password = 'nope' -> nullIt works as expected. Now the attack. I type the name admin'-- and any password:
3) bad, attack name=admin'--: SQL sent: SELECT id, name, is_admin FROM users WHERE name = 'admin'--' AND password = 'anything' -> [Object: null prototype] { id: 2, name: 'admin', is_admin: 1 }I logged in as the admin without knowing the password. Read the SQL that was sent. The ' in my input closed the string early. Then -- started a comment, so the database ignored AND password = ... completely. The query became "find the user called admin". My data became code.
A second classic is to attack the password field. The input is ' OR '1'='1:
4) bad, attack password=' OR '1'='1: SQL sent: SELECT id, name, is_admin FROM users WHERE name = 'alice' AND password = '' OR '1'='1' -> [Object: null prototype] { id: 1, name: 'alice', is_admin: 0 }Now the condition is name = 'alice' AND password = '' OR '1'='1'. SQL does AND before OR. The last part, '1'='1', is always true, so the whole condition is true for every row. The database returns the first user it finds. (It returned alice here, but with a table of 1000 users it returns whoever is first, which is often an admin created at setup.)
It can also steal data, not only skip login
The next one uses UNION, which glues the result of a second query onto the first. I put a second SELECT into the name field that reads the admin's password and puts it into the name column of the result:
6) bad, UNION steals data: SQL sent: SELECT id, name, is_admin FROM users WHERE name = 'x' UNION SELECT id, password, is_admin FROM users WHERE name='admin'--' AND password = 'y' -> [Object: null prototype] { id: 2, name: 's3cret-admin', is_admin: 1 }The page would now show s3cret-admin where the user name should be. Data that was never meant to leave the database came out through a login form.
OWASP's A05 page makes this point: SQL injection is less frequent than XSS but its impact is very high. It can read, change or delete the whole database. This is why we learn it first.
It also breaks honest users
Here is a case with no attacker at all. A user named O'Brien types their name:
5) bad, a name with an apostrophe (no attacker): SQL sent: SELECT id, name, is_admin FROM users WHERE name = 'o'brien' AND password = 'x' -> ERROR: near "brien": syntax errorIf an apostrophe in a normal name breaks your app, an attacker can use the same apostrophe to do something useful. Any bug like this is also a security hole. It is a good early warning sign in testing.
The fix: parameterised queries
The fix is to send the SQL and the values separately. We write the SQL once with ? marks. The database reads and understands the structure first. Then the values are given, and they can only ever be values. They are never read as SQL.
function loginGood(name, password) { const sql = "SELECT id, name, is_admin FROM users WHERE name = ? AND password = ?"; return db.prepare(sql).get(name, password) ?? null;}Now the same attacks:
7) good, normal: [Object: null prototype] { id: 1, name: 'alice', is_admin: 0 }8) good, name=admin'--: null9) good, password=' OR '1'='1: null10) good, o'brien: null11) good, UNION string: nullLine 7 still works. Lines 8, 9 and 11 find nobody, because the database looked for a user whose name is literally the text admin'--. Line 10 no longer crashes: it returns null because there is no user called o'brien, which is correct. The same parameterised code also fixed the honest user bug.
This is exactly what the OWASP SQL Injection Prevention cheat sheet puts first. In its words the idea is: define all the SQL code first, then pass in each parameter later, so the database can always tell code from data. The cheat sheet lists three options in order of preference: prepared statements with parameters, stored procedures (carefully, because a stored procedure that joins strings inside itself is still injectable), and allow-list input validation for the parts that cannot be parameters.
Escaping and "cleaning" input is a weaker fix. Removing ' or -- from input sounds simple, but attackers find other ways (different encodings, other SQL syntax, and your own bugs in the cleaning code). Also you would break real names like O'Brien. Use parameters. Treat cleaning as an extra layer at most.
What about ORMs and query builders?
Tools like Prisma, Drizzle and Knex use parameters for you when you use their normal methods. For example prisma.user.findFirst({ where: { name } }) is safe. The danger is in their "raw" escape hatches. In Prisma, the $queryRaw tagged template is parameterised, but $queryRawUnsafe with a string you built is not. The A05 page also reminds us that ORMs can be injected too if you join strings into them (it gives an HQL example). Rule: if you see a template string with ${...} inside a SQL text, stop and look.
The parts that cannot be parameters
Placeholders work for values only. You cannot use ? for a table name, a column name, or ASC/DESC. A common case is "sort by this column", where the column comes from the URL (?sort=name). The OWASP A05 page warns that names of tables and columns cannot be escaped, so putting user input there is dangerous.
The answer is an allow-list: the user picks from your list, and you use your own string, not theirs.
const ALLOWED = { name: "name", id: "id" };function list(sortParam) { const col = Object.hasOwn(ALLOWED, sortParam) ? ALLOWED[sortParam] : undefined; if (!col) return "rejected: " + JSON.stringify(sortParam); return db.prepare(`SELECT id, name FROM t ORDER BY ${col}`).all();}I ran it with four inputs:
3a 2b 1z (sort=name)1z 2b 3a (sort=id)rejected: "name; DROP TABLE t" (a bad value)rejected: "constructor" (a tricky value)The last line has a story. My first version used ALLOWED[sortParam] without Object.hasOwn. When I sent the word constructor, JavaScript did not return undefined. It returned the built-in Object function from the object's prototype, and the query broke with near "Object": syntax error. That was a real error I got while writing this lesson. The lookup into a plain object also reads inherited names like constructor and toString. Use Object.hasOwn, a Map, or an object made with Object.create(null) for allow-lists.
Putting a ? in ORDER BY ? does not sort by that column. I tried it: the query runs, but the database sorts by the constant text, not by the column, so it silently does nothing useful. Do not use placeholders for names.
Other defences, as extra layers
Parameters stop SQL injection at the root. These add safety if you make a mistake somewhere else:
- Least privilege (lesson 0). Use a database user that cannot drop tables or read tables it does not need. In lesson 0 we saw a read-only connection stop writes even when the query was injectable.
- Validate input by type and shape (a user id should be a number, an email should look like an email). Libraries like Zod do this, see Backend Lesson 3. This is not the main defence, it is a second layer.
- Do not show database errors to the user. An error like
near "brien": syntax errortells an attacker how your query is built. Log it on the server and show a plain message. (More about this in lesson 7.) - Hash passwords so that even a successful
UNIONdoes not give plain passwords. See lesson 3.
Command injection: when the data goes to a shell
SQL is not the only interpreter. Your server may also run commands: converting an image, calling git, running ping. In Node the function exec takes one string and gives it to a shell. A shell treats ;, &&, | and backticks as instructions. So the same mistake appears again.
Here is a program that greets a name by running echo. I ran it with a normal name, then with a bad one:
import { exec, execFile } from "node:child_process";import { promisify } from "node:util";const run = promisify(exec), runFile = promisify(execFile);const input = process.argv[2];const r1 = await run(`echo Hello ${input}`); // string -> shellconst r2 = await runFile("echo", ["Hello", input]); // program + list of arguments, no shell$ node cmd.mjs "Sam"input: "Sam"exec -> "Hello Sam\n"execFile -> "Hello Sam\n"$ node cmd.mjs 'Sam; echo INJECTED; id -un'input: "Sam; echo INJECTED; id -un"exec -> "Hello Sam\nINJECTED\nroot\n"execFile -> "Hello Sam; echo INJECTED; id -un\n"With a normal name both are the same. With the attack, exec ran three commands: the echo, then echo INJECTED, then id -un, which printed the user the program runs as (root in my sandbox). The attacker could have used curl or rm instead. execFile printed the whole attack text as one plain word, because it starts the program echo directly and passes the input as one argument. No shell reads it, so ; is just a character.
This is the OWASP OS Command Injection Defense advice in order: first, avoid calling OS commands when a library function can do the job (a image library instead of the convert command, fs instead of rm). If you must call a program, use the form that takes the program and its arguments separately, and validate the argument against an allow-list.
execFile is much safer than exec, but it is not magic. If the program itself reads options from its arguments, an input like -la or --output=/etc/passwd can still change its behaviour. This is called argument (or option) injection. Many programs accept -- to mean "options end here". I tested it with ls: execFile("ls", ["--", "-la"]) printed ls: cannot access '-la': No such file or directory, which proves -la was treated as a file name and not as a flag. Also never set shell: true on execFile or spawn with user data, because that brings the shell back.
A summary table:
| Call | Shell involved? | Safe with user input? |
|---|---|---|
exec("echo " + name) | Yes | No |
execFile("echo", [name]) | No | Mostly, watch for option injection |
spawn("echo", [name]) | No (unless shell: true) | Mostly, same note |
| A library function (no process at all) | No | Best |
NoSQL injection: the data is an object, not a string
Databases like MongoDB do not use SQL text. Queries are JavaScript-like objects. So you might think they are safe. They are not, but the attack looks different: instead of sneaking in a quote, the attacker sneaks in an object with an operator.
A login handler might do this:
const user = await users.findOne({ user: req.body.user, password: req.body.password });If the front-end sends {"user":"alice","password":"pw"}, all is fine. But the server also accepts JSON, and JSON can contain objects. What if the attacker sends:
{ "user": "alice", "password": { "$ne": null } }In MongoDB the key $ne means "not equal". The query becomes "user is alice and password is not null", which is true for every user who has a password. Login without the password.
I did not run MongoDB. To show the idea in plain JavaScript, I wrote a tiny matcher that understands only $ne, and parsed the attack with the normal JSON.parse. First, proof that a plain JSON body can carry an object:
JSON body parse: {"user":"alice","password":{"$ne":null}}JSON.parse happily returns an object where the code expected a string. Now the toy matcher (written by me) and the result:
naive operator-aware matcher: { user: 'alice', password: 'pw' }The matcher returned alice's record even though the attacker never gave her password. This is a model of the real behaviour, not a test of MongoDB itself, so check the MongoDB docs for your driver version.
The fix is simple: check the type before you use the value. A value that should be a string must be a string.
const safe = (q) => { if (typeof q.user !== "string" || typeof q.password !== "string") throw new Error("must be strings"); return users.find(u => u.user === q.user && u.password === q.password);};typed check: must be stringsIn a real project use a schema library (Zod, Joi, or the validation in your framework) at the edge of your API, as in Backend Lesson 3. Some Mongo tools also have options to turn off operators in user input. Look for them, but do not rely on them alone.
The same trick appears elsewhere. Express can parse ?id[$ne]=1 in a URL into an object, and a JSON body can hold arrays or objects where you expected strings. Anywhere you read a value from outside, check both its type and its content.
Other places injection hides
You do not need to learn each one in detail. Just recognise the shape: a string built from user data and given to an interpreter.
- LDAP and XPath queries: used for company directories and XML.
- Template injection: user text put into the template (not into the data of the template) of an engine like Handlebars or Pug.
- Header injection: a user value with a newline put into an HTTP header can add extra headers. Modern Node refuses invalid header characters, but do not rely on that.
- Log injection: a user value with a newline forges a log line. Log structured JSON instead of joining strings.
evalandnew Function: running a string as JavaScript. Almost never needed. Never put user input into them.- XSS (cross-site scripting) is also injection: user text is put into HTML, and the browser runs it. That is the whole of lesson 2.
The rule: never mix data into code
Put it on one page:
- Know the interpreter. SQL engine, shell, HTML parser, JSON query engine.
- Keep the structure fixed in your code. The code is written by you.
- Pass the user's data through a separate channel that the interpreter cannot read as instructions:
?parameters, argument arrays, output encoding for HTML. - If a part cannot be a parameter (a column name, a program name), choose it from an allow-list that you wrote.
- Add layers: least privilege, validation, no verbose errors, and tests that try the attack.
The fix is almost never "clean the bad characters". It is "use the interface that was designed to carry data".
Try it
- Copy the
loginBadfunction into a file and run it withadmin'--as the name. Print the SQL it sends. Where does the--end the query? - Change the attack so that it logs in as
alice(id 1) with no password, by editing only the name field. - Write
loginGoodand run all your attacks again. What does each return? - In the command example, try the input
$(id -un)and also`id -un`. Which ofexecandexecFileruns the command inside? - Make a small
search(term)that usesLIKE. Write it with a parameter. Where must the%signs go? - Write a function that accepts a JSON body and throws unless
userandpasswordare strings. - Search your own code for
${inside a string that begins withSELECT,INSERT,UPDATEorDELETE. How many do you find?
Answers
- The
'afteradmincloses the string;--starts a comment, so the rest of the line (AND password = ...) is ignored. - Use the name
alice'--. The query becomesWHERE name = 'alice'--' AND password = .... - They all return
null, apart from a real correct login. The attack text is only ever compared as a plain string. execruns the command inside, because the shell expands$(...)and backticks.execFileprints the text as it is.- Put the percent signs inside the value, not the SQL:
db.prepare("... WHERE body LIKE ?").all("%" + term + "%"). Note that%and_typed by the user act as wildcards inLIKE. That is not an injection of SQL but it can surprise you. Escape them if it matters. - Check
typeof body.user === "string" && typeof body.password === "string", or use a schema library. - If you find any, check each one. A template string is fine only if the interpolated parts are your own constants.
Interview questions
What is SQL injection and how do you prevent it? (Must know)
It happens when user input is joined into the text of a SQL query, so the database reads the input as part of the SQL. For example, a name of admin'-- closes the string and comments out the password check. I prevent it with parameterised queries (prepared statements): the SQL has ? placeholders and the values are sent separately, so they can never change the structure. For parts that cannot be parameters, like a column name for sorting, I use an allow-list. As extra layers I use a database user with minimal rights, input validation and no database errors in the response.
Why is escaping quotes not good enough? (Senior signal)
Escaping is easy to get wrong. Different databases and character sets have different rules, there are other special characters besides quotes, and it is easy to forget one place. It also changes the data, so real names like O'Brien can break. Parameterised queries remove the problem at the root because the database never parses the value as SQL. I treat escaping as the last resort for the rare case where I cannot use parameters.
What are the three or four things an attacker can do with SQL injection? (Must know)
Bypass login (the admin'-- case), read data they should not (using UNION), change or delete data (UPDATE, DROP, if the database user allows it), and in some setups run commands on the database server. That is why the database user should have the least rights it can work with.
A teammate says "we use an ORM, so we are safe from SQL injection." Is that true? (Trap)
Mostly for the normal methods, but not always. ORMs and query builders use parameters when I use their normal API. The risk is in raw query helpers where I join strings (such as an "unsafe" raw query method) and in passing user input as column names, sort fields or operators. I search the code for raw queries and for template strings in SQL, and I allow-list any identifier that comes from the user.
How do you handle "sort by column" or "table name" from a request? (Senior signal)
Placeholders only work for values, not for names. So I keep an allow-list that maps the user's choice to my own safe string, and I reject everything else. I also use Object.hasOwn or a Map for the lookup, because a plain object lookup of a name like constructor returns an inherited function, which I actually hit while testing.
What is the difference between exec and execFile in Node, and why does it matter for security? (Must know)
exec gives one string to a shell, so characters like ;, | and && are instructions, and user input can add commands. execFile starts a program directly and passes arguments as a list, so no shell reads them. In my test, an input of Sam; echo INJECTED; id -un ran three commands with exec and printed as one plain string with execFile. Even better is to avoid running a process at all and use a library.
If you use execFile, can injection still happen? (Trap)
Yes, in the form of argument or option injection. If the user value starts with - the program may take it as a flag. I put -- before user-supplied values when the program supports it, I validate the value against an allow-list or a strict pattern, and I never turn on shell: true. I tested that ls -- -la treats -la as a file name.
What is NoSQL injection? Is MongoDB safe from injection? (Senior signal)
It is the same idea in a different shape. The query is an object, so instead of a quote the attacker sends an object that contains an operator, for example {"password": {"$ne": null}}, which matches any user with a password. The fix is to check types before building the query: a field that should be a string must be a string. I use a schema library at the edge. I also say that I demonstrated the idea with a small matcher of my own, not with a real MongoDB, so the exact behaviour depends on the driver and its settings.
What is the general rule that prevents all injection? (Must know)
Never mix untrusted data into code. Keep the structure of the query or command fixed in my own code. Send user data through a separate channel that the interpreter treats as data only: placeholders for SQL, an argument array for processes, output encoding for HTML. For anything that cannot be sent that way, such as a column name, choose from an allow-list that I wrote.
Where does injection sit in the OWASP Top 10, and does it include XSS? (Trap)
In the 2025 edition it is A05 Injection. It was A03 in the 2021 numbering. The A05 page says the category includes cross-site scripting as well as SQL injection, which is why I teach them as cousins. XSS is very common with lower typical impact, and SQL injection is rarer with high impact. I always name the edition when I give a number.
You find a SQL injection in production. What do you do first? (Senior signal)
First I make it stop being exploitable: ship a fix with a parameterised query, or if that takes time, block the route or the input pattern for a short while, and say so openly. Then I check the logs for signs that it was used and what data could be reached, and ask if secrets or personal data may have leaked (that may mean telling users). I rotate any credentials the attacker could have seen, and I search the codebase for the same pattern. Last, I add a test that tries the attack, so it cannot return. Lesson 8 talks more about the first hour of an incident.
Key takeaways
- Injection is always the same mistake: data and code in one string, then handed to an interpreter.
- SQL injection: use parameterised queries. I showed
admin'--log in as admin andUNIONsteal a password, and then proved the same inputs returnnullafter the fix. - A bug on a normal name like O'Brien is an early warning of an injection hole.
- Names of tables and columns cannot be parameters. Use an allow-list, and look up with
Object.hasOwnor aMap. - Command injection: do not give user data to a shell.
execran extra commands in my test,execFiledid not. Watch for options that start with-, and use--. - NoSQL injection: the attacker sends an object with an operator like
$ne. Check types at the edge. - Escaping and cleaning input are weak fixes. Use the interface made to carry data.
- Add layers: least privilege, validation, quiet errors, and tests that try the attack. Only try attacks on your own toy app.
What's next
Next: XSS, CSRF, clickjacking and security headers, Lesson 2. It is the same mistake in the browser: user text gets into your HTML and the browser runs it. We will run a tiny server, watch a real Chromium execute (and then refuse to execute) an injected script, and look at the headers that act as a safety net. Practise now: grep your code for SQL built with template strings and fix one.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Securing LLM Applications: The OWASP Top 10 for LLM Apps in Code · AI Engineering
- Security and Auth Scenario Bank · Scenario Debugging
- Security in Depth for Full-Stack Developers · Full-Stack Questions
Part of the study guide
Related series