Skip to content
rusthacks.dev
Security

SQL Injection in Rust and Why Typed Queries Help Explained

Rust's type system does not stop SQL injection, but parameterized queries do. See the vulnerable pattern, how bind parameters fix it, what cannot be bound and how to handle dynamic identifiers safely.

3 min read
Summarize in AI
Flat illustration for the post “SQL Injection in Rust and Why Typed Queries Help Explained”, composed around generics.

Memory safety does nothing for this bug. A query built by joining strings is as injectable in Rust as anywhere, and the compiler cannot tell. What protects you is the same as everywhere: send values as parameters, not as text. Rust's advantage is that the idiomatic database crates make the safe way the easy way. This post shows the vulnerable pattern, the fix and the one case parameters cannot solve. It belongs to the threats in security for Rust developers.

sqlx 0.8, PostgreSQL 17, Rust 1.90.

The vulnerable pattern

rust
async fn find_user(pool: &sqlx::PgPool, name: &str) -> Result<Vec<sqlx::postgres::PgRow>, sqlx::Error> {
    let sql = format!("SELECT id FROM users WHERE name = '{name}'");
    sqlx::query(&sql).fetch_all(pool).await
}

With name = "x' OR '1'='1" the statement becomes SELECT id FROM users WHERE name = 'x' OR '1'='1', which returns every user. With a stacked statement or a UNION SELECT, an attacker reads other tables. The code compiles without a warning.

The fix: bind parameters

rust
async fn find_user(pool: &sqlx::PgPool, name: &str) -> Result<Vec<(i64,)>, sqlx::Error> {
    sqlx::query_as("SELECT id FROM users WHERE name = $1")
        .bind(name)
        .fetch_all(pool)
        .await
}

The SQL text is a constant. name travels in a separate protocol message, typed as text. If it contains quotes, they are data. The same input now returns no rows. Prepared statements also let the database cache the plan.

The macro form goes further:

rust
let rows = sqlx::query!("SELECT id FROM users WHERE name = $1", name)
    .fetch_all(pool)
    .await?;

query! requires a string literal, so you cannot pass a formatted string by accident: sqlx::query!(&sql) does not compile. That is the sense in which typed queries help: the unsafe pattern becomes a compile error. Details in mapping rows with query_as.

What cannot be a parameter

Parameters stand for values. Table names, column names and ASC/DESC are syntax. For a sortable list:

rust
fn order_clause(sort: &str, dir: &str) -> Result<String, &'static str> {
    let column = match sort {
        "name" => "name",
        "created" => "created_at",
        _ => return Err("unsupported sort column"),
    };
    let direction = match dir {
        "asc" => "ASC",
        "desc" => "DESC",
        _ => return Err("unsupported direction"),
    };
    Ok(format!("ORDER BY {column} {direction}"))
}

Every branch returns a literal you wrote, so the formatted string contains no user text. Compose with QueryBuilder, where push takes only such literals and push_bind takes values.

IN lists and LIKE

Use = ANY($1) with an array in PostgreSQL, or generate placeholders with separated and bind each value. For LIKE, bind the pattern: .bind(format!("%{}%", escaped)), and escape %, _ and the escape character in escaped if you want literal matching.

Other layers

Least privilege reduces the damage: the application's database user should not own the schema or have DROP, and a read-only endpoint should use a read-only role. Log queries without values. Validate input shape at the boundary as well; it is not a substitute for parameters.

Limits

Second-order injection stores hostile text safely and later uses it in a string-built query. The rule is the same: never build SQL by concatenation. OWASP's SQL Injection Prevention Cheat Sheet is the primary reference.

Also inDatabases
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.