Apex Security Guide
Sharing Modes
with sharing (Default)
Use for most classes. Enforces record-level security (OWD, sharing rules).
public with sharing class AccountService {
public List<Account> getAccounts() {
// Only returns records user has access to
return [SELECT Id, Name FROM Account];
}
}
inherited sharing
Use for utility/helper classes. Inherits sharing mode from caller.
public inherited sharing class QueryHelper {
public static List<SObject> query(String soql) {
// Sharing determined by calling class
return Database.query(soql);
}
}
without sharing
Use sparingly. Only for specific system operations that require full access.
public without sharing class SystemService {
// DOCUMENT WHY this needs without sharing
public static void updateSystemRecords(List<Record__c> records) {
// Updates regardless of sharing rules
update records;
}
}
Rule: Keep without sharing classes small and isolated. Never expose directly to users.
CRUD/FLS Enforcement
USER_MODE (Recommended)
// SOQL with USER_MODE - enforces CRUD and FLS
List<Account> accounts = [
SELECT Id, Name, AnnualRevenue
FROM Account
WITH USER_MODE
];
// Database methods with AccessLevel
Database.insert(records, AccessLevel.USER_MODE);
Database.update(records, AccessLevel.USER_MODE);
Database.delete(records, AccessLevel.USER_MODE);
// Database.query with USER_MODE
String query = 'SELECT Id FROM Account WHERE Name = :name';
List<Account> accounts = Database.query(query, AccessLevel.USER_MODE);
Security.stripInaccessible()
Remove inaccessible fields before DML or returning to user:
// Strip inaccessible fields before insert
List<Account> accounts = getAccountsFromExternalSource();
SObjectAccessDecision decision = Security.stripInaccessible(
AccessType.CREATABLE,
accounts
);
insert decision.getRecords();
// Strip before returning to user
List<Account> accounts = [SELECT Id, Name, Secret_Field__c FROM Account];
SObjectAccessDecision decision = Security.stripInaccessible(
AccessType.READABLE,
accounts
);
return decision.getRecords(); // Secret_Field__c removed if not accessible
// Check which fields were removed
Set<String> removedFields = decision.getRemovedFields().get('Account');
Manual Checks (Legacy)
// Object-level
if (!Schema.sObjectType.Account.isCreateable()) {
throw new SecurityException('No create access to Account');
}
// Field-level
if (!Schema.sObjectType.Account.fields.Name.isUpdateable()) {
throw new SecurityException('No update access to Account.Name');
}
SOQL Injection Prevention
Use Bind Variables (Preferred)
// SAFE: Bind variable
String name = userInput;
List<Account> accounts = [SELECT Id FROM Account WHERE Name = :name];
// SAFE: Dynamic query with bind
String query = 'SELECT Id FROM Account WHERE Name = :name';
List<Account> accounts = Database.query(query);
Escape User Input (If Dynamic)
// If you must build dynamic SOQL
String safeName = String.escapeSingleQuotes(userInput);
String query = 'SELECT Id FROM Account WHERE Name = '' + safeName + ''';
Never Do This
// VULNERABLE: Direct concatenation
String query = 'SELECT Id FROM Account WHERE Name = '' + userInput + ''';
// Attacker input: ' OR Name != '
// Results in: SELECT Id FROM Account WHERE Name = '' OR Name != ''
Named Credentials
Never Hardcode Credentials
// BAD: Hardcoded credentials
Http http = new Http();
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.example.com');
req.setHeader('Authorization', 'Bearer sk_live_abc123'); // EXPOSED!
// GOOD: Named Credential
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:MyNamedCredential/api/resource');
// Authorization handled automatically
Setting Up Named Credentials
- Setup → Named Credentials
- Configure authentication (OAuth, Password, etc.)
- Reference in code:
callout:CredentialName/path
Custom Settings for Bypass Flags
Enable admins to disable automation without code changes:
public class TriggerConfig {
private static Trigger_Settings__c settings;
public static Boolean isDisabled(String triggerName) {
if (settings == null) {
settings = Trigger_Settings__c.getInstance();
}
return settings?.Disable_All_Triggers__c == true ||
(Boolean)settings.get('Disable_' + triggerName + '__c') == true;
}
}
// Usage in trigger action
if (TriggerConfig.isDisabled('Account')) {
return;
}
Secure Apex for LWC/Aura
AuraHandledException
@AuraEnabled
public static Account getAccount(Id accountId) {
try {
// Use USER_MODE for security
return [SELECT Id, Name FROM Account WHERE Id = :accountId WITH USER_MODE];
} catch (QueryException e) {
throw new AuraHandledException('Account not found');
} catch (Exception e) {
// Log for debugging
System.debug(LoggingLevel.ERROR, e.getMessage());
// Return user-friendly message
throw new AuraHandledException('An error occurred. Please contact support.');
}
}
Cacheable Methods
@AuraEnabled(cacheable=true)
public static List<Account> getAccounts() {
// Cacheable methods cannot have DML
// Must be idempotent
return [SELECT Id, Name FROM Account WITH USER_MODE LIMIT 100];
}
Permission Checks
Custom Permissions
public static Boolean hasCustomPermission(String permissionName) {
return FeatureManagement.checkPermission(permissionName);
}
// Usage
if (!hasCustomPermission('Access_Sensitive_Data')) {
throw new SecurityException('Insufficient permissions');
}
Profile/Permission Set Checks
// Check if user has specific permission
public static Boolean canModifyAllData() {
return [
SELECT PermissionsModifyAllData
FROM Profile
WHERE Id = :UserInfo.getProfileId()
].PermissionsModifyAllData;
}
Security Review Checklist
| Check | Status |
|---|---|
Uses with sharing by default |
☐ |
without sharing justified and documented |
☐ |
| SOQL uses USER_MODE or manual CRUD/FLS checks | ☐ |
| No SOQL injection vulnerabilities | ☐ |
| No hardcoded credentials | ☐ |
| Sensitive data not exposed in debug logs | ☐ |
| AuraEnabled methods have proper error handling | ☐ |
| Custom permissions used for sensitive operations | ☐ |