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.
Scanned 9/6/2026
Install to Claude Code
npx -y skills add PranavNagrecha/AwesomeSalesforceSkills --skill apex-dynamic-soql-binding-safety --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Apex Dynamic Soql Binding Safety?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/pranavnagrecha-apex-dynamic-soql-binding-safety)More formats (shields.io, HTML) on the badges page.
---
name: apex-dynamic-soql-binding-safety
description: "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."
category: apex
salesforce-version: "Spring '25+"
well-architected-pillars:
- Security
triggers:
- "how to safely concatenate user input into dynamic soql"
- "database.query bind variable colon prefix not in scope error"
- "database.querywithbinds map string object accesslevel example"
- "soql injection apex string.escapesinglequotes is it enough"
- "dynamic field list reporting tool whitelist field names apex"
- "with user_mode dynamic soql fls runtime enforcement"
tags:
- apex
- soql
- injection
- dynamic
- binding
- security
inputs:
- User-supplied filter values, field names, sort columns, or limits
- The dynamic SOQL string being built
- Required AccessLevel (USER_MODE vs SYSTEM_MODE) and FLS posture
- Schema of the queried sObject (for field-name allowlisting)
outputs:
- Database.queryWithBinds call site with explicit bind map
- Field-name and ORDER BY direction allowlists
- SOQL-injection negative tests asserting safe behavior on attack payloads
- Documented USER_MODE / SYSTEM_MODE choice with justification
dependencies: []
version: 1.0.1
author: Pranav Nagrecha
updated: 2026-08-14
---
# 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 `@AuraEnabled` argument, 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_MODE` enforces FLS and object permissions at runtime; `AccessLevel.SYSTEM_MODE` bypasses them. Default to USER_MODE; document any SYSTEM_MODE call.
- **Common wrong assumption:** "I called `String.escapeSingleQuotes` so 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:
```apex
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 throw `System.QueryException: Variable does not exist`.
- `IN :collection` requires `collection` to be a `List` or `Set`.
- Works for primitives, sObjects, IDs, and collections of those.
### `Database.queryWithBinds` (API 55+) — the modern explicit-bind variant
```apex
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:
```apex
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:
```apex
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: [`agents/_shared/AGENT_CONTRACT.md`](../../../agents/_shared/AGENT_CONTRACT.md) § *Apex security idiom by API version*.
The access-mode clause protects what the query CAN ASK FOR; it does not protect HOW the query is built. A dynamic query with `WITH USER_MODE` is still injectable if you concatenate user input into the WHERE clause — `WITH USER_MODE` just stops the attacker from selecting fields they cannot see.
Prefer `Database.queryWithBinds(..., AccessLevel.USER_MODE)` over an in-string `WITH USER_MODE`; the AccessLevel argument does the same job and stays out of the parsed string.
---
## Common Patterns
### Pattern: `queryWithBinds` for user-supplied values
**When to use:** Any time the WHERE clause contains a value derived from user input.
**How it works:**
```apex
public List<Contact> searchContacts(String term, Integer maxRows) {
Map<String, Object> binds = new Map<String, Object>{
'term' => '%' + String.escapeSingleQuotes(term) + '%',
'cap' => Math.min(maxRows, 200)
};
return Database.queryWithBinds(
'SELECT Id, Name FROM Contact WHERE Name LIKE :term LIMIT :cap',
binds,
AccessLevel.USER_MODE
);
}
```
`String.escapeSingleQuotes` here is defense-in-depth; the bind itself prevents injection.
**Why not the alternative:** Inline concatenation of `term` into the query string is injectable. `Database.query(soql)` would also work, but `queryWithBinds` makes the bind contract explicit and survives refactoring.
### Pattern: Field-name allowlist for dynamic field lists
**When to use:** Reporting tool, list view, or export where the user picks which columns appear.
**How it works:**
```apex
public List<SObject> runReport(String objectApi, List<String> requestedFields) {
Schema.SObjectType sot = Schema.getGlobalDescribe().get(objectApi);
if (sot == null) throw new IllegalArgumentException('Unknown object');
Map<String, Schema.SObjectField> fmap = sot.getDescribe().fields.getMap();
List<String> safeFields = new List<String>();
for (String f : requestedFields) {
Schema.SObjectField sf = fmap.get(f.toLowerCase());
if (sf == null) throw new IllegalArgumentException('Unknown field: ' + f);
safeFields.add(sf.getDescribe().getName());
}
String soql = 'SELECT ' + String.join(safeFields, ', ') +
' FROM ' + sot.getDescribe().getName() +
' WITH USER_MODE LIMIT 200';
return Database.queryWithBinds(soql, new Map<String, Object>(), AccessLevel.USER_MODE);
}
```
**Why not the alternative:** Concatenating `requestedFields` directly lets an attacker inject `Id), (SELECT Username FROM User` or similar. The lookup-then-canonical-name pattern guarantees only real, current fields reach the parser.
### Pattern: ORDER BY direction whitelist
**When to use:** Sortable list views or table headers backed by Apex.
**How it works:**
```apex
String safeField = canonicalFieldName(sortField); // allowlist as above
String dir = 'DESC'.equalsIgnoreCase(sortDir) ? 'DESC' : 'ASC';
String soql = 'SELECT Id, Name FROM Account ORDER BY ' + safeField + ' ' + dir +
' LIMIT :pageSize';
Map<String, Object> binds = new Map<String, Object>{ 'pageSize' => pageSize };
return Database.queryWithBinds(soql, binds, AccessLevel.USER_MODE);
```
**Why not the alternative:** A user-supplied direction string can carry `, (SELECT ...)` or `NULLS FIRST OFFSET 9999`. Two-value choice removes the entire class.
---
## Decision Guidance
| Situation | Recommended Approach | Reason |
|---|---|---|
| User-supplied filter value | `Database.queryWithBinds` + bind map | Parameterizes value; explicit AccessLevel |
| Single-method dynamic query, value only | `Database.query` with `:localVar` | Simplest; bind in lexical scope |
| User-supplied field or object name | Schema describe lookup + canonical name | Bind variables cannot bind identifiers |
| User-supplied sort direction | Two-value `ASC`/`DESC` ternary | Allowlist of two; nothing else can sneak in |
| User-supplied LIMIT/OFFSET | Parse to `Integer` then bind | Type system rejects non-numeric input |
| Need FLS at runtime on dynamic query | `AccessLevel.USER_MODE` on `queryWithBinds` | Single-knob enforcement, cleaner than `WITH USER_MODE` in string |
| Background job that legitimately needs SYSTEM_MODE | `AccessLevel.SYSTEM_MODE` + comment block explaining why | Documented exception, reviewable |
---
## Recommended Workflow
1. **Inventory every variable** that flows into the query string. Mark each as VALUE or IDENTIFIER.
2. **Bind every VALUE** via `Database.queryWithBinds` (preferred) or `Database.query` with `:varName`. Never concatenate a value.
3. **Allowlist every IDENTIFIER** — field names via `Schema.SObjectType.X.fields.getMap()` (lowercase the input, use the canonical name from `getDescribe().getName()`); sObject names via `Schema.getGlobalDescribe()`; ORDER BY direction via two-value ternary; LIMIT/OFFSET via `Integer` parsing.
4. **Choose AccessLevel deliberately** — default `USER_MODE`; document `SYSTEM_MODE` with a justification comment.
5. **Write a SOQL-injection negative test** — feed `' OR Id != null --` and `; DELETE FROM Account` style payloads; assert the call returns expected rows or throws `QueryException`, never extra rows.
6. **Run the skill checker** — `python3 skills/apex/apex-dynamic-soql-binding-safety/scripts/check_apex_dynamic_soql_binding_safety.py --manifest-dir path/to/classes`.
7. **Code review pass** — verify no `Database.query('... ' + ` patterns escaped the audit, especially ones hidden behind helper methods.
---
## Review Checklist
Run through these before marking work in this area complete:
- [ ] No string concatenation of user input inside any `Database.query(...)` call
- [ ] Every value-shaped fragment uses a bind variable (`:name` or bind map)
- [ ] Every identifier-shaped fragment is allowlisted via `Schema` describe
- [ ] ORDER BY direction is the result of a two-value ternary
- [ ] LIMIT and OFFSET are typed `Integer`, not `String`
- [ ] `AccessLevel.USER_MODE` used unless SYSTEM_MODE is explicitly justified in a comment
- [ ] Negative test exists for at least one classic SOQL-injection payload
- [ ] `String.escapeSingleQuotes` is treated as defense-in-depth, never the sole control
---
## Salesforce-Specific Gotchas
Non-obvious platform behaviors that cause real production problems:
1. **`Database.query` bind scope is lexical, not class-wide.** `:term` resolves against the executing method's local variables, not a class field with the same name in some cases — and definitely not a variable in the caller. Refactoring a query into a helper method routinely produces `Variable does not exist` at runtime. Use `Database.queryWithBinds` to pass binds explicitly.
2. **`Database.queryWithBinds` requires `Map<String, Object>`.** A `Map<String, String>` will compile but throw at runtime when the platform tries to bind a non-string value (Integer for LIMIT, List for IN). Always declare the map as `Map<String, Object>`.
3. **`AccessLevel.SYSTEM_MODE` silently bypasses FLS and CRUD.** It is the right choice for some background jobs but is a security regression in user-facing controllers. Default to `USER_MODE`; require a code comment for any `SYSTEM_MODE` call site.
---
## Output Artifacts
| Artifact | Description |
|---|---|
| `queryWithBinds` call site | The canonical safe call: query string + bind map + AccessLevel |
| Field-name allowlist helper | Method that maps user input → canonical field name via Schema |
| ORDER BY direction whitelist | Two-value ternary collapsed to `ASC`/`DESC` |
| Injection negative test | Test method asserting attack payloads return expected rows or throw |
---
## Related Skills
- `apex/apex-soql-fundamentals` — static SOQL, when dynamic SOQL is unnecessary
- `apex/apex-stripinaccessible-and-fls-enforcement` — post-query FLS scrubbing for results
- `apex/apex-soql-injection-prevention` — broader injection threat model
- `security/soql-security` — security-pillar guidance for SOQL access control
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!