Salesforce Apex Security Pitfalls and How to Avoid Them


Salesforce's Apex is a powerful server-side language, but its security model is subtle enough that even experienced developers often misconfigure it. This could lead to data exposure, from sensitive fields to full database dumps — sometimes accessible to unauthenticated guest users.
This post walks through three categories of mistakes: sharing-mode misconceptions, insufficient access enforcement, and SOQL injection patterns, including some that survive common sanitization attempts.
Before getting into the vulnerabilities, it's worth being precise about where Apex becomes externally reachable.
Two annotations are particularly relevant from an attack surface perspective:
- @RemoteAction exposes an Apex method to JavaScript via Visualforce.
- @AuraEnabled exposes a method to Lightning components (Aura and LWC) and, critically, to Experience Cloud sites.
Those become even more dangerous when they can be invoked by guest users - unauthenticated visitors to an Experience Cloud site or a Visualforce site.
Note that there are other ways to expose Apex methods, such as @HttpGet or @HttpPost. Salesforce allows guest user access to @AuraEnabled methods if the class is exposed through a guest user profile or permission sets assigned to the guest user.
Sharing Modes and the FLS/OLS Blind Spot
Apex classes can be declared with one of three sharing keywords:
public with sharing class MyClass { }
public without sharing class MyClass { }
public inherited sharing class MyClass { }
with sharing enforces the running user's sharing rules — meaning Apex will only return records the user has been granted access to through ownership, organization-wide defaults, role hierarchy, sharing rules, or manual shares. without sharing strips that record-level filter entirely, returning all records regardless of who owns them or what sharing rules say. inherited sharing defers to the sharing context of the calling class.
Those sharing modes are often confused with another important concept in Apex: User-Mode vs System-Mode. Developers add with sharing and believe their method is now secure. It isn't.
The sharing keyword controls record-level security only. It says nothing about whether the running user has permission to read a given object or field. Object-Level Security (OLS) and Field-Level Security (FLS) are enforced in User-Mode, but not in System-Mode. When Apex is invoked as a remote action or using Aura, it runs in System-Mode, meaning it ignores them entirely unless you explicitly enforce them in code. This is true regardless of whether you use with sharing or without sharing.
Consider this class:
public with sharing class AccountService {
@AuraEnabled
public static List<Account> getAccounts() {
return [SELECT Id, Name, AnnualRevenue, TaxId__c FROM Account];
}
}
with sharing ensures the query only returns accounts the running user can see. But if that user's profile has FLS restrictions on AnnualRevenue or TaxId__c, those fields are returned anyway. The query executes in system mode with respect to field and object access - Salesforce does not strip restricted fields automatically. Even if the user’s profile or permission sets do not allow access to Account altogether, but the Account records are allowed (for example, because of Organization-Wide Defaults) — the Apex code will retrieve and return the records.
without sharing takes this further. On top of ignoring FLS and OLS, it also ignores record-level restrictions — the query returns every Account in the org. It is the most permissive and most dangerous mode. When “without sharing” is used, the developers must justify it and must list the potential threats and how they address them.
The correct mental model:
OLS and FLS require explicit programmatic enforcement in all cases.
Enforcing Field and Object Access
WITH USER_MODE vs. WITH SECURITY_ENFORCED
Salesforce provides two SOQL clauses for enforcing the running user's permissions at query time.
WITH SECURITY_ENFORCED was the original approach:
List<Contact> contacts = [
SELECT Id, Name, Email, SSN__c
FROM Contact
WHERE AccountId = :accountId
WITH SECURITY_ENFORCED
];
It throws a System.QueryException if the running user lacks FLS access to any field in the SELECT clause. This, however, doesn’t cover other clauses: the WHERE, ORDER BY, GROUP BY, and HAVING clauses are not checked. A field referenced in a WHERE condition can be filtered on even if the user has no FLS access to it — leaking information about field values through the filter itself. These oversights were demonstrated to be exploitable by Reco researchers who were able to use “blind-injection” techniques to exfiltrate sensitive data. Additionally, it also handles polymorphic fields (e.g., What.Name on Task) inconsistently and can throw unexpected errors in those cases.
In API v55.0 (Summer 2022) Salesforce released a replacement: WITH USER_MODE
List<Contact> contacts = [
SELECT Id, Name, Email, SSN__c
FROM Contact
WHERE AccountId = :accountId
WITH USER_MODE
];
WITH USER_MODE enforces the running user's permissions across all clauses — SELECT, WHERE, ORDER BY, everything. It also enforces OLS (the user must have read access to the Contact object itself) and handles polymorphic fields correctly. If any field or object permission is violated, the query throws rather than silently returning filtered data.
Reco experts recommend scanning your code base and replacing all occurrences of WITH SECURITY_ENFORCED with WITH USER_MODE.
stripInaccessible
For cases requiring more granular control — particularly before DML or when combining results from multiple queries — use stripInaccessible. Let’s look at an example from the official documentation of Salesforce:
List<Account> accountsWithContacts =
[SELECT Id, Name, Phone,
(SELECT Id, LastName, Phone FROM Account.Contacts)
FROM Account];
// Strip fields that are not readable
SObjectAccessDecision decision = Security.stripInaccessible(
AccessType.READABLE,
accountsWithContacts);
// Print stripped records
for (Integer i = 0; i < accountsWithContacts.size(); i++)
{
System.debug('Insecure record access: '+accountsWithContacts[i]);
System.debug('Secure record access: '+decision.getRecords()[i]);
}
stripInaccessible removes fields the running user can't access from the SObject list before it's returned or written, including across child relationship subquery results.
SOQL Injection
Bind Variables and escapeSingleQuotes
Static SOQL with bind variables is safe — Salesforce handles parameterization internally:
String name = userInput;
List<Account> results = [SELECT Id, Name FROM Account WHERE Name = :name];
The risk appears with Database.query(), which accepts a dynamically constructed string:
String query = 'SELECT Id, Name FROM Account WHERE Name = \'' + userInput + '\'';
List<Account> results = Database.query(query);
If userInput is ' OR Id != ', the resulting query becomes WHERE Name = '' OR Id != '' — returning all records. escapeSingleQuotes is the perfect solution for that. It was introduced by Salesforce specifically to solve these vulnerabilities. But it is not a silver bullet.
The Mistake: escapeSingleQuotes on Dynamic Field Names
This is a common mistake that Reco researchers have seen repeatedly in real organizations, from small companies to Fortune 100 as well as government agencies. Developers are applying escapeSingleQuotes to user-controlled field names rather than field values.
Consider the following scenario:
A customer-facing support portal where authenticated customers can view their Account details. The UI component passes in the Account ID and a list of fields to display, allowing different portal pages to reuse the same Apex method with different field sets — one page shows basic info, another shows billing details, and so on. The developer, security-conscious enough to apply escapeSingleQuotes to both parameters, believed the code was safe from injection attacks.
public without sharing class AccountPortalController {
@AuraEnabled
public static List<Account> getAccountDetails(String accountId, String fields) {
String safeAccountId = String.escapeSingleQuotes(accountId);
String safeFields = String.escapeSingleQuotes(fields);
String query = 'SELECT ' + safeFields +
' FROM Account' +
' WHERE Id = \'' + safeAccountId + '\'';
return Database.query(query);
}
}
The developer has escaped both inputs, but the code is still vulnerable.
A valid Salesforce field name contains no single quotes. escapeSingleQuotes on a field name does not solve the problem — the attack surface isn't string delimiters, it's the structure of the query itself.
An attacker could invoke the function with the following parameters:
{
"accountId": Some account,
"fields": "(SELECT Id, Subject, Description FROM Cases)"
}
And receive case details of all of the cases under the account.
Reco researchers have identified those patterns in AI-generated code as well.
Proper Mitigation: Allowlisting and FLS Checks
escapeSingleQuotes is not a mitigation for dynamic field names. The correct approach combines an explicit allowlist, an FLS check, and WITH USER_MODE:
public without sharing class UserService {
@AuraEnabled
public static User getUser(String userId, String nameField) {
// Allowlist: only permit the three expected name fields
Set<String> allowedFields = new Set<String>{'Name', 'FirstName', 'LastName'};
if (!allowedFields.contains(nameField)) {
throw new AuraHandledException('Invalid field: ' + nameField);
}
// FLS check: verify the running user can read this field
Schema.SObjectField fieldToken = Schema.SObjectType.User.fields.getMap()
.get(nameField.toLowerCase());
if (fieldToken == null || !fieldToken.getDescribe().isAccessible()) {
throw new AuraHandledException('Field is not accessible: ' + nameField);
}
String query = 'SELECT Id, ' + nameField +
' FROM User WHERE Id = \'' +
String.escapeSingleQuotes(userId) + '\'' +
' WITH USER_MODE';
return (User) Database.query(query)[0];
}
}
The allowlist is the primary control — since we know exactly which three values are valid, anything outside that set is rejected immediately. The FLS check is a second layer for cases where an allowlist isn't practical and the valid field set is determined at runtime; it confirms both that the field exists and that the running user has read access to it. WITH USER_MODE is always present regardless, enforcing OLS and full-clause permission checks at the query level.
Note that escapeSingleQuotes is still correct on userId — that value genuinely ends up as a string literal in the query. The allowlist makes it entirely redundant for nameField: once a value has been validated against a hardcoded set, there is nothing left to sanitize.
Summary
Three corrections to common mental models:
- with sharing does not enforce OLS or FLS. The sharing keyword only controls record visibility. Field and object permissions are ignored by Apex in all sharing modes unless explicitly enforced — use WITH USER_MODE on queries and stripInaccessible where appropriate.
- Prefer WITH USER_MODE over WITH SECURITY_ENFORCED. The older clause leaves WHERE and other non-SELECT clauses unchecked, creating an information leakage path through filter conditions. WITH USER_MODE covers the full query.
- escapeSingleQuotes on a field name is not a mitigation.
These aren't edge cases — they're patterns that appear in production orgs, often in @AuraEnabled methods reachable by Experience Cloud guest users.

.png)


