All articles
SQL injectionweb securitysecure codingdeveloper toolsOWASP

SQL Injection Prevention: What Actually Works in 2025

SQL injection prevention for web developers, explained with real code examples, common mistakes, and a practical testing workflow you can use today.

WebSentry TeamAugust 3, 20266 min read

SQL injection has been on the OWASP Top 10 for over two decades, yet it's still one of the most common ways attackers breach databases. Not because developers don't know it exists — almost everyone has heard of it — but because prevention often gets applied inconsistently across a codebase. One raw query buried in a legacy controller is all it takes.

This post skips the theory and focuses on what actually stops SQL injection in production applications, with real code, real anti-patterns, and a workflow for catching gaps before attackers do.

How SQL Injection Actually Happens

SQL injection occurs when untrusted input is concatenated directly into a query string, letting an attacker change the query's logic. The classic example:

SELECT * FROM users WHERE username = '$username' AND password = '$password';

If $username is set to admin' --, the query becomes:

SELECT * FROM users WHERE username = 'admin' --' AND password = '';

The -- comments out the password check entirely. That's a login bypass in one line, and it's still found in real codebases today — usually in "temporary" admin tools, internal dashboards, or code written before an ORM was introduced.

It's Not Just Login Forms

  • Search boxes that build LIKE clauses from raw input
  • Sort/filter parameters passed directly into ORDER BY
  • API endpoints that accept JSON and pass fields straight into raw SQL
  • Reporting tools that let users pick columns or table names dynamically
  • Batch import scripts that build INSERT statements from CSV rows

The Only Real Fix: Parameterized Queries

Prepared statements (parameterized queries) separate the SQL logic from the data. The database engine knows what's a query and what's a value, so injected SQL syntax is treated as a literal string, not executable code.

Node.js (node-postgres)

const result = await pool.query(
  'SELECT * FROM users WHERE username = $1 AND password_hash = $2',
  [username, passwordHash]
);

PHP (PDO)

$stmt = $pdo->prepare('SELECT * FROM users WHERE username = :username');
$stmt->execute(['username' => $username]);
$user = $stmt->fetch();

Python (psycopg2)

cur.execute(
    "SELECT * FROM users WHERE username = %s AND password_hash = %s",
    (username, password_hash)
)

Notice the pattern: in every language, the query string never changes based on user input. Only the bound parameters change. This is the difference that matters — not "escaping" input, not "sanitizing" it, but never letting it touch the query string at all.

Common Mistakes That Reintroduce Risk

1. Mixing Parameterized and Raw Queries in the Same Codebase

Teams often parameterize the obvious stuff (login, signup) but leave raw string concatenation in admin panels, internal tools, or "quick" reporting features. Attackers specifically look for these less-tested corners.

2. Trusting ORM "Raw" Escape Hatches

Most ORMs (Sequelize, Django ORM, Eloquent, TypeORM) offer a raw query method for performance or complex joins. These bypass the ORM's built-in protection:

// Sequelize — still vulnerable if you concatenate
sequelize.query(`SELECT * FROM users WHERE email = '${email}'`);

// Safe version — use bound replacements
sequelize.query('SELECT * FROM users WHERE email = :email', {
  replacements: { email },
  type: QueryTypes.SELECT
});

3. Dynamic Table or Column Names

Parameterization protects values, not identifiers. If your app lets users choose a table or column name dynamically (common in reporting tools), you need an explicit allowlist:

const allowedColumns = ['created_at', 'name', 'status'];
if (!allowedColumns.includes(sortColumn)) {
  throw new Error('Invalid sort column');
}
const query = `SELECT * FROM orders ORDER BY ${sortColumn}`;

4. Second-Order Injection

Data that was safely parameterized on insert can still be dangerous later if it's pulled from the database and concatenated into a new raw query somewhere else — for example, a username stored safely, then used to build a dynamic report query without parameters.

5. Assuming ORMs Make You Immune

Using Prisma, Django ORM, or ActiveRecord dramatically reduces risk, but doesn't eliminate it. Any raw SQL fragment, custom migration script, or admin-only query tool still needs the same discipline.

Defense in Depth: Beyond Parameterized Queries

Parameterized queries are the primary control, but layering additional protections limits the damage if something slips through.

  • Least-privilege database accounts: Your application's DB user shouldn't have permission to drop tables, alter schema, or read unrelated databases. Create separate accounts for read-only reporting vs. write operations.
  • Input validation at the edge: Validate type, length, and format before data even reaches your query layer (e.g., reject non-numeric input for an ID field).
  • Web Application Firewall (WAF): Can catch and block common injection payloads before they reach your app, buying time to patch.
  • Error handling that doesn't leak schema: Never return raw database error messages to the client — they often reveal table names, column names, or query structure that helps attackers refine their payloads.
  • Logging and alerting: Flag repeated malformed query attempts or unusual characters (' OR 1=1, UNION SELECT) in request parameters.

A Practical Testing Workflow

You don't need a dedicated pentester to catch the obvious gaps. Build this into your release process:

  1. Code review checklist: Flag any PR containing raw SQL string concatenation, template literals with variables, or ORM .raw()/.query() calls for extra scrutiny.
  2. Static analysis: Tools like Semgrep or Bandit (Python) have rules specifically for detecting string-built queries.
  3. Manual probing: Try entering ', --, and ' OR '1'='1 into every input field, including hidden and query-string parameters, and check for SQL errors in the response.
  4. External scanning: Run your live site through a scanner that checks for exposed error messages, missing security headers, and other misconfigurations that make injection attacks easier to exploit or detect. WebSentry checks headers, CSP, and other config issues that often accompany injection-prone setups — a low security grade is frequently a sign the same team skipped input validation elsewhere too.
  5. Dependency audits: Outdated ORM versions sometimes have known query-building vulnerabilities. Run npm audit, composer audit, or equivalent regularly.

Why This Matters Beyond the Database

A successful SQL injection rarely stays contained to "just the database." Depending on your setup, it can expose customer PII, leak password hashes, or provide a foothold to escalate into the underlying server. For agencies managing client sites, a single unpatched injection point can turn into a breach notification, a client relationship, and a liability conversation all at once.

If you're auditing sites for clients or maintaining several projects, it's worth combining manual code review with automated checks. Misconfigured headers, missing CSP rules, and exposed error pages often travel together with injection vulnerabilities — they're symptoms of the same rushed security posture. Running a free WebSentry scan takes a few minutes and gives you an A–F grade across SSL, headers, CSP, cookies, DNS, and CORS, so you can see at a glance whether a site's overall security hygiene matches the level of care you'd expect around something as fundamental as query safety.

Check your own site

Run a free security scan and see if your site has the issues covered in this article. Results in under 30 seconds.