# Apex Security Guide Comprehensive guide to Apex security including CRUD/FLS enforcement, sharing rules, and SOQL injection prevention. --- ## Table of Contents 1. [CRUD and FLS (Field-Level Security)](#crud-and-fls-field-level-security) 2. [Sharing and Record Access](#sharing-and-record-access) 3. [SOQL Injection Prevention](#soql-injection-prevention) 4. [Security Checklist](#security-checklist) --- ## CRUD and FLS (Field-Level Security) ### API 62.0: WITH USER_MODE **Modern approach (API 62.0+)**: Use `WITH USER_MODE` in SOQL to enforce CRUD and FLS automatically. ```apex // ✅ GOOD: Respects user permissions List accounts = [ SELECT Id, Name, Industry, AnnualRevenue FROM Account WHERE Industry = 'Technology' WITH USER_MODE ]; ``` **DML with USER_MODE**: Use `AccessLevel.USER_MODE` for Database methods to enforce CRUD/FLS on insert, update, and delete: ```apex // ✅ GOOD: Enforces CRUD/FLS on DML operations Database.insert(records, AccessLevel.USER_MODE); Database.update(records, AccessLevel.USER_MODE); Database.delete(records, AccessLevel.USER_MODE); ``` **What USER_MODE does**: - Enforces object-level CRUD (Create, Read, Update, Delete) - Enforces field-level security (FLS) - Throws `System.QueryException` if user lacks access (for SOQL) - Throws `System.DmlException` if user lacks access (for DML) - Respects user's sharing rules (when combined with `with sharing`) **When to use SYSTEM_MODE**: ```apex // Only use SYSTEM_MODE when you explicitly NEED to bypass security List accounts = [ SELECT Id, Name, Sensitive_Field__c FROM Account WITH SYSTEM_MODE // ⚠️ Use with caution! ]; ``` **Use cases for SYSTEM_MODE**: - Background jobs that must process all records regardless of user - System integrations - Administrative cleanup scripts **ALWAYS document why SYSTEM_MODE is needed:** ```apex // JUSTIFICATION: This batch job processes all accounts for regulatory reporting, // regardless of user's access level. Approved by Security Team on 2025-01-01. ``` --- ### Legacy Approach: Security.stripInaccessible() **For pre-62.0 compatibility** or when you need to filter fields dynamically: ```apex // Query all fields List accounts = [SELECT Id, Name, Industry, AnnualRevenue FROM Account]; // Strip inaccessible fields SObjectAccessDecision decision = Security.stripInaccessible( AccessType.READABLE, accounts ); // Use stripped records List accessibleAccounts = decision.getRecords(); // Check which fields were removed Set removedFields = decision.getRemovedFields().get('Account'); if (removedFields != null && !removedFields.isEmpty()) { System.debug('User lacks access to fields: ' + removedFields); } ``` **Access Types**: - `READABLE` - Read access (for queries) - `CREATABLE` - Create access (before insert) - `UPDATABLE` - Update access (before update) - `UPSERTABLE` - Upsert access **Example: Pre-DML Check** ```apex public static void createAccounts(List accounts) { // Check if user can create these fields SObjectAccessDecision decision = Security.stripInaccessible( AccessType.CREATABLE, accounts ); if (!decision.getRemovedFields().isEmpty()) { throw new SecurityException('User lacks permission to create some fields'); } insert decision.getRecords(); } ``` --- ### Manual CRUD/FLS Checks (Verbose but Explicit) ```apex // Check object-level CRUD if (!Schema.sObjectType.Account.isAccessible()) { throw new SecurityException('User cannot read Accounts'); } if (!Schema.sObjectType.Account.isCreateable()) { throw new SecurityException('User cannot create Accounts'); } // Check field-level security if (!Schema.sObjectType.Account.fields.Industry.isAccessible()) { throw new SecurityException('User cannot read Industry field'); } if (!Schema.sObjectType.Account.fields.Industry.isUpdateable()) { throw new SecurityException('User cannot update Industry field'); } ``` **When to use**: Legacy codebases, specific error messaging, or when you need fine-grained control. --- ## Sharing and Record Access ### Sharing Keywords | Keyword | Behavior | When to Use | |---------|----------|-------------| | `with sharing` | Enforces record-level sharing | Default for user-facing code | | `without sharing` | Bypasses record-level sharing | System operations, integrations | | `inherited sharing` | Inherits from calling class | Utility classes, shared libraries | **Default Rule**: If no keyword specified, class runs in `without sharing` mode (pre-API 40 behavior). **ALWAYS specify a sharing keyword** - implicit behavior is confusing. --- ### with sharing (Recommended Default) ```apex public with sharing class AccountService { public static List getAccountsForUser() { // User only sees Accounts they have access to via sharing rules return [SELECT Id, Name FROM Account WITH USER_MODE]; } public static void updateAccount(Account acc) { // Throws exception if user lacks access update acc; } } ``` **Use cases**: - User-facing controllers (LWC, Aura, Visualforce) - Service classes handling user requests - Trigger actions that respect user context --- ### without sharing (Use Sparingly) ```apex public without sharing class AdminService { // JUSTIFICATION: This method is only called by system administrators // to perform global updates. Access controlled by Custom Permission. public static void globalAccountUpdate() { List allAccounts = [SELECT Id, Name FROM Account]; // Process ALL accounts, ignoring sharing } } ``` **Use cases**: - Background jobs - System integrations - Administrative operations **Security Note**: Always add access control checks when using `without sharing`: ```apex public without sharing class AdminService { public static void globalUpdate() { // Check permission before executing if (!FeatureManagement.checkPermission('Admin_Global_Update')) { throw new SecurityException('Requires Admin_Global_Update permission'); } // Now safe to proceed with without sharing logic } } ``` --- ### inherited sharing (Best for Utilities) ```apex public inherited sharing class StringUtils { // Inherits sharing from calling class public static String sanitize(String input) { return String.escapeSingleQuotes(input); } } // Called from "with sharing" class → runs with sharing // Called from "without sharing" class → runs without sharing ``` **Use cases**: - Utility classes - Helper methods - Shared libraries that don't directly query records --- ### Mixing Sharing Contexts ```apex public with sharing class UserFacingService { public static void processAccount(Id accountId) { // This runs WITH sharing Account acc = [SELECT Id, Name FROM Account WHERE Id = :accountId]; // Call a without sharing method for specific operation SystemOperations.performGlobalCheck(acc); } } public without sharing class SystemOperations { public static void performGlobalCheck(Account acc) { // This runs WITHOUT sharing // Can access records the original user couldn't see } } ``` **Pattern**: Start with `with sharing`, only escalate to `without sharing` when needed. --- ## SOQL Injection Prevention ### The Problem **NEVER concatenate user input into SOQL strings:** ```apex // ❌ VULNERABLE to SOQL injection public static List searchAccounts(String userInput) { String query = 'SELECT Id, Name FROM Account WHERE Name = \'' + userInput + '\''; return Database.query(query); } // Attack: userInput = "test' OR '1'='1" // Results in: SELECT Id, Name FROM Account WHERE Name = 'test' OR '1'='1' // Returns ALL accounts! ``` --- ### Solution 1: Bind Variables (Recommended) ```apex // ✅ SAFE: Use bind variables public static List searchAccounts(String userInput) { return [SELECT Id, Name FROM Account WHERE Name = :userInput WITH USER_MODE]; } // Even with malicious input, it's treated as a literal string ``` **Why it works**: Salesforce treats `:userInput` as a value, not executable SOQL. --- ### Solution 2: String.escapeSingleQuotes() **When dynamic SOQL is unavoidable** (rare cases): ```apex // ✅ SAFE: Escape user input public static List dynamicSearch(String userInput) { String sanitized = String.escapeSingleQuotes(userInput); String query = 'SELECT Id, Name FROM Account WHERE Name = \'' + sanitized + '\''; return Database.query(query); } ``` **What it does**: Escapes single quotes (`'` → `\'`) to prevent breaking out of string literals. **Still prefer bind variables** - escapeSingleQuotes is a backup. --- ### Solution 3: Allowlist Validation **For field names, operators, or other dynamic query parts:** ```apex public static List sortedAccounts(String sortField) { // ✅ SAFE: Validate against allowlist Set allowedFields = new Set{'Name', 'Industry', 'AnnualRevenue'}; if (!allowedFields.contains(sortField)) { throw new IllegalArgumentException('Invalid sort field'); } String query = 'SELECT Id, Name, Industry FROM Account ORDER BY ' + sortField; return Database.query(query); } ``` **Use case**: Dynamic ORDER BY, dynamic field selection (but NOT WHERE clause values). --- ### Dynamic SOQL Best Practices **Pattern: Safe Dynamic Query Builder** ```apex public class SafeQueryBuilder { private static final Set ALLOWED_FIELDS = new Set{ 'Id', 'Name', 'Industry', 'AnnualRevenue' }; private static final Set ALLOWED_OPERATORS = new Set{ '=', '!=', '<', '>', '<=', '>=', 'LIKE', 'IN' }; public static List query( String field, String operator, String value ) { // Validate field if (!ALLOWED_FIELDS.contains(field)) { throw new IllegalArgumentException('Invalid field: ' + field); } // Validate operator if (!ALLOWED_OPERATORS.contains(operator)) { throw new IllegalArgumentException('Invalid operator: ' + operator); } // Use bind variable for value String query = 'SELECT Id, Name FROM Account WHERE ' + field + ' ' + operator + ' :value WITH USER_MODE'; return Database.query(query); } } ``` --- ## Security Checklist Use this checklist when generating or reviewing Apex code: ### CRUD/FLS - [ ] All SOQL queries use `WITH USER_MODE` (or `Security.stripInaccessible()` for pre-62.0) - [ ] DML operations check `isCreateable()`, `isUpdateable()`, `isDeletable()` OR use `WITH USER_MODE` - [ ] Custom fields have Permission Sets/Profiles granting FLS - [ ] System operations using `WITH SYSTEM_MODE` are documented with justification ### Sharing - [ ] All classes have explicit sharing keyword (`with sharing`, `without sharing`, or `inherited sharing`) - [ ] User-facing classes use `with sharing` - [ ] `without sharing` classes have documented justification - [ ] `without sharing` classes include Custom Permission checks ### SOQL Injection - [ ] No string concatenation in WHERE clauses with user input - [ ] All user input uses bind variables (`:variableName`) - [ ] Dynamic SOQL uses allowlist validation for field names/operators - [ ] `String.escapeSingleQuotes()` used if concatenation is unavoidable ### General Security - [ ] No hardcoded credentials or API keys (use Named Credentials) - [ ] No hardcoded Record IDs (use Custom Metadata or queries) - [ ] Sensitive data (SSN, PII) is encrypted at rest (Platform Encryption) - [ ] External callouts use Named Credentials, not plain URLs - [ ] Error messages don't leak sensitive information --- ## Advanced Security Patterns ### Custom Permissions **Check user has specific permission before dangerous operations:** ```apex public without sharing class DataDeletionService { public static void deleteAllTestData() { // Check Custom Permission if (!FeatureManagement.checkPermission('Delete_Test_Data')) { throw new SecurityException('Requires Delete_Test_Data permission'); } // Safe to proceed delete [SELECT Id FROM Account WHERE Name LIKE 'TEST%']; } } ``` **Create Custom Permission**: Setup → Custom Permissions → New --- ### Secure Remote Actions (@AuraEnabled) ```apex public with sharing class AccountController { @AuraEnabled(cacheable=true) public static List getAccounts() { // Runs with sharing + user mode = secure return [SELECT Id, Name FROM Account WITH USER_MODE LIMIT 50]; } @AuraEnabled public static void updateAccount(Account acc) { // Verify user can update if (!Schema.sObjectType.Account.isUpdateable()) { throw new AuraHandledException('No update permission'); } update acc; } } ``` **Security Notes**: - Always use `with sharing` for `@AuraEnabled` methods - Use `WITH USER_MODE` in SOQL - Validate DML permissions before operations - Use `AuraHandledException` for friendly error messages --- ### Platform Events Security ```apex public with sharing class EventPublisher { public static void publishEvent(String message) { // Check if user can create Platform Events if (!Schema.sObjectType.MyEvent__e.isCreateable()) { throw new SecurityException('Cannot publish events'); } MyEvent__e event = new MyEvent__e( Message__c = message ); EventBus.publish(event); } } ``` --- ### Custom Settings for Bypass Flags Enable admins to disable automation without code changes: ```apex 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; } ``` --- ## Common Security Vulnerabilities | Vulnerability | Example | Fix | |---------------|---------|-----| | **SOQL Injection** | `'WHERE Name = \'' + input + '\''` | Use bind variable `:input` | | **XSS (Cross-Site Scripting)** | Returning unsanitized HTML | Use `HTMLENCODE()` in VF or LWC escaping | | **Insecure Direct Object Reference** | Accepting record ID from user without checking access | Query with `WITH USER_MODE`, verify in `with sharing` | | **Hardcoded Credentials** | `String apiKey = 'abc123';` | Use Named Credentials | | **Missing FLS** | Directly querying fields without checking | Use `WITH USER_MODE` | | **Overly Permissive Sharing** | `without sharing` everywhere | Use `with sharing` by default | **Back to Main**: [SKILL.md](../SKILL.md)