
Preventing Unsafe SQL Construction in Rust: Building Injection-Resistant Database Queries
Why SQL injection still matters in Rust
SQL injection happens when untrusted input becomes part of the SQL grammar instead of remaining data. A classic example is a login query that interpolates a username directly into a WHERE clause. If the input contains quotes or SQL operators, the database may interpret it as executable syntax.
Rust developers sometimes assume that using a safe language eliminates this risk. It does not. The danger is not memory corruption; it is incorrect query construction. Any code path that uses format!, string concatenation, or ad hoc escaping deserves scrutiny.
The core defense: parameterized queries
The most reliable defense is to separate SQL text from data values. In Rust database libraries, this usually means placeholders in the query string and a separate argument list.
Example with sqlx
use sqlx::{PgPool, Row};
async fn find_user(pool: &PgPool, email: &str) -> Result<Option<i64>, sqlx::Error> {
let row = sqlx::query("SELECT id FROM users WHERE email = $1")
.bind(email)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| r.get::<i64, _>("id")))
}Here, email is transmitted as a bound value, not embedded into the SQL text. Even if email contains ' OR 1=1 --, the database treats it as a literal string.
Example with rusqlite
use rusqlite::{Connection, Result};
fn find_user(conn: &Connection, email: &str) -> Result<Option<i64>> {
let mut stmt = conn.prepare("SELECT id FROM users WHERE email = ?1")?;
let mut rows = stmt.query([email])?;
if let Some(row) = rows.next()? {
Ok(Some(row.get(0)?))
} else {
Ok(None)
}
}The exact placeholder syntax varies by driver, but the principle is the same: let the driver encode values safely.
What parameterization protects, and what it does not
Parameterized queries protect values, not SQL structure. That distinction is critical.
| Input type | Safe to bind as a parameter? | Example |
|---|---|---|
| String values | Yes | WHERE email = ? |
| Numbers | Yes | WHERE id = ? |
| Dates and timestamps | Yes | WHERE created_at >= ? |
| Column names | No | ORDER BY ? usually does not work as intended |
| Table names | No | FROM ? is invalid in most drivers |
| Sort direction | No | ASC / DESC cannot be bound as data |
If you need dynamic structure, you must use a different strategy. That usually means whitelisting known-safe options and generating SQL from those validated choices.
Handling dynamic ORDER BY safely
A common mistake is to accept a user-supplied sort field and insert it directly into the query. This is dangerous because identifiers are part of SQL syntax.
Instead, map external values to a fixed set of known columns:
enum SortField {
CreatedAt,
Email,
LastLogin,
}
impl SortField {
fn as_sql(&self) -> &'static str {
match self {
SortField::CreatedAt => "created_at",
SortField::Email => "email",
SortField::LastLogin => "last_login",
}
}
}
fn build_user_query(sort: SortField, descending: bool) -> String {
let direction = if descending { "DESC" } else { "ASC" };
format!(
"SELECT id, email, created_at FROM users ORDER BY {} {}",
sort.as_sql(),
direction
)
}This pattern is acceptable because both the column name and direction come from a closed set under your control. Never pass raw user input into format! for SQL identifiers.
Validating dynamic filters without concatenation
Real applications often support optional filters: status, date range, tenant ID, or search terms. The safest approach is to build the query incrementally while binding every value.
use sqlx::QueryBuilder;
use sqlx::Postgres;
async fn search_users(
pool: &sqlx::PgPool,
status: Option<&str>,
min_id: Option<i64>,
) -> Result<(), sqlx::Error> {
let mut qb: QueryBuilder<Postgres> =
QueryBuilder::new("SELECT id, email FROM users WHERE 1=1");
if let Some(status) = status {
qb.push(" AND status = ").push_bind(status);
}
if let Some(min_id) = min_id {
qb.push(" AND id >= ").push_bind(min_id);
}
let query = qb.build();
let _rows = query.fetch_all(pool).await?;
Ok(())
}QueryBuilder helps you avoid manual string assembly for values while still allowing flexible query shapes. Use it carefully: push_bind is safe for data, but push should only receive trusted SQL fragments.
Avoiding unsafe escaping as a primary defense
Some developers try to “sanitize” input by replacing quotes or stripping suspicious characters. This is fragile and database-specific. Escaping rules differ across SQL dialects, collations, encodings, and connection settings.
Relying on manual escaping creates several problems:
- It is easy to miss edge cases.
- It is hard to keep consistent across queries.
- It can fail when the database interprets strings differently than expected.
- It often gives a false sense of security.
Use escaping only when a library explicitly requires it for a narrow purpose. For normal application queries, prefer parameter binding.
Secure patterns for authentication and lookup queries
Login and account-recovery endpoints are common injection targets because they often accept free-form identifiers. A secure login query should bind both username and password hash lookup values.
use sqlx::PgPool;
async fn load_account(
pool: &PgPool,
username: &str,
) -> Result<Option<(i64, String)>, sqlx::Error> {
let row = sqlx::query_as::<_, (i64, String)>(
"SELECT id, password_hash FROM accounts WHERE username = $1"
)
.bind(username)
.fetch_optional(pool)
.await?;
Ok(row)
}If you also support case-insensitive matching, keep the SQL structure fixed and bind only the value:
SELECT id FROM accounts WHERE lower(username) = lower($1)Do not rewrite the query with string concatenation to handle casing or wildcard searches.
Handling LIKE safely
Search features often use LIKE patterns, which can be tricky because % and _ are wildcard characters. Binding the value is still safe, but you may need to escape wildcard characters if you want literal matching.
For example, if users search for product names, you may want to treat their input as plain text:
fn escape_like(input: &str) -> String {
input
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
}
async fn search_products(pool: &sqlx::PgPool, term: &str) -> Result<(), sqlx::Error> {
let pattern = format!("%{}%", escape_like(term));
sqlx::query(
r#"SELECT id, name FROM products
WHERE name LIKE $1 ESCAPE '\'"#
)
.bind(pattern)
.fetch_all(pool)
.await?;
Ok(())
}This is not a substitute for parameterization; it is an additional step to control wildcard behavior.
Common mistakes to avoid
1. Building SQL with format!
let sql = format!("SELECT * FROM users WHERE email = '{}'", email);This is unsafe because the input becomes part of the SQL text.
2. Using raw SQL fragments from request parameters
If a web handler accepts sort=created_at DESC; DROP TABLE users; --, and you insert it into the query, the database may execute unintended commands.
3. Trusting “internal” inputs too much
Data from message queues, admin panels, CSV imports, or other services can still be attacker-controlled. Treat all external data as untrusted until proven otherwise.
4. Mixing safe and unsafe fragments in one query
A query can be partially parameterized and still vulnerable if one identifier or clause comes from raw input.
Designing safer data access layers
A good Rust data access layer makes the safe path the easy path. Consider these design practices:
- Expose functions that accept typed parameters, not raw SQL strings.
- Keep query templates close to the code that binds values.
- Represent user-selectable options with enums instead of strings.
- Centralize dynamic SQL construction in a small number of audited helpers.
- Use integration tests with malicious inputs to verify that queries behave correctly.
For example, a repository API might look like this:
enum UserSort {
CreatedAt,
Email,
}
struct UserFilter {
email_prefix: Option<String>,
active_only: bool,
sort: UserSort,
}This design prevents callers from injecting arbitrary SQL because they can only choose from predefined options.
Testing for injection resistance
Security testing should include hostile inputs, not just valid cases. Add tests that attempt to break query structure and confirm the application still behaves normally.
Useful test inputs include:
' OR 1=1 --"; DROP TABLE users; --abc%defx' UNION SELECT ...- Unicode apostrophes and unusual whitespace
Your goal is not to “block suspicious characters” universally. Your goal is to ensure those characters are always handled as data unless they are part of a trusted, validated SQL fragment.
Practical checklist
Before shipping a Rust feature that talks to a database, verify the following:
- All user-controlled values are bound parameters.
- No
format!or string concatenation is used for values. - Dynamic identifiers come from enums or whitelists.
- Search patterns handle wildcard characters intentionally.
- Query helpers are small, reviewed, and tested.
- Database access code does not accept raw SQL from higher layers.
Conclusion
Rust gives you strong guarantees about memory safety, but SQL injection remains a design problem, not a language problem. The safest approach is simple: bind values, whitelist structure, and keep raw SQL fragments under strict control.
If you make parameterized queries the default and reserve dynamic SQL for validated, narrow cases, your Rust application will be far more resilient to injection attacks and much easier to maintain.
