SQL injection remains one of the most exploited vulnerability classes, ranking third in the OWASP Top 10. Despite being well-understood for over two decades, it still appears in production code daily. This guide covers modern prevention techniques, ORM pitfalls developers overlook, and how to detect blind SQLi in your own apps.

Why SQL Injection Still Happens

The root cause is always the same: user-controlled data is concatenated directly into a SQL query rather than separated from it. Frameworks and ORMs reduce the surface area, but they don’t eliminate it—especially when developers reach for raw queries for performance or complexity.

# Vulnerable — never do this
user_id = request.args.get('id')
query = f"SELECT * FROM users WHERE id = {user_id}"
cursor.execute(query)

An attacker supplying 1 OR 1=1 retrieves the entire table. Supplying 1; DROP TABLE users; -- causes catastrophic data loss on some databases.

Parameterised Queries: The Non-Negotiable Fix

Parameterised queries (also called prepared statements) separate SQL structure from data. The database driver handles quoting and escaping, making injection structurally impossible for those parameters.

Python (psycopg2 / sqlite3)

# Safe — parameter placeholder, not string formatting
cursor.execute(
    "SELECT * FROM users WHERE id = %s AND active = %s",
    (user_id, True)
)

# With named parameters (sqlite3)
cursor.execute(
    "SELECT * FROM orders WHERE user_id = :uid AND status = :status",
    {"uid": user_id, "status": "pending"}
)

Java (JDBC)

// Vulnerable
String query = "SELECT * FROM accounts WHERE username = '" + username + "'";

// Safe
PreparedStatement stmt = conn.prepareStatement(
    "SELECT * FROM accounts WHERE username = ? AND password_hash = ?"
);
stmt.setString(1, username);
stmt.setString(2, passwordHash);
ResultSet rs = stmt.executeQuery();

Node.js (pg / mysql2)

// pg (PostgreSQL)
const { rows } = await pool.query(
  'SELECT * FROM products WHERE category = $1 AND price < $2',
  [category, maxPrice]
);

// mysql2 with prepared statements
const [rows] = await connection.execute(
  'SELECT * FROM sessions WHERE token = ? AND expires_at > NOW()',
  [token]
);

ORM Pitfalls

ORMs are safe by default for basic queries, but developers regularly bypass their protections without realising it.

Raw Query Escapes (Django)

# Dangerous — raw() with string formatting
User.objects.raw(f"SELECT * FROM auth_user WHERE username = '{username}'")

# Safe — raw() with parameterisation
User.objects.raw("SELECT * FROM auth_user WHERE username = %s", [username])

# Safe — use the ORM
User.objects.filter(username=username)

Sequelize (Node.js)

// Dangerous — literal injection into where clause
const users = await User.findAll({
  where: sequelize.literal(`username = '${username}'`)
});

// Safe — use model methods
const users = await User.findAll({
  where: { username: username }
});

// Safe — replacements for raw queries
const users = await sequelize.query(
  'SELECT * FROM users WHERE username = :name',
  { replacements: { name: username }, type: QueryTypes.SELECT }
);

Hibernate (Java)

// Dangerous — HQL string concatenation
Query q = session.createQuery(
    "FROM User WHERE username = '" + username + "'"
);

// Safe — named parameters
Query q = session.createQuery("FROM User WHERE username = :name");
q.setParameter("name", username);

// Safe — Criteria API
CriteriaBuilder cb = session.getCriteriaBuilder();
CriteriaQuery<User> cq = cb.createQuery(User.class);
Root<User> root = cq.from(User.class);
cq.where(cb.equal(root.get("username"), username));

Blind SQL Injection Patterns

Not all SQLi produces visible output. Blind SQLi extracts data through side channels:

Boolean-based: The response differs based on a true/false condition.

/api/user?id=1 AND 1=1   -- normal response
/api/user?id=1 AND 1=2   -- empty or error response

Time-based: Delays confirm injection even when output is identical.

-- PostgreSQL
1; SELECT pg_sleep(5)--

-- MySQL
1 AND SLEEP(5)--

-- MSSQL
1; WAITFOR DELAY '0:0:5'--

Detecting it in your own app: Use tools like sqlmap in safe mode (--level=1 --risk=1) against your staging environment, or manually test input fields with ', '', 1'1, and time-delay payloads, watching for response time anomalies.

Defense in Depth

Parameterised queries prevent SQLi, but layered controls limit blast radius:

  • Least privilege: App database users should only have SELECT/INSERT/UPDATE/DELETE on the tables they need. Never connect as a DBA account.
  • WAF rules: A web application firewall can catch common patterns, but is not a substitute for fixing the underlying code.
  • Error handling: Never expose raw database errors to users. Log them server-side; return generic messages to clients.
  • Regular scanning: Integrate sqlmap or SAST tools (Semgrep, CodeQL) into your CI pipeline to catch regressions.

Key Takeaways

  1. Always use parameterised queries or prepared statements — no exceptions.
  2. Audit any use of .raw(), .execute(), or literal() in your ORM code.
  3. Apply database least-privilege: the breach of one endpoint shouldn’t compromise your entire schema.
  4. Test actively with automated scanners in your staging environment.