Where SQL injection still lives in Rails
Active Record parameterises almost everything. The gaps are specific, well known, and exactly where people write raw fragments.
Active Record makes the common cases safe by default. where(email: params[:email])
binds a parameter; there is no string being assembled and nothing to escape.
Which is why the vulnerabilities that do exist cluster in a small number of well-defined escape hatches.
String interpolation into a condition
The canonical mistake is building a condition with interpolation instead of binding:
putting params[:name] directly inside the SQL fragment rather than passing it as a
parameter.
Use the array or hash form — where("name = ?", params[:name]) or where(name: ...) —
and the value is bound. Rails will warn about some of these, but the warning is not
universal and should not be your control.
Order, pluck, group and friends
Methods taking a SQL fragment are the second cluster. order is the notorious one,
because sortable columns are so often driven by a query parameter.
Rails does protect order against raw unrecognised input, but the protection is
narrower than people assume and can be worked around with enough determination. The
robust answer is not escaping — it is an allowlist. Map the user-supplied sort key to a
known column name, and reject anything not in the map. The set of sortable columns is
finite and you know it at development time.
The same applies to group, having, joins with a fragment, and select with
computed columns.
User input should never be a column name. If it needs to be, it should be a key into a map of column names you wrote.
LIKE, and the wildcards you did not mean
A search with LIKE "%#{query}%" is not SQL injection — the value is still bound — but
it has a related bug. % and _ are wildcards inside a LIKE pattern, so a search for
100% matches everything and a search for _ matches every single-character position.
sanitize_sql_like escapes them so the user's input is treated literally. Worth doing
for correctness even where there is no security impact, because "search for 100% returns
the whole table" is a real bug.
Raw execution
find_by_sql, connection.execute and friends do exactly what you tell them. They are
occasionally necessary. When they are, use bind parameters or sanitize_sql_array
rather than assembling the string yourself, and treat those call sites as the small
audited set they should be.
Making it a review problem, not a memory problem
Two things scale better than remembering.
Static analysis catches the common shapes. Brakeman flags interpolation into SQL fragments with high accuracy, and running it in CI turns this into a build failure rather than a code review someone might skip.
And strong parameters should never be bypassed. Most real incidents I have read about involve a parameter that was permitted for convenience and then flowed somewhere it should not have — the injection was the second step, not the first.
The defaults are good. The job is noticing the handful of places where you have stepped outside them, and being deliberate there.