Security & Sharing

SOQL Injection in Apex — Why Bind Variables Beat escapeSingleQuotes

By Rishabh Panwar · 4 min read · Intermediate

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 pointescapeSingleQuotesBind variableAllowlist
String value inside quotesWorksWorksNot needed
Number, date or Boolean valueFails, because there are no quotes to escapeWorksNot needed
Field name, object name or ORDER BYFailsCan’t bind identifiersRequired

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.

Frequently asked questions

How do I prevent SOQL injection in Apex?

Use bind variables for every value, either in static SOQL or in dynamic SOQL through Database.queryWithBinds. For anything that can't be bound, such as field names, object names or sort direction, check the input against an allowlist.

Is String.escapeSingleQuotes enough to stop SOQL injection?

No. It escapes only the single quote character, so it protects only values placed inside quotes. Numbers, dates, Booleans and identifiers have no quotes to escape, and an injected payload passes straight through.

Can you use bind variables in dynamic SOQL?

Yes. Database.query resolves bind variables that are in scope, and Database.queryWithBinds (API v57.0 and later) takes an explicit map of bind names to values plus an AccessLevel.

Does WITH USER_MODE prevent SOQL injection?

No. User mode enforces object permissions, field-level security and sharing. It doesn't stop an attacker from changing a query's structure, so you still need bind variables and allowlists.

Can I bind a field name or ORDER BY clause in SOQL?

No. Bind variables work only for values. Validate field names, object names and sort directions against a fixed set of allowed values before adding them to the query string.