Prevent SQL injection in Ruby by never building SQL from user input with string interpolation. Use ActiveRecord hash conditions or placeholder arrays so the database receives values separately from the query text, and treat raw fragments in order, group and select as dangerous too.
What does the unsafe version look like?
The Rails Security Guide shows the pattern to avoid: a condition built by interpolating a parameter into a string. Here is a constructed example for a fictional search page.
# Unsafe: params[:name] becomes part of the SQL text
Project.where("name = '#{params[:name]}'")
An input such as ' OR '1'='1 changes what the query means. The attacker is no longer supplying a name, they are supplying SQL.
What does the safe version look like?
Let ActiveRecord bind the value.
Project.where(name: params[:name])
Project.where("name = ?", params[:name])
Project.where("name = :name AND status = :status", name: params[:name], status: "open")
The Rails guide recommends passing arguments as an array or hash rather than directly into the condition string. The database driver sends the value as data, so quote characters inside it stay inert.
Where do developers still get caught?
Placeholders protect values. They cannot protect identifiers. Places where Rails code often accepts raw text:
order(params[:sort]), where a column name arrives from the clientpluck,select,groupandjoinswith string argumentsfind_by_sqlandconnection.executewith interpolated stringsLIKEclauses, where the pattern is bound but wildcard characters still match more than intended
For identifiers, use an allow list. If a user can sort by name or created_at, map the incoming word to one of those two constants and ignore anything else. The OWASP SQL Injection Prevention Cheat Sheet makes the same point: when a value cannot be bound, validate it against a known list, because bind variables do not work for table or column names.
Is an ORM enough?
No. An ORM is a safer default, not a guarantee. Every ORM has an escape hatch to raw SQL, and the injection reports in Rails history come from those hatches and from interpolation inside otherwise normal calls. Review the escape hatches first.
Least privilege helps as a second layer. OWASP lists it alongside parameterized queries: the application's database account should not own the schema or read tables it never uses, so a successful injection reaches less.
What about stored data and second-order injection?
Injection does not always arrive in the current request. A value saved safely last week, such as a username containing a quote, can be read back and pasted into a raw query by a different code path. The second query is just as vulnerable as a first-pass one. The fix is the same: every query that touches data, wherever the data came from, uses bound values. Do not label some columns as trusted because the application wrote them.
How to check or apply this
- Search the code for interpolation inside query strings:
where(",order(",find_by_sql,execute(. - Replace string conditions with hash conditions or placeholders.
- Put every user-controlled sort or column choice behind an allow list.
- Add a static analysis rule or code review checklist item for raw SQL fragments.
- Run the application's database role with the minimum grants it needs.
- Write a test that sends a quote character and a known payload to each search and filter endpoint and asserts the response is an empty result or a validation error, not a server error.
Dependencies matter too. Old gems with injection bugs have the same impact as your own code. Safeguard's dependency scanning can flag known vulnerable packages, and reachability analysis helps show which ones your code actually calls.
Quick check
Is where("id = #{params[:id]}") safe if the id is a number?
Not unless you force the type. Params arrive as strings. Use where(id: params[:id]), which Rails casts and binds, instead of interpolating.
Can I bind a column name with a question mark placeholder?
No. Placeholders bind values only. Validate column names against an allow list in your code.
Sources
Last reviewed: 18 October 2026.