← back to blog
5 min read
  • #cybersecurity
  • #web-security
  • #sql
  • #owasp

SQL injection in 3 minutes

The vulnerability that won't die. What it is, why it works, how to stop it.


SQL injection is old. It has been on the OWASP Top 10 since the list existed, and it is still there in the latest revision. I keep studying it for the same reason it keeps showing up in breach reports: the bug is simple to understand, simple to fix, and still everywhere because people skip the fix. So here is the short version, the part I actually understand, written as study notes from someone still working through the eJPT and Security+ material.

What it actually is

SQL injection happens when your application builds a database query by gluing untrusted input directly into the query text. The database receives one string and has no way to tell which part you meant as a command and which part was supposed to be plain data. So it runs all of it.

That is the whole bug. Code and data get mixed in the same channel, and the attacker controls part of the data.

Think about a login form. The user types an email and a password. Your code takes those strings and builds a query to look up the matching row. If you build that query by string concatenation, the user's input becomes part of the SQL itself, not just a value the SQL operates on.

Why it works

Here is a vulnerable example in Node, using a raw query string:

// VULNERABLE: do not ship this
const email = req.body.email;
const query = "SELECT * FROM users WHERE email = '" + email + "'";
const result = await db.query(query);

Now picture the input. A normal user sends aldo@example.com and the query reads:

SELECT * FROM users WHERE email = 'aldo@example.com'

Fine. But an attacker sends this exact string as the email:

' OR '1'='1

The query your code builds becomes:

SELECT * FROM users WHERE email = '' OR '1'='1'

That OR '1'='1' is always true, so the WHERE clause matches every row. Depending on how the rest of the app is written, that can dump the user table or log the attacker in as the first account. The closing quote in the input balanced your opening quote, and everything after it got read as SQL logic instead of as an email address. That is the trick: the attacker breaks out of the string and writes their own SQL.

It gets worse than a true condition. With the right payload, the same flaw can be used to read other tables with UNION SELECT, run stacked queries, or pull data out one character at a time when the app shows no output (blind injection). The root cause is identical in all of those: input concatenated into the query.

The fix: parameterized queries

The fix is to stop building queries by concatenation and use parameterized queries, also called prepared statements. You send the SQL and the values to the database separately. The query text has placeholders, and the driver binds your values into those placeholders as data. The database never reinterprets a value as SQL.

Same lookup, done right in Node with the pg driver:

// SAFE: parameterized query
const email = req.body.email;
const result = await db.query(
  "SELECT * FROM users WHERE email = $1",
  [email]
);

Here is the same idea in Python with sqlite3:

# SAFE: parameterized query
email = request.form["email"]
cursor.execute(
    "SELECT * FROM users WHERE email = ?",
    (email,)
)

Now if someone sends ' OR '1'='1 as their email, the database treats that whole thing as a literal string to compare against the email column. It looks for a user whose email is literally ' OR '1'='1, finds nobody, and returns zero rows. The payload is just text now. It has no power because it never reaches the SQL parser.

One thing I had to get straight while studying: the placeholder syntax changes by driver. Postgres uses $1, $2. MySQL and SQLite use ?. Some libraries use named placeholders like :email. The principle is the same everywhere. Never concatenate, always bind.

Layers around the fix

Parameterized queries are the real fix. The rest is defense in depth, the stuff that limits the damage when something slips through.

  • Use an ORM or query builder, carefully. Tools like Prisma, Drizzle, Django ORM, or SQLAlchemy parameterize for you by default. That removes a whole class of mistakes. But the moment you drop down to a raw query escape hatch and start concatenating again, you are back to square one. The ORM does not protect the raw string you hand-built.
  • Least-privilege database users. The account your app connects with should only have the rights it needs. A web app that reads and writes a few tables does not need DROP TABLE, schema changes, or superuser. If an injection does land, a limited account caps how far it goes.
  • Input validation as a second layer. Validate that an email looks like an email, that an ID is actually a number, that a value is in your allowed set. This is not your primary defense against injection (a valid-looking string can still carry a payload), but it shrinks the attack surface and catches obvious junk early.
  • Watch the spots binding does not cover. Placeholders bind values, not table or column names. If you let user input choose a column or a sort direction, you cannot parameterize that. There you need a strict allowlist of permitted names, checked in code.

The honest wrap

I am still studying this, not certified yet, so take it as one learner's notes. But the core is not complicated. SQL injection survives because raw string concatenation is the path of least resistance, and the fix is one habit: keep your queries and your data in separate channels, always. Parameterize everything, give the database user the least power it needs, and validate input on top.

If you want a longer walkthrough of this and the other web bugs I keep running into, I put it together in Web Security for Builders ($29): aldowebsitellc.xyz/shop/web-security-for-builders.

$ share

community rating

$ ls ./comments

sign in or create an account to rate and comment.

no comments yet, be first.