SOQL injection vulnerability — Database.query with concatenated user input
Code that builds a SOQL query by concatenating user input is vulnerable to SOQL injection — a hostile user can extend the query to read or modify data they shouldn't. Apex's `String.escapeSingleQuotes` or bind variables solve this; some scanners flag this as a defect even before exploitation.
Also seen asSOQL injection·Database.query injection·string concatenation SOQL·concatenated user input
A new hire on the security team ran the Salesforce Code Analyzer against the org's repo as part of their first-week orientation. The report flagged a single critical finding: a SOQL injection vulnerability in LeadSearchController.cls. The controller is used on the partner portal landing page. It accepts a search string from a URL parameter and concatenates it into a Database.query call. The reviewer wrote up the finding and looped in the team lead before lunch.
What the analyzer detected
SOQL injection is the Salesforce equivalent of SQL injection. It happens when user-supplied input is concatenated directly into a SOQL query string and the resulting query is executed via Database.query or Database.queryWithBinds (using identifiers that should have been bound as values).
The attacker exploits the concatenation by submitting a value containing SOQL syntax. The platform parses the value as if it were part of the query author's code. The attacker can read records the author never intended to expose, bypass filters, or in some configurations, perform DML against records they shouldn't be able to touch.
The vulnerability sits at the boundary between trusted code (the Apex class) and untrusted input (anything coming from the request, including URL parameters, form fields, custom labels populated from external sources, or even data read out of records that another user controls). Whenever an untrusted value crosses that boundary into a query string via concatenation, the boundary is broken.
The broken example
A controller used on the partner portal's lead-search page:
public with sharing class LeadSearchController {
public List<Lead> results { get; set; }
public String searchTerm { get; set; }
public void doSearch() {
String query =
'SELECT Id, Name, Email, Company, Status ' +
'FROM Lead ' +
'WHERE Name LIKE \'%' + searchTerm + '%\' ' +
'AND IsConverted = false ' +
'LIMIT 100';
results = Database.query(query);
}
}
When a user types "Smith" into the search box, the query becomes:
SELECT Id, Name, Email, Company, Status FROM Lead
WHERE Name LIKE '%Smith%' AND IsConverted = false LIMIT 100
The query returns the leads matching the search. The user sees the results. Everything works.
When an attacker types ' OR Email LIKE '%@competitor.com%, the query becomes:
SELECT Id, Name, Email, Company, Status FROM Lead
WHERE Name LIKE '%' OR Email LIKE '%@competitor.com%%' AND IsConverted = false LIMIT 100
The OR short-circuits the intended Name LIKE filter. The query now returns every lead whose email contains @competitor.com. The attacker exfiltrates the entire competitor lead list through the search page.
More damaging variants exist. An attacker who knows about the Account object can include a subquery: ' AND Id IN (SELECT WhoId FROM Task WHERE Subject LIKE '%internal%') OR Name LIKE '%. Each iteration of the technique extracts a slightly different slice of data. The defender never sees a single "injection attempt"; they see a series of unusual searches that look like legitimate queries.
What the platform is checking when it parses
The Apex compiler treats Database.query(stringExpression) as a runtime construct. The string is parsed as SOQL at the moment the call runs. There is no static analysis on the contents of the string. Any SOQL syntax inside the string is accepted by the parser, including operators, subqueries, ordering clauses, and limit overrides.
The platform offers two safer alternatives. The first is static SOQL inside Apex source, where the compiler validates field names at compile time and the bind syntax :variableName substitutes values after parsing. The second is Database.queryWithBinds, where the query string still has identifiers but values are passed through a typed bind map. Both alternatives make injection structurally impossible for the bound values.
Static SOQL covers nearly every legitimate query. Dynamic SOQL is occasionally needed when the field list, the object, or the filter shape is determined at runtime. Even in dynamic SOQL, values should always be bound, never concatenated.
The fix, by likelihood
Use static SOQL with bind variables. For any query where the shape is known, write the SOQL inline and bind the values:
public void doSearch() {
String wildcardTerm = '%' + String.escapeSingleQuotes(searchTerm) + '%';
results = [
SELECT Id, Name, Email, Company, Status
FROM Lead
WHERE Name LIKE :wildcardTerm
AND IsConverted = false
LIMIT 100
];
}
The bind variable :wildcardTerm is substituted by the platform after the SOQL is parsed. The attacker's payload, if any, ends up as a literal string in the substituted value, not as part of the query syntax. String.escapeSingleQuotes strips any single quotes the attacker might still try to slip in, which is belt-and-suspenders for the LIKE wildcard.
Use Database.queryWithBinds for dynamic queries. When the query shape really must be dynamic (object name varies, field list varies), use the bind-map form:
public void doSearch(String objectApiName, String filterField) {
Set<String> allowedObjects = new Set<String>{ 'Lead', 'Contact' };
Set<String> allowedFields = new Set<String>{ 'Name', 'Email', 'Company' };
if (!allowedObjects.contains(objectApiName)) {
throw new IllegalArgumentException('Unsupported object');
}
if (!allowedFields.contains(filterField)) {
throw new IllegalArgumentException('Unsupported filter field');
}
String wildcardTerm = '%' + searchTerm + '%';
String query =
'SELECT Id, Name FROM ' + objectApiName +
' WHERE ' + filterField + ' LIKE :v LIMIT 100';
Map<String, Object> binds = new Map<String, Object>{ 'v' => wildcardTerm };
results = Database.queryWithBinds(query, binds, AccessLevel.USER_MODE);
}
The object name and field name are still concatenated, but both are checked against allowlists first. The value is bound. Injection at the value level is impossible. The allowlist makes identifier injection impossible too.
Validate input shape if the value type is constrained. A numeric filter accepts only digits. A picklist accepts only the platform's known picklist values. A date accepts only a valid date string. Validating the input shape before binding catches malformed inputs early and makes the security posture easier to reason about.
The fixed example
The full controller, rewritten:
public with sharing class LeadSearchController {
public List<Lead> results { get; set; }
public String searchTerm { get; set; }
public void doSearch() {
if (String.isBlank(searchTerm) || searchTerm.length() > 80) {
results = new List<Lead>();
return;
}
String wildcardTerm = '%' + String.escapeSingleQuotes(searchTerm) + '%';
results = [
SELECT Id, Name, Email, Company, Status
FROM Lead
WHERE Name LIKE :wildcardTerm
AND IsConverted = false
WITH USER_MODE
LIMIT 100
];
}
}
Four changes. Input length is bounded (longer searches are rejected; an attacker can't smuggle a payload through a length-unbounded field). Single quotes are escaped (defense in depth, even though the bind would handle them). The query uses :wildcardTerm as a bind variable. WITH USER_MODE enforces the running user's CRUD and FLS on the query, so an attacker who finds another vulnerability still can't see fields the running user lacks access to.
Why escapeSingleQuotes alone is not enough
String.escapeSingleQuotes is the most-cited mitigation for SOQL injection, and it's necessary but not sufficient.
The function escapes single-quote characters by prefixing them with a backslash. Inside a string literal in a SOQL query, the backslash-quote sequence is interpreted as a literal single quote, not as the end of the literal. This blocks the most common injection vector: closing the string and appending operators.
What it doesn't block is identifier-level injection. If the attacker controls a field name or object name being concatenated into the query, escapeSingleQuotes does nothing useful (there are no quotes to escape; the identifier is unquoted). Allowlisting identifiers is the only mitigation that works.
escapeSingleQuotes also doesn't block injection via numeric concatenation. If the query is WHERE Amount > and you concatenate a user-supplied integer without validation, the attacker can submit 1000 OR Name LIKE '%competitor%' and inject. The fix there is to parse the input as Decimal or Integer and use the typed value, not the raw string.
Use escapeSingleQuotes as a belt; use bind variables as the suspenders; use input validation as the actual fix.
The Salesforce Code Analyzer's role
Salesforce Code Analyzer (sf scanner) bundles PMD and other static analysis tools and ships rules specifically for Apex security. The relevant rules are ApexSOQLInjection and ApexInsecureEndpoint.
Run it in CI on every pull request:
sf scanner run --target force-app --category Security
Findings are reported as Apex.ApexSOQLInjection with the file and line number. Every finding is a candidate for the static-SOQL or bind-variable rewrite. False positives exist (the analyzer can't always determine that a value was already validated), but the false-positive rate is low compared to the real-finding rate in untouched legacy code.
For teams that don't run the analyzer in CI, run it manually before every major release. The first run on a legacy codebase often surfaces dozens of findings. Triaging them takes a few days; fixing them takes a sprint.
When sharing isn't enough
with sharing enforces record-level sharing rules, but it doesn't enforce field-level security or object-level CRUD. A SOQL injection in a with sharing class can still expose fields the running user shouldn't see, because the FLS check happens separately.
WITH USER_MODE on the SOQL query (or Database.queryWithBinds(query, binds, AccessLevel.USER_MODE)) enforces both sharing and FLS for the running user. Use it on every query in user-facing controllers, especially the ones that read sensitive fields.
Closely related security findings
| Finding | Mechanism |
|---|---|
| SOQL injection | User input concatenated into Database.query string |
| DML injection | User input concatenated into dynamic DML field assignments |
| Cross-site scripting (XSS) | User input rendered into Visualforce or Lightning markup without escaping |
| Insufficient access enforcement | Apex bypasses FLS by querying with elevated privileges |
All four sit at the trust boundary between user input and platform code. The cure for each is to enforce a clear boundary: validate before crossing, bind values, escape identifiers, enforce the user's permissions on every read.
Test patterns for injection resistance
A test that confirms the controller rejects an obvious injection payload:
@isTest
static void doSearch_rejectsOrInjection() {
LeadSearchController c = new LeadSearchController();
c.searchTerm = '\' OR Email LIKE \'%competitor.com%';
Test.startTest();
c.doSearch();
Test.stopTest();
System.assertEquals(0, c.results.size(),
'Search with injection payload should return no rows');
}
The test passes when the injection is neutralized. If a future refactor reintroduces concatenation, the test fails because the payload starts returning rows. Pin this kind of test to every controller that touches user input.
Real-world remediation scope
A typical legacy Salesforce org accumulates dozens of injection-prone queries over several years. The first audit usually surfaces controllers behind Visualforce pages, REST resources exposed via @RestResource, Apex methods called from Lightning Web Components, and email-handler classes that parse inbound subject lines.
Triage by exposure. Controllers reachable by anonymous portal users come first. Authenticated portal users second. Internal admin pages third. Each tier represents a different attacker model and a different remediation urgency.
For the highest-exposure tier, plan a focused sprint to rewrite every concatenation as a bind. The work is mechanical but tedious; budget two engineering days per controller. For the internal tier, batch the fixes into the next maintenance release.
Track every fix with a short security note in the commit message ("Removes SOQL injection in LeadSearchController.doSearch by switching to bind variable"). The notes give the security team a paper trail when they audit the org later.
Defensive habits
Treat every user-controlled value as untrusted, including values read from records that other users can write to.
Use static SOQL whenever possible. Use bind variables in dynamic SOQL. Use allowlists for identifiers.
Run Salesforce Code Analyzer in CI. Treat every Security category finding as a blocking issue until fixed.
Pair-review any code that does Database.query with string concatenation. The pair-review is the cheapest defense against the most expensive class of bug.
Further reading from Salesforce
- Apex Developer Guide: SOQL Injection
- Apex Developer Guide: Secure Coding Guidelines
- Apex Developer Guide: WITH USER_MODE Clause
- Trailhead: Apex Specialist Superbadge
- Salesforce Help: Salesforce Code Analyzer
Related dictionary terms
Share this fix
Related Security errors
ENTITY_FAILED_IFLASTMODIFIED_ON_UPDATE_CHECK: record has been modified since you retrieved it
SecurityYou sent an update with an `If-Unmodified-Since` header (or the API equivalent), and the record was modified by someone else after your read…
Insufficient Privileges. You do not have the level of access necessary to perform the operation you requested.
SecurityObject-level access — the user lacks Read/Edit/Create/Delete on the object itself, not on a particular row. Different from "You don't have a…
INSUFFICIENT_ACCESS_OR_READONLY: insufficient access rights on cross-reference id
SecurityThe running user can see the record they're trying to update, but doesn't have edit access to it (or to a record it depends on). The error m…
INVALID_CROSS_REFERENCE_KEY: invalid cross reference id
SecurityYou set a relationship field to an Id that the platform rejects — either the Id doesn't exist, points at the wrong object type, has been del…
invalid_grant: <reason>
SecuritySalesforce rejected the OAuth credentials in your token request. The `error_description` field tells you which part — refresh token revoked/…