Apex secure coding is the practice of writing Salesforce Apex code — the Java-like language that runs on the Force.com platform — in a way that prevents exploitable security vulnerabilities. Apex runs with full access to Salesforce data, so a vulnerability in an Apex class can expose every object, field, and record visible to the executing user.
This guide covers the most common Apex security vulnerabilities, how to fix each one, and how Static Application Security Testing (SAST) detects them in Apex code before deployment.
Why Apex Security Is Different from General Java Security
Apex looks like Java but has important differences that change the security threat model:
All Apex runs in a multi-tenant cloud. A vulnerability in your Apex code doesn’t just affect your org — in edge cases, Salesforce’s multi-tenant governor limits mean poorly written code can affect shared infrastructure. Salesforce Security Review (required for AppExchange listings) will reject any code with known vulnerabilities.
Apex has platform-enforced sharing rules — but only if you use them. Salesforce’s sharing model restricts record visibility by default for objects, but Apex code can be written with without sharing, bypassing these controls for every query in that class.
SOQL is not SQL, but it has injection risks. Salesforce Object Query Language (SOQL) is Apex’s query language. Dynamic SOQL (using Database.query() or [FIND ... IN ALL FIELDS]) with string concatenation is vulnerable to SOQL injection.
Visualforce and Lightning Web Components render user input differently. XSS in Apex-rendered pages is possible when developer-controlled output skips the platform’s automatic HTML encoding.
Most Common Apex Security Vulnerabilities
1. SOQL Injection (CWE-89 in Apex Context)
SOQL injection is the Apex equivalent of SQL injection. It occurs when user-controlled input is concatenated into a dynamic SOQL query string without escaping.
Vulnerable code:
// VULNERABLE - user input concatenated directly into dynamic SOQL
public List<Account> searchAccounts(String searchTerm) {
String query = 'SELECT Id, Name FROM Account WHERE Name LIKE \'%'
+ searchTerm + '%\'';
return Database.query(query);
}
Attack payload: If searchTerm is %' OR Name LIKE '%, the query becomes:
SELECT Id, Name FROM Account WHERE Name LIKE '%%' OR Name LIKE '%%'
This returns all Accounts, bypassing the intended filter and potentially exposing records the user should not see.
What makes SOQL injection less catastrophic than SQL injection:
- SOQL cannot modify data (no
INSERT,UPDATE,DELETEvia SOQL) - SOQL cannot execute stored procedures
- Sharing rules still apply (unless the class runs
without sharing)
What SOQL injection can still achieve:
- Read all accessible records across any object
- Exfiltrate all visible field values
- Bypass intended query filters to access records outside intended scope
Fix: Use bind variables (always preferable)
// SECURE - bind variable prevents any injection
public List<Account> searchAccounts(String searchTerm) {
String likePattern = '%' + searchTerm + '%';
return [SELECT Id, Name FROM Account WHERE Name LIKE :likePattern];
}
Bind variables in static SOQL (the bracket syntax [SELECT ...]) or in dynamic SOQL using :variableName are always parameterized — the value is never interpreted as SOQL syntax.
Fix: Escape when bind variables are not feasible
When you must use dynamic SOQL and cannot use a bind variable for a particular parameter, use String.escapeSingleQuotes():
// ACCEPTABLE when bind variable isn't possible (e.g., field names are dynamic)
public List<Account> dynamicQuery(String fieldName, String searchValue) {
// Field name cannot be a bind variable - validate it against an allowlist
Set<String> allowedFields = new Set<String>{'Name', 'Industry', 'BillingCity'};
if (!allowedFields.contains(fieldName)) {
throw new SecurityException('Invalid field name');
}
// Value can be escaped
String safeValue = String.escapeSingleQuotes(searchValue);
String query = 'SELECT Id, ' + fieldName
+ ' FROM Account WHERE ' + fieldName
+ ' = \'' + safeValue + '\'';
return Database.query(query);
}
Note:
String.escapeSingleQuotes()only escapes single quote characters. It does not make arbitrary SOQL safe — only use it for string literal values, not for field names, object names, or SOQL keywords. Use an allowlist for all non-literal parts of a dynamic query.
2. Apex Without Sharing (Access Control Bypass)
Salesforce enforces record-level access through sharing rules, role hierarchy, and ownership. When an Apex class is declared without sharing, all SOQL queries in that class bypass the sharing model and return all records regardless of the running user’s visibility.
Vulnerable code:
// DANGEROUS - runs without sharing rules; exposes all records
public without sharing class AccountService {
public static List<Account> getAllAccounts() {
// Returns ALL accounts in the org, ignoring sharing rules
return [SELECT Id, Name, AnnualRevenue FROM Account];
}
}
If this method is callable from a Lightning Web Component or Aura component accessible to community users or external partners, those users can retrieve all Account data regardless of their visibility rules.
Fix: Use with sharing as the default
// SECURE - respects the running user's sharing rules
public with sharing class AccountService {
public static List<Account> getUserAccounts() {
// Only returns accounts the current user has access to
return [SELECT Id, Name, AnnualRevenue FROM Account];
}
}
When without sharing is legitimate:
- Batch jobs that need to process all records regardless of the system account’s sharing visibility
- Internal system processes where the running user is an integration account
- Administrative functions that explicitly need org-wide data access
Always document why without sharing is required and limit the methods in without sharing classes to only the operations that need it. Consider using an inner class pattern where only specific methods bypass sharing.
3. CRUD and FLS Bypass (Authorization Missing on DML)
Salesforce enforces two levels of data access:
- CRUD (Create, Read, Update, Delete): object-level permissions - can a user see the Account object at all?
- FLS (Field-Level Security): field-level permissions - can a user see the
SSN__cfield on Account?
Apex code that performs DML or queries fields without checking CRUD/FLS bypasses these controls, allowing code running in a user’s context to access objects and fields they don’t have permission to view.
Vulnerable code:
// VULNERABLE - no CRUD or FLS check before DML
public static void updateAccountRevenue(Id accountId, Decimal revenue) {
Account acc = new Account(Id = accountId, AnnualRevenue = revenue);
update acc; // Bypasses the running user's Update permission on Account
}
Fix: Check CRUD/FLS before DML or queries
// SECURE - check object and field permissions before operating
public static void updateAccountRevenue(Id accountId, Decimal revenue) {
// Check CRUD: can the current user update Account records?
if (!Schema.sObjectType.Account.isUpdateable()) {
throw new System.SecurityException(
'Current user does not have permission to update Account records.'
);
}
// Check FLS: can the current user edit the AnnualRevenue field?
if (!Schema.sObjectType.Account.fields.AnnualRevenue.isUpdateable()) {
throw new System.SecurityException(
'Current user does not have permission to edit the AnnualRevenue field.'
);
}
Account acc = new Account(Id = accountId, AnnualRevenue = revenue);
update acc;
}
Using WITH SECURITY_ENFORCED in SOQL (Salesforce API v50.0+)
For queries, the WITH SECURITY_ENFORCED clause automatically enforces FLS and throws a QueryException if any queried field is not accessible:
// SECURE - WITH SECURITY_ENFORCED enforces FLS on this query
public static List<Account> getAccounts() {
return [SELECT Id, Name, AnnualRevenue
FROM Account
WITH SECURITY_ENFORCED];
}
Using Security.stripInaccessible (Salesforce API v48.0+)
Security.stripInaccessible is more flexible — it removes inaccessible fields from the result set rather than throwing an exception, which is better for displaying partial data:
// SECURE - strips fields the current user cannot read
public static List<Account> getAccountsSafe() {
List<Account> accounts = [SELECT Id, Name, AnnualRevenue, SSN__c FROM Account];
SObjectAccessDecision result = Security.stripInaccessible(
AccessType.READABLE, accounts
);
return result.getRecords();
}
4. Cross-Site Scripting (XSS) in Visualforce Pages
Visualforce pages that render Apex variables using {!variable} are HTML-encoded by default. However, several mechanisms bypass this encoding and create XSS vulnerabilities.
Vulnerable: apex:outputText escape="false"
<!-- VULNERABLE - escape="false" renders unencoded HTML -->
<apex:outputText escape="false" value="{!userProvidedHtml}"/>
If userProvidedHtml contains <script>alert(document.cookie)</script>, the script executes in the user’s browser.
Vulnerable: JavaScript context without encoding
<!-- VULNERABLE - variable used directly in JavaScript context -->
<script>
var userName = '{!account.Name}';
</script>
If account.Name is '; alert(1)//, the script becomes:
var userName = ''; alert(1)//';
Fix: Use JSENCODE for JavaScript contexts
<!-- SECURE - JSENCODE for JavaScript contexts -->
<script>
var userName = '{!JSENCODE(account.Name)}';
</script>
Salesforce provides context-specific encoding functions:
{!HTMLENCODE(value)}- for HTML contexts (usually automatic, but explicit forescape="false"scenarios){!JSENCODE(value)}- for JavaScript string contexts{!JSINHTMLENCODE(value)}- for JavaScript inside HTML attributes (applies both HTML and JS encoding){!URLENCODE(value)}- for URL contexts
Fix: Avoid escape="false" wherever possible
<!-- SECURE - default encoding protects against XSS -->
<apex:outputText value="{!userInput}"/>
<!-- No escape="false" needed unless you explicitly need raw HTML -->
5. Hardcoded Credentials in Apex (CWE-798)
Hardcoded API keys, passwords, and tokens in Apex code are a frequent finding in Salesforce AppExchange security reviews.
Vulnerable:
// VULNERABLE - hardcoded API key
public class IntegrationService {
private static final String API_KEY = 'sk-prod-abc123def456xyz';
private static final String API_ENDPOINT = 'https://api.external-service.com';
public static String callExternalService(String payload) {
HttpRequest req = new HttpRequest();
req.setEndpoint(API_ENDPOINT + '/data');
req.setHeader('Authorization', 'Bearer ' + API_KEY);
// ...
}
}
Hardcoded credentials in Apex are stored in Salesforce metadata and appear in version control history. They are also visible to any developer with access to the org’s metadata.
Fix: Use Custom Settings or Named Credentials
// SECURE - API key stored in Custom Settings (org-specific, not in code)
public class IntegrationService {
public static String callExternalService(String payload) {
// Read from Custom Settings (stored in org configuration, not code)
Integration_Config__c config = Integration_Config__c.getInstance();
HttpRequest req = new HttpRequest();
// Named Credentials handle authentication without exposing keys in code
req.setEndpoint('callout:External_Service/data');
// ...
}
}
Named Credentials (Setup → Security → Named Credentials) are the Salesforce-recommended approach for external service authentication. They store authentication details in the platform configuration, not in code, and support OAuth flows natively.
For non-authentication secrets, Custom Settings (Custom Metadata Types for packaged apps) store values in org configuration rather than Apex code.
6. Open Redirect in Apex Page Controllers
Apex page controllers that redirect to user-supplied URLs can be used for phishing attacks.
Vulnerable:
// VULNERABLE - redirect to user-supplied URL without validation
public PageReference redirectUser() {
String returnUrl = ApexPages.currentPage().getParameters().get('returnUrl');
return new PageReference(returnUrl);
}
Attacker constructs: /apex/MyPage?returnUrl=https://evil.com/phishing
Fix: Validate the destination against an allowlist
// SECURE - validate destination against known-safe paths
public PageReference redirectUser() {
String returnUrl = ApexPages.currentPage().getParameters().get('returnUrl');
// Allow only relative URLs within the org (starting with /)
// or specific known-safe domains
if (returnUrl != null && returnUrl.startsWith('/')) {
// Relative URL within Salesforce - safe
return new PageReference(returnUrl);
}
// Default fallback for invalid or external URLs
return new PageReference('/');
}
Apex SAST: What Static Analysis Detects
Static Application Security Testing for Apex analyzes your Apex class files, triggers, and Visualforce pages without executing them, tracing data flows from user-controlled inputs to dangerous operations.
Sources (user-controlled input in Apex):
ApexPages.currentPage().getParameters().get()RestContext.request.requestBody- Record field values from user-editable inputs
- Lightning Web Component
@AuraEnabledmethod parameters - Inbound REST/SOAP service parameters
Sinks (dangerous operations):
Database.query()with string concatenation (SOQL injection)Database.search()with string concatenation (SOSL injection)- DML operations without CRUD/FLS checks
PageReferenceconstructor with user-supplied URL (open redirect)- JavaScript output via
{!}expressions withescape="false"
What Offensive360 SAST detects in Apex:
- SOQL injection via
Database.query()with concatenated user input (without bind variables) - Dynamic SOSL injection via
Database.search() - DML operations in Apex classes with no preceding CRUD/FLS check
without sharingdeclarations on classes handling user-visible data operations- Hardcoded credentials matching credential patterns in Apex class fields
- XSS via Visualforce
escape="false"with user-controlled data - Open redirect via
PageReferencewith user-supplied URL parameters
SAST analysis of Apex requires understanding of the Force.com platform’s data flow model — sources in @AuraEnabled methods, sharing context inheritance between calling and called classes, and the difference between static and dynamic SOQL. A generic Java SAST tool applied to Apex will produce many false positives and miss platform-specific sinks.
Apex Security Checklist
Use this checklist before submitting an AppExchange security review or deploying to production:
SOQL / SOSL Injection:
- Dynamic
Database.query()calls do not concatenate user input directly — use bind variables (:variable) orString.escapeSingleQuotes()for string literals - Dynamic
Database.search()calls use bind variables for user-controlled search terms - Field names and object names in dynamic SOQL are validated against an allowlist — not user-supplied
Access Control:
- All Apex classes that perform DML or queries on user-visible data use
with sharing(or explicitly justifywithout sharing) - DML operations check object CRUD permissions before executing
- Queries use
WITH SECURITY_ENFORCEDorSecurity.stripInaccessible()to enforce FLS -
@AuraEnabledmethods accessible from community or partner portals usewith sharing
XSS in Visualforce:
- No
<apex:outputText escape="false">with user-controlled or database-sourced content - JavaScript contexts use
{!JSENCODE(value)}— not raw{!value} -
{!HTMLENCODE(value)}used explicitly whenescape="false"is required
Secrets and Configuration:
- No API keys, passwords, or tokens hardcoded in Apex class fields or methods
- External service authentication uses Named Credentials
- Environment-specific configuration uses Custom Settings or Custom Metadata Types
Redirect Safety:
-
PageReferenceconstructors with user-supplied parameters validate the destination is a relative path or known-safe domain
AppExchange Compliance:
- All Apex classes reviewed for Salesforce Security Review requirements (see Salesforce Security Review Checklist)
- SOQL injection, XSS, and CRUD/FLS tests run before submission
Integrating Apex SAST into Salesforce CI/CD
For teams using Salesforce DX (SFDX) with GitHub Actions or GitLab CI, SAST can run as a pipeline step before deployment:
# GitHub Actions - Apex SAST scan on pull request
name: Apex Security Scan
on:
pull_request:
branches: [main]
paths:
- 'force-app/**/*.cls'
- 'force-app/**/*.page'
- 'force-app/**/*.trigger'
jobs:
apex-sast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Offensive360 Apex SAST
env:
O360_API_KEY: ${{ secrets.O360_API_KEY }}
run: |
# Submit Apex source for security analysis
curl -X POST https://api.offensive360.com/scan/code \
-H "X-API-Key: $O360_API_KEY" \
-F "source=@./force-app" \
-F "language=apex"
For teams using the Salesforce CLI and scratch orgs, SAST can be integrated into the deployment pipeline to block deployments with critical findings before they reach sandbox or production.
Frequently Asked Questions
What is the most common Apex security vulnerability?
CRUD/FLS bypass is the most commonly found Apex security issue in AppExchange security reviews. Developers often perform DML or queries without first checking whether the running user has the necessary permissions. SOQL injection via dynamic queries is the second most common finding.
Does the Salesforce platform protect against SOQL injection automatically?
Salesforce protects against SOQL injection in static SOQL queries (the bracket syntax: [SELECT Id FROM Account WHERE Name = :userInput]) because bind variables in static SOQL are always parameterized. Dynamic SOQL using Database.query() with string concatenation is not automatically protected — it requires explicit use of bind variables or String.escapeSingleQuotes().
Is with sharing the default for Apex classes?
No. If you omit the sharing keyword entirely, Apex runs in the inherited sharing context in some API versions, but historically defaults to without sharing behavior. Always explicitly declare with sharing for classes handling user-visible data to avoid accidental bypass of sharing rules. The Salesforce documentation recommends explicit with sharing declarations for all public-facing controllers and service classes.
How is Apex SAST different from Java SAST?
Apex SAST must understand Salesforce-specific sources and sinks: ApexPages.currentPage().getParameters(), RestContext.request, @AuraEnabled method parameters as sources, and Database.query(), DML statements, and PageReference as sinks. It must also model the platform’s sharing context (whether a class runs with sharing or without sharing) and understand that WITHOUT SECURITY_ENFORCED queries bypass FLS. Generic Java SAST tools applied to Apex produce many false positives because they don’t model Force.com’s data access layer correctly.
What vulnerabilities does Salesforce Security Review check for?
Salesforce Security Review for AppExchange packages specifically checks for: SOQL/SOSL injection, XSS in Visualforce pages, CRUD/FLS bypass, hardcoded credentials, open redirects, and stored sensitive data violations. Packages that fail security review cannot be listed on AppExchange. Many ISVs run SAST tools before submission to catch findings proactively.
Summary
Apex secure coding centers on five areas:
| Vulnerability | Root Cause | Fix |
|---|---|---|
| SOQL injection | Dynamic SOQL with concatenated input | Bind variables; String.escapeSingleQuotes() for string literals |
| CRUD/FLS bypass | DML without permission checks | isCreateable()/isUpdateable()/isReadable() checks; WITH SECURITY_ENFORCED |
without sharing bypass | Sharing keyword omitted or wrong | Always declare with sharing; audit without sharing classes |
| XSS in Visualforce | escape="false" or raw JS context | Remove escape="false"; use JSENCODE() for JS contexts |
| Hardcoded credentials | API keys in Apex source | Named Credentials; Custom Settings |
SAST tools that model Salesforce’s data access layer — understanding bind variables, sharing context inheritance, CRUD/FLS schema methods, and Visualforce encoding functions — provide reliable detection of these findings before code reaches production or AppExchange review.
Offensive360 SAST includes Apex analysis for Salesforce security review preparation and ongoing CI/CD integration. Book a demo to see Apex vulnerability findings on your own codebase.