Safeguard
Industry Analysis

SQL Injection Prevention in Ruby with exec_params Binding

Ruby's pg gem makes SQL injection preventable with one method change. Here's how exec_params binds parameters, why Rails CVEs keep recurring, and how to migrate safely.

Aman Khan
AppSec Engineer
7 min read

When a Ruby application talks to PostgreSQL directly through the pg gem, the difference between exec and exec_params is the difference between a text template and a parameterized query — and that difference decides whether user input can rewrite your SQL. exec sends a raw string to the server, so any interpolated value ("WHERE email = '#{params[:email]}'") becomes part of the command itself. exec_params sends the SQL text and the values as two separate wire-protocol messages, so PostgreSQL always treats $1, $2, and so on as data, never as syntax. This isn't a style preference; it's the mechanism that closed dozens of real Rails and Sinatra CVEs between 2012 and 2022. Below, we walk through why interpolated queries keep reappearing in production codebases, what the CVE history actually shows, how to migrate to bound parameters without breaking existing code, and where ORMs still leave gaps that exec_params doesn't cover.

What Makes exec_params Different From Ruby's exec Method?

exec_params separates SQL structure from SQL data at the protocol level, while exec concatenates them into one string before PostgreSQL ever sees it. The pg gem's PG::Connection#exec method takes a single string argument and hands it to the server as-is:

conn.exec("SELECT * FROM users WHERE email = '#{params[:email]}'")

If params[:email] contains ' OR '1'='1, the resulting query returns every row in the table. exec_params, by contrast, takes the query text with positional placeholders and a separate array of bind values:

conn.exec_params("SELECT * FROM users WHERE email = $1", [params[:email]])

PostgreSQL's extended query protocol receives the placeholder text and the parameter array in distinct messages (Parse, Bind, Execute). The value is never parsed as SQL grammar — it's substituted after parsing is complete, so quote characters, semicolons, and comment sequences inside it are inert. This is the same guarantee that prepared statements provide via conn.prepare plus conn.exec_prepared, and it's the reason OWASP lists parameterized queries as the primary defense for Injection, ranked A03 in the OWASP Top 10 2021.

Why Do String-Interpolated Queries Still Show Up in Production Rails Apps?

They show up because Rails makes it just as easy to write unsafe SQL as safe SQL, and both look idiomatic. ActiveRecord's query interface — where(email: params[:email]) — binds parameters automatically and is safe. But the moment a developer needs a dynamic column name, a computed ORDER BY, or a raw fragment for performance, they reach for find_by_sql, where("email = '#{params[:email]}'"), or order(params[:sort]), and the framework does not stop them. This pattern is exactly what produced CVE-2012-2695, CVE-2012-2660, and CVE-2012-2661 — three Active Record SQL injection vulnerabilities disclosed together on January 8, 2013, all rooted in how dynamic finders and where hashes handled certain input types. It resurfaced again in CVE-2016-6317, an Active Record SQL injection triggered through serialized column values, and in CVE-2022-32224, where unsafe YAML deserialization in Action Pack combined with SQL execution paths. Each of these had the same root cause pattern: application code or framework internals building query strings from untrusted input instead of passing that input as a bound parameter through something like exec_params.

How Many CVEs Trace Back to Ruby SQL Injection Since 2012?

At least a dozen CVEs affecting Rails, Sinatra, and popular gems between 2012 and 2022 list SQL injection as the root cause, and that count only reflects publicly disclosed, framework-level bugs — it excludes the far larger number of injection flaws found in individual applications that never receive a CVE identifier. The National Vulnerability Database entries for CVE-2012-2660, CVE-2012-2661, CVE-2012-2695, CVE-2013-0155, and CVE-2016-6317 all map to Active Record query construction. The common thread across every one of them is a code path where a value that should have been bound as a parameter was instead interpolated into a SQL fragment before it reached the database driver. Verizon's Data Breach Investigations Report has repeatedly ranked injection-class attacks among the top techniques in web application breaches, and Ruby's ecosystem is not exempt simply because ActiveRecord defaults are safe — the exceptions are where the damage happens.

What Does a Real exec_params Migration Look Like?

A real migration means finding every raw SQL string built with interpolation and replacing the interpolated values with positional placeholders bound through exec_params, sanitize_sql_array, or ActiveRecord's own parameterized helpers. Consider a reporting endpoint that filters by a date range and a status the user selects from a dropdown:

# Vulnerable: values interpolated directly into the query string
conn.exec("SELECT * FROM orders WHERE status = '#{status}' AND created_at > '#{since}'")

# Fixed: values passed as bound parameters
conn.exec_params(
  "SELECT * FROM orders WHERE status = $1 AND created_at > $2",
  [status, since]
)

If the same query needs to run repeatedly with different values — for example, inside a loop processing a batch of accounts — prepare plus exec_prepared amortizes the parse/plan cost on the server:

conn.prepare("orders_by_status", "SELECT * FROM orders WHERE status = $1")
results = conn.exec_prepared("orders_by_status", [status])

For teams on ActiveRecord who need raw SQL for a performance-sensitive path, ActiveRecord::Base.sanitize_sql_array(["SELECT * FROM orders WHERE status = ?", status]) achieves the same binding guarantee without dropping to the pg gem directly. The migration effort is almost always a search for string interpolation (#{}) inside anything passed to exec, execute, find_by_sql, or where with a string argument — a grep -rn '\.exec(\|find_by_sql\|where("' app/ pass surfaces most of it in a single afternoon for a mid-sized codebase.

Does ActiveRecord's Query Interface Fully Protect You?

No — ActiveRecord protects you only inside its structured query methods, and it stops protecting you the instant you pass it a raw string. where(status: status) is safe because ActiveRecord builds the query with bound parameters under the hood. where("status = '#{status}'") is not, because the string is handed to the connection adapter as literal SQL, bypassing the binding layer entirely. The same gap applies to order, group, having, pluck with raw column expressions, and connection.execute. Static analysis tools like Brakeman flag many of these patterns by detecting string interpolation inside query methods, but they can miss dynamically constructed strings assembled across multiple lines or passed through helper methods — which is exactly why dependency and code-path visibility matters as much as the linter rule itself.

How Safeguard Helps

Safeguard treats SQL injection risk in Ruby codebases as a supply chain visibility problem, not just a linting problem. Our platform maps which of your gems and internal modules construct raw SQL, flags call sites where exec, execute, or find_by_sql receive interpolated strings instead of bound parameters, and correlates those findings against the CVE history for the exact gem versions you run — so a vulnerable pattern in an outdated pg or Rails release gets surfaced before it reaches production, not after an audit. Safeguard also tracks the provenance of every gem in your dependency graph, alerting you when a transitive dependency introduces a raw-SQL code path you never wrote yourself. For teams migrating from exec to exec_params across a large codebase, Safeguard's continuous scanning re-checks each pull request against the same rule set, so regressions — a new interpolated query added six months after the initial cleanup — get caught at review time instead of in a penetration test. The result is a measurable, continuously enforced guarantee that the parameterization your team fixed once stays fixed.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.