Apex Security Anti-Patterns: The Code-Level Risks Hiding in Your Org

We’ve previously covered how vendors authenticate into your org–from session hijacking to the JWT Bearer flow–and how permission set overprivileging creates unnecessary risk once they’re inside. But there’s another layer worth examining: what happens when the Apex code itself doesn’t enforce security?
Apex runs in system mode by default. That means it bypasses object permissions, field-level security, and–if the class uses without sharing–record-level security too. This is by design, but it’s also the source of the most common security vulnerabilities in Salesforce orgs.
Whether you’re writing custom Apex or reviewing a vendor’s code, here are the anti-patterns you need to watch for.
Anti-Pattern #1: SOQL Without CRUD/FLS Enforcement
The most pervasive issue. By default, an Apex SOQL query returns all records and fields regardless of the running user’s permissions:
// Dangerous: runs in system mode, ignores CRUD and FLS
List<Contact> contacts = [SELECT Id, Name, SSN__c, Birthdate FROM Contact];
Even if the running user’s profile has no access to SSN__c, this query returns it anyway. The legacy fix was verbose and error-prone–manually checking Schema.sObjectType.Contact.isAccessible() and each field before querying.
The modern fix is WITH USER_MODE:
// Secure: enforces CRUD, FLS, and sharing rules at the query level
List<Contact> contacts = [SELECT Id, Name, SSN__c, Birthdate FROM Contact WITH USER_MODE];
If the running user doesn’t have access to SSN__c or the Contact object itself, Salesforce throws a System.QueryException. One keyword replaces dozens of lines of manual checks.
Anti-Pattern #2: SOQL Injection
When Apex builds SOQL queries using string concatenation with user input, it’s vulnerable to injection–just like SQL injection in traditional web apps:
// Dangerous: user input directly concatenated into SOQL
String query = 'SELECT Id, Name FROM Account WHERE Name = \'' + userInput + '\'';
List<Account> results = Database.query(query);
An attacker can pass ' OR Name != ' as input, turning the query into one that returns every Account in the org.
The fix is to use bind variables whenever possible:
// Secure: bind variable prevents injection
List<Account> results = [SELECT Id, Name FROM Account WHERE Name = :userInput];
When dynamic SOQL is unavoidable, always use String.escapeSingleQuotes():
String query = 'SELECT Id, Name FROM Account WHERE Name = \''
+ String.escapeSingleQuotes(userInput) + '\'';
Anti-Pattern #3: Unnecessary without sharing
Apex classes declared without sharing bypass all record-level security–OWDs, sharing rules, role hierarchy, and manual shares:
// Dangerous: bypasses all record-level security
public without sharing class AccountService {
public List<Account> getAllAccounts() {
return [SELECT Id, Name FROM Account];
}
}
This is sometimes necessary for system-level operations (e.g., a batch job that needs to process all records). But it should be the exception, not the default.
If a class doesn’t specify a sharing declaration at all, it inherits the sharing context from the calling class–which can be unpredictable. Always explicitly declare sharing:
// Secure: explicitly enforces record-level security
public with sharing class AccountService {
public List<Account> getAllAccounts() {
return [SELECT Id, Name FROM Account WITH USER_MODE];
}
}
Combining with sharing at the class level with WITH USER_MODE at the query level gives you enforcement at both the record and field level.
Anti-Pattern #4: Hardcoded Secrets
API keys, passwords, and tokens hardcoded directly in Apex:
// Dangerous: credentials visible in source code
HttpRequest req = new HttpRequest();
req.setHeader('Authorization', 'Bearer sk-a8Kj29xL...');
req.setEndpoint('https://api.vendor.com/data');
Anyone with access to the class metadata–including admins and other developers–can see these credentials. If the class is part of a CI/CD pipeline, the secrets end up in version control too.
The fix is Named Credentials:
// Secure: credentials managed by Named Credential, not visible in code
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Vendor_API/data');
Named Credentials store the authentication details securely in Salesforce and inject them at runtime. The Apex code never touches the credentials directly.
How to Audit Your Org
You can use tools like Salesforce Code Analyzer to scan for these patterns:
- SOQL queries without
WITH USER_MODEorWITH SECURITY_ENFORCED - Dynamic SOQL using string concatenation without
escapeSingleQuotes() - Classes declared
without sharingor with no sharing declaration - Hardcoded URLs and potential credentials in Apex classes
Salesforce’s own Security Review checks for all of these before allowing packages onto the AppExchange. But custom code in your org doesn’t go through that review–which means you need to do it yourself.
Deeper Dive
WITH USER_MODE vs WITH SECURITY_ENFORCED
Salesforce introduced WITH SECURITY_ENFORCED first, and many teams still use it. Both enforce CRUD and FLS, but they behave differently:
WITH SECURITY_ENFORCEDWITH USER_MODECRUD enforcementYesYesFLS enforcementYesYesSharing rule enforcementNo (depends on class declaration)YesBehavior on violationThrows System.QueryExceptionThrows System.QueryExceptionDML supportNo (query only)Yes (Database.insert(records, AccessLevel.USER_MODE) or update as user records)Polymorphic fieldsLimited supportFull support
Both keywords throw an exception when the running user lacks access to a queried object or field. The key differences are elsewhere: WITH USER_MODE also enforces sharing rules regardless of whether the class is declared with sharing or without sharing, supports DML operations, and handles polymorphic fields more reliably–making it a more comprehensive security control.
For new development, WITH USER_MODE is the recommended approach. For existing code using WITH SECURITY_ENFORCED, migration isn’t urgent, but be aware of the differences in scope–especially sharing rule enforcement–before switching.
The inherited sharing Keyword
Salesforce introduced inherited sharing as a way to handle utility classes that are called from multiple contexts:
public inherited sharing class QueryHelper {
public List<Account> getAccounts() {
return [SELECT Id, Name FROM Account];
}
}
With inherited sharing, the class uses the sharing context of whatever class called it. If called from a with sharing class, it enforces sharing. If called from without sharing, it doesn’t.
This is useful for generic utility classes where you don’t want to make a blanket sharing decision. But it comes with a catch: if the class is called as an entry point (e.g., from a Visualforce controller, Aura component, or Flow), it defaults to with sharing. This is the safe default, but it can cause unexpected behavior if you’re not aware of it.
This is a critical distinction from having no sharing declaration at all. A class with no sharing keyword also inherits the sharing context of its caller–but when that class is the entry point, it runs as without sharing. That means an undeclared class exposed as a controller or invocable action silently bypasses all record-level security. inherited sharing flips that default: when it’s the entry point, it enforces with sharing instead.
Use inherited sharing for shared utility classes. Use explicit with sharing or without sharing for classes where the security context should be deterministic.
Apex Triggers and Security Context
Triggers are a special case. They always run in system mode and cannot declare a sharing keyword. This means:
- Trigger code bypasses sharing rules regardless of the user who caused the trigger to fire.
- SOQL in triggers runs in system mode unless you explicitly use
WITH USER_MODE. - Any CRUD/FLS enforcement must be done manually or by calling a helper class that uses
WITH USER_MODE.
A common pattern is to keep trigger logic thin and delegate to handler classes that declare their own sharing:
// Trigger: thin, delegates to handler
trigger AccountTrigger on Account (before insert, before update) {
AccountTriggerHandler.handleBeforeInsert(Trigger.new);
}
// Handler: explicit sharing and user mode enforcement
public with sharing class AccountTriggerHandler {
public static void handleBeforeInsert(List<Account> newAccounts) {
// Business logic with explicit security context
List<Contact> relatedContacts = [
SELECT Id, AccountId FROM Contact
WHERE AccountId IN :newAccounts WITH USER_MODE
];
}
}
Note that enforcing WITH USER_MODE in triggers can cause unexpected failures if automated processes (like Flow or Process Builder) fire the trigger under a user context that lacks the necessary permissions. Test thoroughly across execution contexts.
Running Salesforce Code Analyzer
Salesforce Code Analyzer can scan your org’s Apex for many of these anti-patterns. To run it:
Install the plugin:
sf plugins install code-analyzer
Run a scan against your project source:
sf code-analyzer run --target "force-app" --rule-selector Recommended:Security --output-file scan.json
This checks for SOQL injection, CRUD/FLS violations, hardcoded credentials, and other security issues. The output is saved to scan.json and includes the file, line number, and a description of each violation.
You can also integrate Code Analyzer into your CI/CD pipeline so that insecure patterns are caught before they reach production. This is especially valuable for teams with multiple developers–it enforces a security baseline without relying on manual code review alone.
Migrating Legacy Code
If your org has years of Apex written before WITH USER_MODE existed, here’s a practical migration approach:
- Inventory your classes. Query all Apex classes and their sharing declarations:
SELECT Name, Body FROM ApexClass WHERE NamespacePrefix = null
2. Prioritize by risk. Focus first on classes that:
- Are called from Lightning components, Visualforce pages, or REST endpoints (user-facing entry points)
- Query or manipulate sensitive objects (Contact, Opportunity, Case, custom objects with PII)
- Use dynamic SOQL
3. Add WITH USER_MODE incrementally. You don’t need to migrate everything at once. Start with the highest-risk queries in user-facing controllers. Run your test suite after each change to catch any permission-related failures.
4. Fix sharing declarations. Any class without an explicit sharing keyword should get one. Default to with sharing unless there’s a documented reason for without sharing.
5. Replace hardcoded credentials. Move any API keys or tokens to Named Credentials. This is often the easiest win–it’s a configuration change in Setup plus a small code change to use callout: syntax.
The Bottom Line
Salesforce’s default system mode execution is powerful, but it puts the burden on developers to enforce security explicitly. WITH USER_MODE has dramatically simplified this, but only if developers actually use it.
Whether it’s your own custom Apex or code from a vendor’s managed package, the same rules apply: enforce CRUD/FLS at the query level, prevent injection with bind variables, declare sharing explicitly, and never hardcode secrets.
If you’re not confident your org’s Apex code follows these practices, a targeted code review can surface vulnerabilities before they’re exploited.
Book a 15-Minute Security Strategy Call
Reference(s):
https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_dynamic_soql.htm