Skip to main content

Free 30-min security demo Book Now

Application Security

Apex Secure Coding: Salesforce SAST Guide

Salesforce Apex secure coding: SOQL injection, CRUD/FLS bypass, XSS in Visualforce and hardcoded credentials — fixes and SAST detection for AppExchange review.

Offensive360 Security Research Team 15 min read
apex secure coding salesforce security apex security soql injection apex sast salesforce apex apex vulnerabilities salesforce secure coding apex xss apex code review salesforce SAST apex static analysis soql injection apex salesforce code review

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, DELETE via 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__c field 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 for escape="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 @AuraEnabled method 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
  • PageReference constructor with user-supplied URL (open redirect)
  • JavaScript output via {!} expressions with escape="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 sharing declarations 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 PageReference with 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) or String.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 justify without sharing)
  • DML operations check object CRUD permissions before executing
  • Queries use WITH SECURITY_ENFORCED or Security.stripInaccessible() to enforce FLS
  • @AuraEnabled methods accessible from community or partner portals use with 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 when escape="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:

  • PageReference constructors with user-supplied parameters validate the destination is a relative path or known-safe domain

AppExchange Compliance:


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:

VulnerabilityRoot CauseFix
SOQL injectionDynamic SOQL with concatenated inputBind variables; String.escapeSingleQuotes() for string literals
CRUD/FLS bypassDML without permission checksisCreateable()/isUpdateable()/isReadable() checks; WITH SECURITY_ENFORCED
without sharing bypassSharing keyword omitted or wrongAlways declare with sharing; audit without sharing classes
XSS in Visualforceescape="false" or raw JS contextRemove escape="false"; use JSENCODE() for JS contexts
Hardcoded credentialsAPI keys in Apex sourceNamed 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.

Dynamic testing

Benchmark a real DAST scanner on your lab

Evaluate Offensive360 DAST on an authorized lab or staging application. Review crawling, authentication coverage and available request/response evidence against a defined test plan.

Also see: Free Juice Shop benchmark kit (PDF) · Attack Surface Management · Autonomous Red Teaming

Benchmark a real DAST scanner on your lab

Book a DAST demo