Home / apex / apex-dynamic-soql-binding-safety
apex-dynamic-soql-binding-safety
Safe construction of dynamic SOQL — Database.query bind variables (:varName, API 60+ semantics), Database.queryWithBinds(query, Map<String,Object>, AccessLevel) (API 55+), field-name allowlisting, ORDER BY direction whitelist, LIMIT/OFFSET typing, and WITH USER_MODE interaction (WITH SECURITY_ENFORCED was removed in API 67.0). NOT for reviewing a query for injection or CRUD/FLS generally — use apex/soql-security. NOT for Schema.describe and runtime type inspection — use apex/dynamic-apex.
Open the skill folder on GitHub
Apex Dynamic SOQL Binding Safety
Activate when ANY part of a SOQL query string is built at runtime from variables — filter values, field names, sort columns, limits, or the WHERE clause itself. Dynamic SOQL is necessary for reporting tools, configurable list views, and search; it is also the single most common SOQL-injection vector in Apex. This skill establishes the safe construction pattern for every value, identifier, and clause that flows into Database.query or Database.queryWithBinds.
Before Starting
Gather this context before working on anything in this domain:
- Where does each fragment of the query string come from? Trace every concatenation back to its source — Visualforce parameter, LWC
@AuraEnabledargument, REST request, custom metadata, or hard-coded literal. Hard-coded literals are safe; everything else needs binding or allowlisting. - Is the dynamic part a value or an identifier? Bind variables protect VALUES. They do NOT protect field names, sObject names, ORDER BY columns, ASC/DESC direction, or LIMIT keywords — those require allowlisting.
- What AccessLevel is correct?
AccessLevel.USER_MODEenforces FLS and object permissions at runtime;AccessLevel.SYSTEM_MODEbypasses them. Default to USER_MODE; document any SYSTEM_MODE call. - Common wrong assumption: "I called
String.escapeSingleQuotesso I'm safe." Escaping single quotes prevents quote-breakout on string literals, but does nothing for field-name injection, ORDER BY injection, or LIMIT injection.
Core Concepts
Why string concatenation is SOQL injection
Consider:
String name = ApexPages.currentPage().getParameters().get('name');
String soql = 'SELECT Id, Name FROM Account WHERE Name = \'' + name + '\'';
List<Account> accs = Database.query(soql);
If a user supplies name = ' OR Id != null --, the resulting query becomes SELECT Id, Name FROM Account WHERE Name = '' OR Id != null --'. Every Account in the org is returned, FLS-aware or not. The -- swallows the trailing quote so the parser is happy. Variants exist for sub-selects, UNION-style trickery via semi-joins, and ORDER BY exfiltration.
String.escapeSingleQuotes(input) defangs the quote-breakout vector but provides ZERO protection if the attacker controls a field name, ORDER BY column, or LIMIT integer represented as a string. It is necessary but not sufficient.
Bind variables in Database.query
When the query string contains a colon-prefixed token like :accountName, Database.query(soql) resolves it against the local scope of the call site. The platform parameterizes the value — the user input never participates in SOQL parsing. Caveats:
- The variable MUST be in lexical scope at the
Database.query(...)call. A variable defined in the calling class but not in the executing method will throwSystem.QueryException: Variable does not exist. IN :collectionrequirescollectionto be aListorSet.- Works for primitives, sObjects, IDs, and collections of those.
Database.queryWithBinds (API 55+) — the modern explicit-bind variant
Map<String, Object> binds = new Map<String, Object>{
'searchName' => '%' + sanitizedTerm + '%',
'minAmount' => userMinAmount
};
List<Opportunity> opps = Database.queryWithBinds(
'SELECT Id, Name, Amount FROM Opportunity ' +
'WHERE Name LIKE :searchName AND Amount >= :minAmount',
binds,
AccessLevel.USER_MODE
);
Use queryWithBinds whenever a query is constructed across methods or service layers — bind values travel as a map, not a hidden lexical-scope contract. Always pair with AccessLevel.USER_MODE unless SYSTEM_MODE is documented.
Allowlisting field names, sObject names, and clauses
Bind variables cannot bind identifiers. For fields:
Map<String, Schema.SObjectField> fieldMap =
Schema.SObjectType.Account.fields.getMap();
String requested = userInput.toLowerCase();
if (!fieldMap.containsKey(requested)) {
throw new IllegalArgumentException('Unknown field: ' + userInput);
}
String fieldName = fieldMap.get(requested).getDescribe().getName();
Use the canonical name returned by getDescribe().getName() (not the raw user input) when concatenating into the query. Apply analogous patterns for sObject names (Schema.getGlobalDescribe()).
For ORDER BY direction, hard-code an allowlist:
String dir = ('DESC'.equalsIgnoreCase(userDir)) ? 'DESC' : 'ASC';
For LIMIT and OFFSET, parse to Integer first; never let a String reach the query. Apex Integer.valueOf throws on non-numeric input — that is the desired failure mode.
Dynamic SOQL with WITH USER_MODE
WITH USER_MODE (GA at API 57.0, Spring '23) is the access-mode clause to put inside a dynamic query string, and it is enforced at parse time on the field list. WITH SECURITY_ENFORCED is the older, weaker equivalent: it was removed in API 67.0 (Summer '26) and a class pinned at 67.0 or above fails to compile with WITH SECURITY_ENFORCED is no longer supported, use WITH USER_MODE instead. Because the clause lives inside a string here, a dynamic query hides that compile error until the string is parsed at run time — so it surfaces as a QueryException in production rather than a build failure. Never build WITH SECURITY_ENFORCED into a query string. Per-version detail: