During a code review you spot a dynamic query built from a search box, wrapped in String.escapeSingleQuotes(). The author says it’s safe. For that one field it probably is. Change the input to a number or a sort column, though, and the protection disappears. SOQL injection in Apex happens whenever user input becomes part of a query’s structure, and escaping handles only one narrow case. Binding every value and allowlisting everything else covers all of them, and the examples below show why.
Injection is a different problem from permissions, which the Apex user mode guide handles. For the full access model behind both, see the security model deep dive.
Why bind variables work
A bind variable tells the platform to treat the input purely as data. Whatever the user types, it can’t change the shape of the query. Binding also removes string concatenation, which removes a whole class of quoting bugs.
String.escapeSingleQuotes() does one thing: it escapes the ' character. That helps only when the value sits inside quotes in the query. For anything else there is nothing to escape, so the payload goes through untouched.
From weakest to strongest
Escaping (avoid as the main defence):
String name = String.escapeSingleQuotes(userInput);
String q = 'SELECT Id, Name FROM Account WHERE Name = \'' + name + '\'';
List<Account> accts = Database.query(q);
Static SOQL with a bind (the default choice):
List<Account> accts = [SELECT Id, Name FROM Account WHERE Name = :userInput];
Dynamic SOQL with a bind:
String name = userInput;
String q = 'SELECT Id, Name FROM Account WHERE Name = :name';
List<Account> accts = Database.query(q);
Dynamic SOQL with an explicit bind map and user mode:
Map<String, Object> binds = new Map<String, Object>{ 'n' => userInput };
List<Account> accts = Database.queryWithBinds(
'SELECT Id, Name FROM Account WHERE Name = :n',
binds,
AccessLevel.USER_MODE
);
Database.queryWithBinds arrived in Spring ‘23 (API v57.0). Because it takes an AccessLevel, you get bound values and user-mode enforcement in the same call.
Where escaping fails
| Injection point | escapeSingleQuotes | Bind variable | Allowlist |
|---|---|---|---|
| String value inside quotes | Works | Works | Not needed |
| Number, date or Boolean value | Fails, because there are no quotes to escape | Works | Not needed |
| Field name, object name or ORDER BY | Fails | Can’t bind identifiers | Required |
A numeric example
// userInput = '0 OR Name != null' (no quotes anywhere)
String q = 'SELECT Id FROM Account WHERE AnnualRevenue > '
+ String.escapeSingleQuotes(userInput);
// Resulting query:
// SELECT Id FROM Account WHERE AnnualRevenue > 0 OR Name != null
// The revenue filter no longer restricts anything.
Escaping had nothing to work on. Converting the input with Decimal.valueOf(userInput) and binding the result would have thrown on this payload, because the text isn’t a number, and the query structure could never change.
Allowlist anything that isn’t a value
Field names, object names and sort directions can’t be bound, so compare them against a fixed set before building the query:
Set<String> allowedSortFields = new Set<String>{ 'Name', 'CreatedDate', 'Industry' };
Set<String> allowedDirections = new Set<String>{ 'ASC', 'DESC' };
if (!allowedSortFields.contains(sortField) || !allowedDirections.contains(sortDir)) {
throw new AuraHandledException('Invalid sort option.');
}
String q = 'SELECT Id, Name FROM Account WHERE Industry = :industry'
+ ' ORDER BY ' + sortField + ' ' + sortDir;
List<Account> accts = Database.queryWithBinds(
q,
new Map<String, Object>{ 'industry' => industry },
AccessLevel.USER_MODE
);
Compare against exact values. Checking that input “looks like” a field name with a pattern is weaker than an explicit set.
Two common mistakes
Moving a class to API v67 doesn’t stop injection. User mode enforces permissions and sharing, but an attacker who can widen a filter still reaches every record the running user is allowed to see.
LIKE filters need their own care. A bound % matches everything, so a search box can return the whole table. Technically that isn’t injection, but it defeats the filter all the same. Escape % and _ in user input, or require a minimum search length.
Rule of thumb
Use static SOQL with :variable by default. If the query has to be dynamic, bind every value and allowlist every identifier. Treat escapeSingleQuotes as a last resort for legacy code you can’t restructure yet. Static queries are also easier to keep selective, which matters on big objects (see SOQL best practices for large data volumes).
If an interviewer asks how you’d secure a dynamic query, the rule above is your answer: bind the values, allowlist the identifiers, and use the numeric example to show why escaping alone falls short. Adding that user mode handles permissions and leaves injection untouched shows you know where each control stops.