
Preventing SQL Injection in Rust with Parameterized Queries
Why SQL injection still matters in Rust
Rust often gives developers a false sense of security: if the code compiles and there are no memory bugs, the system must be safe. That is not true for SQL construction. Injection happens when untrusted input changes the meaning of a query.
For example, this is dangerous:
let sql = format!(
"SELECT id, email FROM users WHERE email = '{}'",
user_email
);If user_email contains ' OR '1'='1, the query logic changes. Even if your database driver rejects some malformed input, relying on string formatting is a security bug waiting to happen.
The fix is simple in principle: keep SQL text and data separate.
The core defense: bind parameters, never interpolate values
Parameterized queries use placeholders in the SQL statement and bind values separately. The database treats bound values as data, not executable SQL.
Unsafe versus safe
| Approach | Example | Risk |
|---|---|---|
| String interpolation | format!("... WHERE id = {}", id) | High |
| Manual escaping | replace('\'', "''") | Error-prone |
| Parameter binding | query("... WHERE id = $1", &[&id]) | Preferred |
Manual escaping is not a reliable defense because SQL dialects differ, encodings can be tricky, and escaping rules vary for strings, identifiers, and literals. Parameter binding avoids those problems.
Using sqlx safely in Rust
sqlx is a popular async database library that supports compile-time query checking and parameter binding. It works well for security-sensitive code because it encourages explicit queries and typed parameters.
Example: safe login lookup
use sqlx::{PgPool, Row};
pub async fn find_user_by_email(
pool: &PgPool,
email: &str,
) -> Result<Option<(i64, String)>, sqlx::Error> {
let row = sqlx::query(
"SELECT id, password_hash FROM users WHERE email = $1"
)
.bind(email)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| {
let id: i64 = r.get("id");
let password_hash: String = r.get("password_hash");
(id, password_hash)
}))
}Why this is safe
- The SQL text is fixed.
emailis bound as data.- The driver handles quoting, encoding, and protocol-level escaping.
- The query remains readable and auditable.
Best practices with sqlx
- Prefer
query()with.bind()for dynamic values. - Use
query_as!orquery!when you want compile-time validation against a live database schema. - Keep SQL in one place instead of assembling fragments across multiple functions.
- Treat all request-derived data as untrusted, even if it comes from authenticated users.
Handling dynamic filters without concatenating SQL
A common reason developers fall back to string building is search and filtering. For example, a product listing endpoint may allow optional filters for category, price range, and sort order.
The secure pattern is:
- Keep values parameterized.
- Restrict dynamic SQL fragments to a fixed allowlist.
- Never accept raw column names, operators, or sort directions from users.
Example: safe search endpoint
use sqlx::{PgPool, Row};
pub async fn search_products(
pool: &PgPool,
category: Option<&str>,
max_price: Option<i64>,
sort: Option<&str>,
) -> Result<Vec<(i64, String)>, sqlx::Error> {
let sort_clause = match sort {
Some("price_asc") => "ORDER BY price ASC",
Some("price_desc") => "ORDER BY price DESC",
Some("name_asc") => "ORDER BY name ASC",
_ => "ORDER BY id ASC",
};
let mut sql = String::from(
"SELECT id, name FROM products WHERE 1=1"
);
if category.is_some() {
sql.push_str(" AND category = $1");
}
if max_price.is_some() {
sql.push_str(" AND price <= $2");
}
sql.push(' ');
sql.push_str(sort_clause);
let mut query = sqlx::query(&sql);
if let Some(category) = category {
query = query.bind(category);
}
if let Some(max_price) = max_price {
query = query.bind(max_price);
}
let rows = query.fetch_all(pool).await?;
Ok(rows
.into_iter()
.map(|r| (r.get("id"), r.get("name")))
.collect())
}This example shows a subtle but important point: not every piece of SQL can be parameterized. Identifiers and keywords such as ORDER BY price DESC must come from a strict allowlist. Never pass user input directly into those positions.
Safer pattern for dynamic SQL
If your query has many optional filters, consider building it with a query builder that supports bindings rather than manual string concatenation. The goal is to make unsafe interpolation difficult or impossible.
Choosing the right database API
Different Rust database libraries offer different ergonomics, but the security rule is the same: bind parameters.
| Library | Strengths | Security notes |
|---|---|---|
sqlx | Async, compile-time checks, strong ergonomics | Excellent for explicit parameter binding |
tokio-postgres | Low-level PostgreSQL control | Safe when using prepared statements and binds |
diesel | Strong type system, query builder | Avoid raw SQL unless absolutely necessary |
rusqlite | Simple SQLite access | Use placeholders like ?1, ?2 |
If you use an ORM or query builder, verify how it handles raw SQL escape hatches. Many applications become vulnerable when developers use a safe abstraction for 95% of queries and then concatenate strings for the remaining 5%.
Prepared statements and their limits
Prepared statements are often mentioned alongside parameterized queries, but they are not identical in every library. In general, a prepared statement is parsed once and executed multiple times with different bound values.
This can help with:
- Security: values are separated from SQL structure.
- Performance: repeated queries may be faster.
- Maintainability: query shape stays fixed.
However, prepared statements do not magically make unsafe SQL safe. If you prepare a statement built from user input, the injection risk already exists before preparation. The rule remains: only bind values, never SQL syntax.
Avoiding raw SQL footguns
Sometimes raw SQL is necessary for performance or advanced features. That is acceptable, but it increases the burden on the developer.
Common mistakes
- Building
WHEREclauses withformat! - Concatenating
ORDER BYfields from request parameters - Injecting table names from tenant or user input
- Using
IN (...)lists by joining strings manually - Trusting “sanitized” input from earlier layers
Better alternatives
- Use
.bind()for every value. - Map request parameters to fixed SQL fragments.
- Use typed enums for sort modes and filters.
- For variable-length lists, use driver support for array parameters or query builders that expand placeholders safely.
For example, if you need to query multiple IDs, do not build IN (1, 2, 3) by string joining user input. Prefer a library feature that binds arrays or generates placeholders safely.
Validating input is not a substitute for binding
Input validation is still important, but it solves a different problem. Validation ensures the data matches your business rules. Parameterization ensures the data cannot alter SQL structure.
A secure application usually needs both:
- Validate that an email looks like an email.
- Bind the email as a query parameter.
- Validate that a sort mode is one of a few known values.
- Use the validated mode to choose a fixed SQL fragment.
Do not treat validation as a replacement for parameter binding. Even “safe-looking” input can be malicious in a SQL context.
Testing for injection resistance
Security-sensitive database code should be tested with malicious inputs, not just normal cases. Add tests that attempt to break query structure.
Example test cases
' OR '1'='1'; DROP TABLE users; --[email protected]' --- Unicode and encoding edge cases
- Very long strings and unusual whitespace
A good test does not need to prove the absence of all injection bugs, but it should confirm that user input is treated as data and that queries still behave correctly under hostile values.
Practical testing tips
- Test repository functions directly, not only HTTP handlers.
- Include integration tests against a real database.
- Verify that malicious input returns no extra rows and does not change schema or data.
- Log query failures carefully, but never print secrets or full credentials.
Secure patterns for common query types
Authentication lookups
Use a single parameterized query to fetch the account record by username or email. Then compare password hashes using a dedicated password hashing library. Do not build a query that “checks password in SQL.”
Search endpoints
Use parameterized LIKE patterns carefully. For example, if users search by prefix, build the wildcard in Rust and bind the full string:
let pattern = format!("{}%", prefix);
let rows = sqlx::query("SELECT id, name FROM users WHERE name LIKE $1")
.bind(pattern)
.fetch_all(pool)
.await?;The wildcard is part of the data, not the SQL syntax.
Multi-tenant applications
Never accept tenant table names or schema names from the request. Use a tenant identifier as a bound value in row-level filters, or isolate tenants using separate connections, schemas, or databases controlled by server-side configuration.
Operational best practices
Secure query construction is only part of the picture. Production systems also need operational controls.
- Use least-privilege database accounts.
- Restrict application users to only the tables and operations they need.
- Separate read and write roles where practical.
- Avoid superuser credentials in application configuration.
- Review raw SQL during code review with the same rigor as cryptographic code.
- Centralize database access in repository modules so unsafe patterns are easy to spot.
A secure database layer is easier to maintain when query logic is not scattered across controllers, handlers, and utility functions.
A simple checklist for Rust projects
Before merging database code, verify the following:
- All user-controlled values are bound parameters.
- No
format!,push_str, or string concatenation is used for SQL values. - Any dynamic SQL fragments come from a fixed allowlist.
- Raw SQL is reviewed carefully and minimized.
- Tests cover malicious input cases.
- Database credentials use least privilege.
- Query code is centralized and easy to audit.
If you can answer “yes” to all of these, your application is in a much better position against SQL injection.
Conclusion
Rust gives you strong guarantees at the language level, but SQL injection is a design problem, not a memory-safety problem. The secure pattern is straightforward: keep SQL structure fixed, bind every value, and allow only controlled SQL fragments where absolutely necessary.
When you combine parameterized queries with strict allowlists, careful testing, and least-privilege database accounts, you dramatically reduce the risk of injection vulnerabilities in Rust applications.
