ismycodesafe.com

SQL Injection Is Still the #1 Database Threat

SQL injection has been around for 25 years. It still works because developers still concatenate strings into queries.

··9 min read·By ismycodesafe.com Security Team
Comparison of vulnerable SQL string concatenation versus safe parameterized query with database icon

Key Takeaway

SQL injection happens when user input is concatenated directly into SQL queries. The fix is simple and absolute: use parameterized queries or prepared statements. Every modern database driver and ORM supports them.

How SQL Injection Works

Your application builds a SQL query by pasting user input directly into the query string. The attacker provides input that changes the query's structure. Adding conditions, unions, or entirely new statements.

Vulnerable code (Python):

# NEVER DO THIS
query = f"SELECT * FROM users WHERE email = '{email}'"
cursor.execute(query)

If the attacker enters ' OR '1'='1' -- as the email, the query becomes:

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

This returns every row in the users table. The -- comments out the rest of the query.

Attack Types

  • Classic (in-band). The attacker sees the query results directly in the response. Fastest to exploit.
  • Blind (boolean-based). The response changes based on whether the injected condition is true or false. Slower, but works when results aren't displayed.
  • Time-based blind. The attacker injects SLEEP(5) and measures response time. Works when there's no visible difference in output.
  • Union-based. The attacker appends a UNION SELECT to extract data from other tables. Can dump the entire database schema.
  • Stacked queries. Some database drivers allow semicolons to execute multiple statements. The attacker can DROP TABLE or INSERT admin accounts.

Real-World Damage

SQL injection has caused some of the largest data breaches in history. The OWASP SQL Injection page documents the attack vector. Some notable cases:

  • 2008: Heartland Payment Systems. 130 million credit card numbers stolen via SQLi
  • 2011: Sony Pictures. LulzSec said a single SQL injection gave them access to user accounts stored with plaintext passwords
  • 2015: TalkTalk. 157,000 customer records, £400,000 fine
Vulnerable code concatenates user input into a SQL string, letting an attacker bypass the query; a parameterized query treats the same input as data, never as SQL
Same input, opposite outcome: concatenation trusts it as code, parameterization treats it as data.

Parameterized Queries

The fix is straightforward. Parameterized queries (also called prepared statements) separate the SQL structure from the data. The database treats user input as a value, never as SQL code.

Python (psycopg2):

cursor.execute("SELECT * FROM users WHERE email = %s", (email,))

Node.js (pg):

await pool.query('SELECT * FROM users WHERE email = $1', [email]);

Python (SQLAlchemy):

result = session.execute(text("SELECT * FROM users WHERE email = :email"), {"email": email})

Java (JDBC):

PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE email = ?");
ps.setString(1, email);
ResultSet rs = ps.executeQuery();

PHP (PDO):

$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);

Go (database/sql with PostgreSQL):

row := db.QueryRowContext(ctx, "SELECT id FROM users WHERE email = $1", email)

The placeholder syntax differs by driver (?, %s, $1, :name), but the idea is the same everywhere: the query text and the values travel separately. In PDO, also set PDO::ATTR_EMULATE_PREPARES to false so the database does the binding instead of PHP.

Every language and database driver supports parameterized queries. There is no valid reason to concatenate user input into SQL strings. See bobby-tables.com for examples in every language.

ORM Protection

Object-Relational Mappers (ORMs) like SQLAlchemy, Django ORM, Prisma, and TypeORM use parameterized queries internally. As long as you use the ORM's query builder, you're protected by default.

# Django. Safe by default
User.objects.filter(email=email)

# Prisma. Safe by default
await prisma.user.findMany({ where: { email } })

The danger comes when you bypass the ORM and write raw SQL. If you must use raw queries, use the ORM's parameterized raw query method. Never string concatenation.

Running Django? The ORM parameterizes queries for you, and the Django scanner checks the deployment settings it can't. Running Laravel? The Laravel scanner checks the live site for the configuration mistakes that usually come first.

ORM Pitfalls

Most SQL injection bugs in modern codebases are not in hand-written database code. They sit in the few places where a developer stepped outside the ORM for performance or flexibility. These are the patterns to search for in code review.

Raw query helpers with string building

Each ORM has a safe and an unsafe way to run raw SQL, and they often look almost the same:

// Prisma. Safe: the tagged template turns ${email} into a bound parameter
await prisma.$queryRaw`SELECT * FROM "User" WHERE email = ${email}`

// Prisma. Unsafe: the string is built first, then sent as SQL
await prisma.$queryRawUnsafe(`SELECT * FROM "User" WHERE email = '${email}'`)

// Sequelize. Safe: named replacements
await sequelize.query("SELECT * FROM users WHERE email = :email", {
  replacements: { email },
  type: QueryTypes.SELECT,
})
# SQLAlchemy. Unsafe: text() does not protect an f-string
session.execute(text(f"SELECT * FROM users WHERE email = '{email}'"))

# Django. Safe: params passed separately to raw()
User.objects.raw("SELECT * FROM app_user WHERE email = %s", [email])

