Skip to main content

Apex SOQL injection: examples, fixes and how to find it

· 10 min read
Vulkro
Security research

Apex SOQL injection happens when a value the caller controls is pasted into a query string and the string is run with Database.query. The caller can then change what the query means: widen a filter, drop a condition, or sort and page through records the code never meant to return. SOQL cannot delete or update data the way SQL injection can, but reading the wrong records is usually the whole breach.

It is also one of the oldest findings in Salesforce security reviews, and it keeps coming back because dynamic queries are genuinely useful. Search screens, list views with a sort column, filter builders and admin tools all assemble queries at runtime.

This post shows the common vulnerable shapes, the fix for each (static queries, bind variables, Database.queryWithBinds, and allowlisting names through Schema describe), where String.escapeSingleQuotes stops helping, and how Vulkro for Salesforce traces the flow from the caller to the query.

What is SOQL injection in Apex?​

Salesforce's own SOQL injection guidance describes the attack: user input is placed into a dynamic SOQL string and changes the query's logic. MITRE files the general class as CWE-943, improper neutralization in data query logic.

Three things have to be true:

  1. A value comes from outside: an @AuraEnabled parameter called from a Lightning component, an Apex REST method, an @InvocableMethod input from a Flow or an agent action, a @RemoteAction call, or a Visualforce page parameter.
  2. That value is concatenated into a string.
  3. The string reaches a dynamic query: Database.query, Database.countQuery, Database.getQueryLocator or Search.query for SOSL.

Static SOQL in square brackets is not injectable. The compiler parses it, and every variable in it is a bind.

public with sharing class ContactSearch {
@AuraEnabled
public static List<Contact> search(String term) {
String q = 'SELECT Id, Name, Email FROM Contact WHERE Name LIKE \'%'
+ term + '%\'';
return Database.query(q);
}
}

A caller who sends x%' OR Name != ' turns the filter into Name LIKE '%x%' OR Name != '%', which matches every contact the class can see. Note that @AuraEnabled methods can be called directly through the Aura endpoint, not only from the component you wrote, so checks in the LWC do not count.

The fix needs no dynamic SOQL at all:

public with sharing class ContactSearch {
@AuraEnabled
public static List<Contact> search(String term) {
String pattern = '%' + term + '%';
return [SELECT Id, Name, Email FROM Contact
WHERE Name LIKE :pattern WITH USER_MODE];
}
}

:pattern is a bind variable. Whatever the caller sends is treated as data, never as query text. WITH USER_MODE also enforces the caller's object and field permissions on the read, which is a separate requirement covered in our CRUD and FLS guide.

How do you fix dynamic SOQL with bind variables?​

Sometimes the query really does have to be built at runtime, for example when filters are optional. You can still bind values. A bind in a dynamic string refers to a variable in scope when Database.query runs:

String q = 'SELECT Id, Name FROM Account WHERE Industry = :industry';
if (String.isNotBlank(city)) {
q += ' AND BillingCity = :city';
}
List<Account> rows = Database.query(q);

Only fixed text is concatenated. The values stay in industry and city.

Database.queryWithBinds makes this explicit. You pass the values in a map instead of relying on local variable names, and you can choose the access level in the same call:

Map<String, Object> binds = new Map<String, Object>{
'industry' => industry,
'cities' => cities // a List<String>, bound as an IN list
};
String q = 'SELECT Id, Name FROM Account WHERE Industry = :industry';
if (!cities.isEmpty()) {
q += ' AND BillingCity IN :cities';
}
List<Account> rows = Database.queryWithBinds(q, binds, AccessLevel.USER_MODE);

This is the cleanest way to build a filter from several optional inputs, and it removes the temptation to join a list of values into a quoted string by hand.

Where does String.escapeSingleQuotes stop helping?​

String.escapeSingleQuotes adds a backslash before each single quote. That protects a value that sits inside a quoted string literal, and nothing else. Its limits:

  • Unquoted positions. A field name, an object name, ORDER BY, LIMIT, OFFSET, a number or a date literal has no quote to break out of. Escaping changes nothing there.
  • LIKE wildcards. Escaping does not touch % or _. A caller who sends % to an escaped LIKE '%...%' search still matches everything. That is not injection in the strict sense, but on a search that returns more than a name it can be a data exposure.
  • One escaped piece among several. Escaping one value does not make the rest of the string safe.

We walked through the last two cases, and why tools get them wrong, in three Apex details scanners get wrong. The short rule: prefer binds, and use escaping only when a value must be inlined inside quotes.

Dynamic field and object names: allowlist through Schema describe​

Bind variables cannot replace a field or object name. A sortable table is the usual example:

// Vulnerable: sortField and direction are appended raw
String q = 'SELECT Id, Name, AnnualRevenue FROM Account ORDER BY '
+ sortField + ' ' + direction + ' LIMIT 50';

The fix is to accept only names you know, and to take the name you put in the query from the platform rather than from the caller:

