How to Prevent SQL Injection: A Developer's Guide
SQL injection (SQLi) happens when untrusted input is concatenated into a SQL statement, letting an attacker change the query's meaning instead of just supplying a value. It remains one of the most damaging and most preventable web vulnerabilities, and the fix is well understood: keep code and data on separate channels. This guide covers the defenses that actually work, in priority order, with concrete examples and the mistakes that re-introduce the bug.
Why SQL injection still matters
A single injectable parameter can expose an entire database. Depending on the query and the account's permissions, an attacker may read every row in a table, modify or delete records, bypass authentication, or in some configurations run administrative commands on the database server. Because SQLi targets server-side data rather than a single user's browser, the blast radius is large, which is why it has appeared on the OWASP Top Ten for years.
The root cause is always the same structural mistake: the application builds a query string by mixing fixed SQL with values that came from a user, an HTTP header, a file, or another system. The database parser then sees attacker-controlled text as part of the command. Every technique below exists to break that mixing.
Use parameterized queries (the primary defense)
Parameterized queries, also called prepared statements, are the correct, complete fix for the vast majority of cases. You send the SQL text with placeholders to the database first, then send the values separately. The driver never substitutes the values into the SQL text, so input can never change the query structure, no matter what characters it contains.
The key idea: never build SQL by string concatenation with user input. Use placeholders and bind parameters instead. Examples across common stacks:
// Node.js (pg)
await client.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
# Python (sqlite3 / DB-API)
cur.execute(
"SELECT * FROM users WHERE email = ?",
(email,)
)
// Java (JDBC)
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM users WHERE email = ?");
ps.setString(1, email);
-- PHP (PDO)
$stmt = $pdo->prepare(
'SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
Use placeholders for every value, including numbers, dates, and booleans. Do not be tempted to skip parameterization for inputs you think are "just an integer" — validate the type instead (see below), and still bind it.
Use an ORM or query builder correctly
Object-relational mappers (such as Prisma, Hibernate, ActiveRecord, SQLAlchemy, or Entity Framework) and query builders parameterize automatically when you use their normal API. This is one of the strongest practical reasons to adopt one: idiomatic ORM code is parameterized by default. If you ever generate SQL from a schema or migrate raw queries into an ORM, a tool like the SQL to ORM Model Generator can help you express tables as models rather than hand-built strings.
The danger is the escape hatch. Almost every ORM offers a "raw query" method, and many offer string interpolation helpers. The moment you concatenate user input into a raw query, you lose the protection:
// UNSAFE even inside an ORM
db.raw("SELECT * FROM users WHERE name = '" + name + "'");
// SAFE: bind through the raw API's parameters
db.raw("SELECT * FROM users WHERE name = ?", [name]);
Treat any raw-SQL method as a request to parameterize manually, and code-review those call sites closely.
Validate and constrain input as defense in depth
Parameterization protects values, but some parts of a query cannot be parameterized — table names, column names, sort directions, and the choice between ASC and DESC. For these, use an allowlist: map the user's input to a fixed set of known-good values you control, and reject anything else.
- Define the legal options in code (for example, a set of sortable column names).
- Look up the user's choice in that set; if it is absent, return an error or a safe default.
- Insert only the value you retrieved from your own list — never the raw input — into the query.
Also validate the shape of values: enforce types, lengths, and formats before they reach the database. An ID should parse as an integer; an email should match an email pattern. You can prototype and sanity-check those patterns with a Regex Tester. Treat this as a second layer, not a substitute: input validation reduces the attack surface, but parameterized queries are what make injection impossible.
Apply least privilege and other layered controls
Assume that some query, somewhere, will eventually be vulnerable, and limit what that mistake can do.
- Least-privilege database accounts. The account your app connects with should have only the permissions it needs. A read-heavy service does not need
DROP TABLEor access to other applications' schemas. Separate accounts per service contain the damage. - Stored procedures, used carefully. They can encapsulate access, but they are not automatically safe. A procedure that builds and executes dynamic SQL from its parameters is just as injectable. Parameterize inside the procedure too.
- Minimal error detail. Do not return raw database errors to clients. Verbose messages help attackers map your schema and confirm injection points. Log details server-side; return generic errors to users.
- Rate limiting and monitoring. Blind and time-based injection often requires many requests. Throttling and alerting on anomalies raise the cost of an attack; see practical patterns in rate limiting strategies for APIs.
- Keep secrets out of code. Connection strings and credentials belong in environment configuration, not source. Before sharing logs or configs, scrub them with an .env Redactor.
Common mistakes that re-introduce SQLi
Most real-world injections come from a short list of recurring errors:
- Blocklist filtering instead of parameterization. Trying to strip or ban characters like quotes or the word
SELECTis fragile. Attackers bypass blocklists with encoding, comments, and case variation, and you will break legitimate input (anyone named O'Brien). Allowlist, do not blocklist. - Manual escaping. Hand-rolled quote-doubling or custom escape functions miss edge cases and differ per database and character set. Let the driver bind parameters.
- Parameterizing most queries but not all. One concatenated query — often a quick search feature, a dynamic
ORDER BY, or an admin tool — is enough. Audit every query, including reports and internal endpoints. - Trusting non-form inputs. Headers, cookies, JSON bodies, URL parameters, webhooks, and data from other services are all untrusted. SQLi is not limited to form fields.
- Dynamic identifiers from raw input. Building table or column names directly from request data. Use an allowlist mapping instead.
Verify your defenses
Treat prevention as something you confirm, not assume. Add automated tests that send classic payloads (a quote, ' OR '1'='1, comment sequences) to each endpoint and assert that they are handled as ordinary data, not executed. Run a static analysis or linter that flags string-built SQL, and review every raw-query call in code review. To build intuition for how the attack works in a safe sandbox, walk through the SQL Injection Demo, and use a SQL Formatter to read complex queries clearly so concatenation points are easy to spot. The goal is simple and achievable: every query in your codebase keeps code and data on separate channels, every time.
Frequently Asked Questions
Use parameterized queries (prepared statements) everywhere. You send the SQL with placeholders, then bind the values separately, so user input is always treated as data and can never alter the query's structure. This is the primary, complete defense for the overwhelming majority of cases; everything else is defense in depth.
Not automatically. ORMs and query builders parameterize when you use their normal API, which is why idiomatic ORM code is safe by default. But they all provide raw-query escape hatches, and concatenating user input into a raw query reintroduces the vulnerability. Bind parameters even in raw queries and review those call sites carefully.
No. Blocklisting characters or keywords and hand-rolled escaping are fragile: attackers bypass them with encoding, comments, and case changes, and you break legitimate input like names with apostrophes. Use parameterized queries instead, and use allowlists for anything that genuinely cannot be parameterized, such as column names.
These cannot be parameterized, so use an allowlist. Define the legal values in your code, look up the user's choice against that fixed set, and insert only the value you retrieved from your own list. Reject anything not on the list rather than passing raw input into the query.
It is a containment layer for the mistake you have not caught yet. If the application account can only read the data it needs and cannot drop tables or reach other schemas, a single overlooked injectable query does far less damage. Combine least-privilege accounts with minimal error messages, rate limiting, and monitoring.