Identifiers cannot be parameterized

Placeholders bind values, not table names, column names, or sort directions. Code that lets users pick a sort column often falls back to concatenation for exactly this reason. Map the input to a fixed allowlist instead:

ALLOWED_SORT = {"name": "name", "created": "created_at"}
column = ALLOWED_SORT.get(sort_param, "created_at")
# column comes from the dict above, never from the request
query = f"SELECT id, name FROM users ORDER BY {column}"

Lists and LIKE patterns

IN (...) lists and LIKE searches are two more places where people reach for string building. Use the driver's array support (for example = ANY($1) in PostgreSQL) or generate one placeholder per item. For LIKE, bind the whole pattern as a value, such as "%" + term + "%", and escape % and _ inside the term if users should not be able to use wildcards.

Second-Order SQL Injection

Second-order injection is the version that slips past teams who think they are covered. The malicious input is stored safely, through a perfectly parameterized INSERT. It does nothing at that point. Later, a different piece of code reads the value back from the database and concatenates it into a new query, on the assumption that data from your own database is trusted.

A classic example: a user registers with the username admin'--. Registration is safe. Months later, a password-change routine builds UPDATE users SET password = '...' WHERE username = ' plus the stored username, and the comment marker cuts off the rest of the condition, so the query targets the real admin account. Background jobs, reporting scripts, and admin tools are where this usually lives, because they get less review than user-facing endpoints.

The rule that prevents it is simple: treat every value as untrusted at the point where it enters a query, no matter where it came from. If every query is parameterized, second-order injection has nothing to work with.

How to Test for SQL Injection Safely

Only test applications you own or have written permission to test. Running payloads against someone else's site is illegal in most countries, even with good intentions. For your own code, work from the inside out:

  1. Search the code first. Grep for string formatting near query calls: f-strings or % formatting passed to execute(), + concatenation into SQL in JavaScript, $queryRawUnsafe, .raw(, and .extra(. Static analysis helps here: Bandit rule B608 flags SQL built from strings in Python, and Semgrep has rule packs for the common ORMs.
  2. Test on staging with fake data. Never point active injection tests at production. A time-based payload or a stray UPDATE can lock tables or change real records.
  3. Probe by hand. Add a single quote to each parameter and watch for a 500 error or a database message. Then compare a true condition (' AND '1'='1) with a false one (' AND '1'='2). Different responses mean the input reaches the query.
  4. Automate carefully. sqlmap is the standard open-source tool for confirming and exploring an injection point. Start with its default level and risk settings, since higher risk levels can send queries that modify data.
  5. Fix, then add a regression test that sends the payload which worked and asserts the query treats it as a literal value.

Additional Defense Layers

  • Input validation. Reject unexpected characters. An email field should not contain single quotes or semicolons.
  • Least privilege. Your application's database user should only have the permissions it needs. A read-only endpoint should use a read-only database role.
  • WAF rules. A Web Application Firewall can catch common SQLi patterns. This is a defense-in-depth measure, not a replacement for parameterized queries.
  • Error handling. Never show database error messages to users. Stack traces expose table names, column names, and query structure.

The OWASP SQL Injection Prevention Cheat Sheet is the definitive reference.

Frequently Asked Questions

What is SQL injection?
SQL injection is an attack where malicious SQL code is inserted into application queries through user input. It can read, modify, or delete database data, and injection, the category it belongs to, has appeared in every edition of the OWASP Top 10.
How do you prevent SQL injection?
Use parameterized queries or prepared statements. Never concatenate user input into SQL strings. Modern ORMs like SQLAlchemy, Prisma, and Sequelize prevent SQL injection by default when used correctly.
Can ORMs prevent SQL injection?
Yes, ORMs like SQLAlchemy, Prisma, and Sequelize use parameterized queries internally. However, raw SQL methods within ORMs can still be vulnerable if you concatenate user input.
What is second-order SQL injection?
Second-order SQL injection happens when malicious input is stored safely first and then used unsafely later, for example a username saved with a parameterized INSERT and then concatenated into a query by a reporting job. The fix is the same: parameterize every query, including ones that only use data from your own database.
Can I parameterize table names, column names, or ORDER BY?
No. Placeholders only work for values. For identifiers such as a sort column or table name, map the user's choice to a fixed allowlist of known names in code and never insert the raw input into the query.
Is it legal to test a website for SQL injection?
Only with permission. Test your own applications, ideally on a staging copy with fake data, or systems where the owner has authorized testing in writing. Running injection payloads against someone else's site without permission is illegal in most countries.

Check your website right now

200+ security checks in 60 seconds. Free, no signup required.

Scan My Website (Free)

Claude AI helped me with phrasing and proofreading in this article.

ismycodesafe.com Security Team

We run automated security scans on thousands of websites daily, combining static analysis, SSL/TLS inspection, header auditing, and CVE lookups. Our team tracks OWASP, NIST, and evolving compliance requirements (GDPR, NIS2, PCI DSS) to keep these guides accurate and practical.