public with sharing class AccountTable {
private static final Set<String> SORTABLE =
new Set<String>{ 'name', 'annualrevenue', 'createddate' };

@AuraEnabled
public static List<Account> page(String sortField, Boolean descending) {
String key = String.isBlank(sortField) ? 'name' : sortField.toLowerCase();
Schema.SObjectField field =
Schema.SObjectType.Account.fields.getMap().get(key);
if (!SORTABLE.contains(key) || field == null
|| !field.getDescribe().isSortable()) {
throw new AuraHandledException('Unsupported sort field');
}
String q = 'SELECT Id, Name, AnnualRevenue FROM Account ORDER BY '
+ field.getDescribe().getName()
+ (descending == true ? ' DESC' : ' ASC')
+ ' LIMIT 50';
return Database.queryWithBinds(q, new Map<String, Object>(),
AccessLevel.USER_MODE);
}
}

Three things make this safe. The explicit set limits which fields can be named at all. The describe lookup confirms the field exists and is sortable, and getName() returns the platform's own spelling, so nothing the caller typed reaches the string. The direction is a boolean mapped to one of two constants. The same pattern works for a dynamic object name with Schema.getGlobalDescribe().

A checklist for dynamic SOQL​

  • Use static SOQL in brackets wherever the shape of the query is fixed.
  • Bind every value: :name in dynamic strings, or Database.queryWithBinds with a map.
  • Allowlist every field, object, sort direction and keyword, and take the final name from Schema describe.
  • Treat escapeSingleQuotes as a last resort for quoted values only.
  • Enforce access on the read too: WITH USER_MODE or AccessLevel.USER_MODE, and with sharing on the class.
  • Remember who can call the method. An @AuraEnabled method granted to a site guest profile is callable by anyone on the internet.

How Vulkro for Salesforce traces Apex SOQL injection​

vulkro-sf scan runs a taint analysis over the Apex in your project. It treats parameters of @AuraEnabled, @RestResource HTTP methods, @InvocableMethod and @InvocableVariable inputs, global and webservice methods and @RemoteAction methods as untrusted, and follows them through assignments, helper methods and class fields to the query call.

When a value arrives at Database.query or another dynamic query without a bind, an allowlist or an escape that actually protects its position, the finding is apex-taint-soql-injection (High), and apex-taint-sosl-injection for SOSL. The finding card shows the flow hop by hop: the entry parameter, each assignment or call it passes through, and the query, each step with a file and line. It also says who can call the entry point, how the class runs (user mode, system mode with sharing, or without sharing) and the fix in Salesforce terms. When the value starts in a Lightning web component, lwc-apex-cross-language-taint joins the JavaScript call to the Apex flow, so the path starts in the browser.

Related checks cover the shapes that are not a proven flow:

  • apex-soql-injection-possible-dynamic-query: a non-literal, non-bind value concatenated into a slot that can carry a payload, including ORDER BY, where no source was traced.
  • apex-dyn-exec-soql: a runtime-built query string the injection check did not already report, listed as surface to review.
  • escape-single-quotes-call: an escaped value placed outside quotes.

Bound queries, schema-validated names and allowlisted values are recognised and stay quiet. When the engine does not report a sink you expected, vulkro-sf why-not <file:line> explains which gate held it back, or says plainly that it could not determine one. The full list is on the Apex detectors page, and the cross-method sources are described in the cross-method taint engine docs.

These are source checks. Whether an injectable method matters in your org depends on who holds access to the class, which is why the same finding is rated higher when a guest or community profile can reach it, as we argued in severity should depend on who can reach the code.

Where to see it​

Run vulkro-sf scan ./force-app in your SFDX project, or open the VS Code extension to see the flow on the line as you write it. If you are preparing a package for the AgentExchange (formerly AppExchange) Security Review, the readiness checklist covers injection alongside CRUD, FLS and sharing. For platform teams running many orgs, see Vulkro for Salesforce platform teams. To get set up, start here.

Frequently asked questions​

Is static SOQL ever vulnerable to injection?​

No. A query written in square brackets is parsed at compile time, and every Apex variable in it is a bind. It can still have other problems, such as missing field-level security or running without sharing, but the caller cannot change its structure.

Does String.escapeSingleQuotes prevent SOQL injection?​

Only for a value placed inside a quoted string literal. It does nothing for field names, object names, ORDER BY, LIMIT or numeric positions, and it does not escape the % and _ wildcards in a LIKE filter. Bind variables are the safer default.

When should I use Database.queryWithBinds?​

Use it whenever a dynamic query needs values from the caller, especially when filters are optional or a value is a list for an IN clause. The values travel in a map, the query text stays fixed, and you can pass AccessLevel.USER_MODE in the same call.

Can I bind a field name or a sort order?​

No. Bind variables carry values only. For names, check the input against an explicit allowlist, confirm it through Schema describe, and put the describe result into the query rather than the caller's string.

Sources​