In one sentence: bind every value, allow list every identifier. Values go into the query through bind variables so they are never parsed as SOQL, and object or field names, which cannot be bound, get checked against describe information before they are concatenated. String.escapeSingleQuotes is a fallback for the cases you cannot bind, not the primary defence, and it protects quoted literals only.
Why dynamic SOQL is a risk at all
Static SOQL in square brackets is compiled. Field names are checked, bind variables are handled by the platform, and there is no string to tamper with. The moment you build a query as text, whatever a user typed becomes part of the query language rather than part of the data.
The classic injection ends the literal and adds a clause:
// Input: x' OR Name != '
String soql = 'SELECT Id FROM Account WHERE Name = \'' + input + '\'';
// becomes: WHERE Name = 'x' OR Name != ''
That returns every account in the org. With a filter over a case comment or a document body, the same trick reads data the user has no business seeing.
Bind variables, the proper way
Database.query() can see local variables in scope and resolve :name against them, but the scoping rules are subtle and break as soon as the query string is built in another method. Database.queryWithBinds removes the guesswork by taking an explicit map.
Map<String, Object> binds = new Map<String, Object>{
'industry' => userIndustry,
'minRevenue' => threshold,
'ids' => accountIds
};
String soql = 'SELECT Id, Name FROM Account ' +
'WHERE Industry = :industry ' +
'AND AnnualRevenue > :minRevenue ' +
'AND Id IN :ids';
List<Account> accounts = Database.queryWithBinds(
soql, binds, AccessLevel.USER_MODE
);
A bound value is passed to the query engine as data. It cannot close a literal, add a clause or change the object being queried, whatever it contains. There is no escaping to remember and no way to forget it on one branch of an if statement.
The same treatment exists for the other two entry points: Database.getQueryLocatorWithBinds for batch start methods, and Database.countQueryWithBinds.
Note the third argument. AccessLevel.USER_MODE enforces object and field permissions plus sharing for the running user. WITH SECURITY_ENFORCED is no longer supported, so WITH USER_MODE in the query string or the AccessLevel parameter is where that enforcement now lives.
The parts you cannot bind
Bind variables work for values. They do not work for object names, field names, the ORDER BY field, or the LIMIT when it comes from input. Those have to be concatenated, so they have to be validated first.
Validate against describe rather than against a regular expression. A regex tells you the string looks like a field name. Describe tells you it is one.
public static String safeField(SObjectType objType, String fieldName) {
Map<String, Schema.SObjectField> fields =
objType.getDescribe().fields.getMap();
Schema.SObjectField f = fields.get(fieldName.toLowerCase());
if (f == null) {
throw new QueryException('Unknown field: ' + fieldName);
}
if (!f.getDescribe().isAccessible()) {
throw new QueryException('No access to ' + fieldName);
}
return f.getDescribe().getName();
}
Two things make this better than a check. It returns the canonical API name from describe, so the query is built from platform data rather than from user text. And it folds the field level security check into the same place, which means a user cannot name a field they are not allowed to read.
For object names, the equivalent is a hardcoded allow list or a Type.forName lookup followed by an isAccessible() check. If a feature only ever queries four objects, an explicit Set<String> of those four is honest and takes ten seconds to review.
LIMIT from input should be parsed to an Integer and clamped. Integer.valueOf throws on anything that is not a number, which is the validation.
Where escapeSingleQuotes still fits
Use it when you have a value inside a LIKE pattern that you are assembling with wildcards, or in older code you are not rewriting today.
String term = '%' + String.escapeSingleQuotes(input) + '%';
Map<String, Object> binds = new Map<String, Object>{ 'term' => term };
Even there, the cleaner move is to build the pattern in Apex and bind the whole thing, as above. Then the escaping is belt and braces rather than the only thing standing between you and a data leak.
Frequently asked
Do I still need dynamic SOQL at all? Less than people think. It earns its place when the field list genuinely varies, for field set driven UIs, configurable list views and generic selectors. If the query shape is fixed and only the values change, use static SOQL and get compile time field checking for free.
Does queryWithBinds count differently against limits? No. It is one of your 100 synchronous queries and the rows count toward the same 50,000.
Can a bind variable hold a collection? Yes. Put a Set or List in the map and use IN :key. The platform handles the serialisation.
What about SOSL? Same exposure, same remedy. Search.queryWithBinds takes a bind map and an AccessLevel in the same shape as the SOQL version. Bind the search term rather than concatenating it.
