Salesforce · · 30 min read

The Basics of Programmatic Security in Apex

Enforcing security in Apex code — with/without/inherited sharing, object and field-level security, SOQL injection prevention, Apex managed sharing, and building security-aware applications.

Part 43: The Basics of Programmatic Security in Apex

Welcome back to the Salesforce series. In the previous installments we covered Apex fundamentals — variables, collections, control flow, classes, and DML. All of that knowledge lets you build functionality. This post is about building functionality that respects security.

Here is the uncomfortable truth about Apex: by default, it runs in system context. That means your Apex code can read, create, update, and delete any record in the entire org regardless of the running user’s profile, permission sets, sharing rules, or field-level security settings. If you do not explicitly enforce security, your code is a wide-open backdoor.

This is Part 43 of the series, and it is one of the most important installments for any Salesforce developer. We will cover every layer of programmatic security — record-level sharing keywords, object-level security checks, field-level security enforcement, SOQL injection prevention, and Apex managed sharing. We finish with a hands-on project that ties all three security layers together.


The Three Layers of Salesforce Security

Before diving into code, let us review the three layers of security that Salesforce enforces declaratively but that Apex ignores by default:

  1. Record-Level Security (Sharing): Which specific records can a user see? Controlled by org-wide defaults, role hierarchy, sharing rules, manual shares, and territory management.
  2. Object-Level Security: Can the user access the object at all? Can they read, create, edit, or delete records of that object type? Controlled by profiles and permission sets.
  3. Field-Level Security: Can the user see or edit a specific field on an object? Controlled by profiles and permission sets.

When you write Apex, you must decide how much of this security to enforce in your code. The answer, in nearly every case, should be: enforce all of it.


The with, without, and inherited sharing Keywords

The first line of defense in Apex is the sharing keyword on your class declaration. This controls whether the running user’s record-level sharing rules are respected when the class executes SOQL queries and DML operations.

The Three Keywords

with sharing — The class respects the running user’s sharing rules. SOQL queries only return records the user has access to. DML operations respect record-level access.

public with sharing class AccountService {
    public List<Account> getAccounts() {
        // Only returns accounts the running user can see
        return [SELECT Id, Name FROM Account];
    }
}

without sharing — The class ignores the running user’s sharing rules entirely. SOQL queries return all records regardless of the user’s access. This runs in full system context for record visibility.

public without sharing class AccountService {
    public List<Account> getAccounts() {
        // Returns ALL accounts in the org, regardless of user access
        return [SELECT Id, Name FROM Account];
    }
}

inherited sharing — The class inherits the sharing context of the class that called it. If the caller uses with sharing, this class also uses with sharing. If the caller uses without sharing, this class also uses without sharing. If this class is the entry point (called directly from a Lightning component, Visualforce page, or trigger), it defaults to with sharing.

public inherited sharing class AccountService {
    public List<Account> getAccounts() {
        // Sharing context depends on whoever called this method
        return [SELECT Id, Name FROM Account];
    }
}

What Happens When You Specify No Keyword?

If you omit the sharing keyword entirely, the behavior is without sharing. This is the dangerous default. Your class runs in system context and sees everything.

// DANGEROUS: No sharing keyword means without sharing
public class AccountService {
    public List<Account> getAccounts() {
        // Returns ALL accounts — no sharing enforced
        return [SELECT Id, Name FROM Account];
    }
}

This is why every Apex class you write should have an explicit sharing keyword. Never rely on the default.

Sharing Keyword Comparison Table

KeywordRecord-Level SharingWhen to UseDefault Entry Point Behavior
with sharingEnforced — user sees only their recordsMost user-facing operations, LWC controllers, Visualforce controllersN/A — always enforces sharing
without sharingNot enforced — sees all recordsBackground jobs, triggers that need full visibility, admin utilitiesN/A — never enforces sharing
inherited sharingInherited from callerUtility classes, service layers called by multiple contextsDefaults to with sharing
No keyword specifiedNot enforcedNever — always specify a keywordRuns as without sharing

How Sharing Context Flows Through Method Calls

Sharing context is determined at the class level, not the method level. When one class calls another, the called class uses its own sharing keyword — not the caller’s — unless the called class uses inherited sharing.

public with sharing class CallerClass {
    public void doWork() {
        // This method runs with sharing
        WithoutSharingHelper helper = new WithoutSharingHelper();
        // The helper runs WITHOUT sharing, even though
        // this class runs WITH sharing
        helper.getAllAccounts();
    }
}

public without sharing class WithoutSharingHelper {
    public List<Account> getAllAccounts() {
        // Runs without sharing — returns all accounts
        return [SELECT Id, Name FROM Account];
    }
}

This means that a with sharing class can call a without sharing class and temporarily escalate privileges. This is sometimes intentional — for example, a controller that needs to fetch configuration records that the user cannot see directly. But it is also a common source of security holes.

When to Use Each Keyword

Use with sharing when:

  • Building controllers for Lightning Web Components
  • Building Visualforce controllers
  • Writing any code that executes in a user-facing context
  • You want to ensure users only see records they have access to

Use without sharing when:

  • Writing trigger handlers that need to query related records regardless of the triggering user’s access
  • Building batch jobs that process all records in the org
  • Creating admin utility classes
  • Querying org-wide configuration or metadata records

Use inherited sharing when:

  • Building reusable utility or service classes
  • Creating helper classes that are called from multiple contexts
  • You want the sharing behavior to be determined by the caller

Inner Classes and Sharing

Inner classes do not inherit the sharing keyword of their outer class. Each inner class must declare its own sharing keyword.

public with sharing class OuterClass {

    // This inner class does NOT inherit "with sharing"
    // It runs without sharing because no keyword is specified
    public class InnerHelper {
        public List<Account> getAll() {
            return [SELECT Id, Name FROM Account];
        }
    }

    // This inner class explicitly declares its sharing
    public with sharing class SecureInnerHelper {
        public List<Account> getVisible() {
            return [SELECT Id, Name FROM Account];
        }
    }
}

How to Enforce Object-Level Security in Apex

Sharing keywords handle record-level security, but they do nothing for object-level security. Even with with sharing, if a user’s profile does not grant read access to the Account object, your Apex code can still query accounts. You must enforce object-level security separately.

Using Schema Describe Calls

The traditional approach is to use Schema describe methods to check object permissions before performing operations.

public with sharing class AccountService {

    public List<Account> getAccounts() {
        // Check if the user can read Account records
        if (!Schema.sObjectType.Account.isAccessible()) {
            throw new SecurityException('You do not have access to Account records.');
        }
        return [SELECT Id, Name FROM Account];
    }

    public void createAccount(Account acc) {
        // Check if the user can create Account records
        if (!Schema.sObjectType.Account.isCreateable()) {
            throw new SecurityException('You do not have permission to create Account records.');
        }
        insert acc;
    }

    public void updateAccount(Account acc) {
        // Check if the user can update Account records
        if (!Schema.sObjectType.Account.isUpdateable()) {
            throw new SecurityException('You do not have permission to update Account records.');
        }
        update acc;
    }

    public void deleteAccount(Account acc) {
        // Check if the user can delete Account records
        if (!Schema.sObjectType.Account.isDeletable()) {
            throw new SecurityException('You do not have permission to delete Account records.');
        }
        delete acc;
    }
}

Object-Level Security Methods

MethodWhat It Checks
isAccessible()Can the user read records of this object type?
isCreateable()Can the user create records of this object type?
isUpdateable()Can the user update records of this object type?
isDeletable()Can the user delete records of this object type?
isUndeletable()Can the user undelete records of this object type?
isMergeable()Can the user merge records of this object type?

Using WITH SECURITY_ENFORCED in SOQL

Starting with API version 48.0, you can add WITH SECURITY_ENFORCED to any SOQL query. This enforces both object-level and field-level security at the query level. If the user lacks access to the object or any field in the SELECT clause, the query throws a System.QueryException.

public with sharing class AccountService {
    public List<Account> getAccounts() {
        // Throws QueryException if user lacks access to Account
        // or to any of the fields in the SELECT clause
        return [
            SELECT Id, Name, Industry, AnnualRevenue
            FROM Account
            WITH SECURITY_ENFORCED
        ];
    }
}

Pros: Simple, one-line enforcement. No manual describe checks needed.

Cons: It is all-or-nothing. If the user lacks access to even one field, the entire query fails. You cannot gracefully degrade by omitting inaccessible fields. It also does not work for DML — only SOQL.

Using WITH USER_MODE in SOQL

The WITH USER_MODE keyword (available from API version 56.0) is the newer and more flexible alternative. Instead of throwing an exception when a field is inaccessible, it silently strips inaccessible fields from the results.

public with sharing class AccountService {
    public List<Account> getAccounts() {
        // Runs the query as the current user
        // Inaccessible fields are silently excluded from results
        return [
            SELECT Id, Name, Industry, AnnualRevenue
            FROM Account
            WITH USER_MODE
        ];
    }
}

Pros: Graceful degradation — the query succeeds even if some fields are inaccessible. Also enforces sharing rules (record-level security) at the query level.

Cons: You may get null values for fields the user cannot see, which can cause unexpected NullPointerExceptions if your code does not handle that scenario.

There is also WITH SYSTEM_MODE, which explicitly runs the query in system context. This is useful for documentation purposes when you want to make it clear that a query intentionally bypasses security.

Using Security.stripInaccessible()

The Security.stripInaccessible() method strips fields from SObject records that the current user cannot access. It works on both query results and records before DML.

public with sharing class AccountService {

    public List<Account> getAccounts() {
        List<Account> accounts = [
            SELECT Id, Name, Industry, AnnualRevenue
            FROM Account
        ];

        // Strip fields the user cannot read
        SObjectAccessDecision decision = Security.stripInaccessible(
            AccessType.READABLE,
            accounts
        );

        return decision.getRecords();
    }

    public void createAccounts(List<Account> accounts) {
        // Strip fields the user cannot create
        SObjectAccessDecision decision = Security.stripInaccessible(
            AccessType.CREATABLE,
            accounts
        );

        insert decision.getRecords();
    }
}

Object-Level Security Enforcement Comparison

ApproachScopeBehavior on ViolationWorks With
Schema describe (isAccessible())Object-level onlyYou decide (throw, return empty, etc.)Any context
WITH SECURITY_ENFORCEDObject + field (SOQL only)Throws QueryExceptionSOQL queries
WITH USER_MODEObject + field + sharingSilently strips inaccessible fieldsSOQL queries, DML
Security.stripInaccessible()Field-level on recordsStrips inaccessible fields from recordsQuery results, DML records

How to Enforce Field-Level Security in Apex

Field-level security (FLS) is the most granular layer. Even if a user has access to the Account object, they may not have access to the AnnualRevenue field. In Apex, you must check FLS explicitly.

Using Schema Describe for Individual Fields

public with sharing class AccountService {

    public List<Account> getAccounts() {
        // Check object-level first
        if (!Schema.sObjectType.Account.isAccessible()) {
            throw new SecurityException('No access to Account.');
        }

        // Check field-level access
        Map<String, Schema.SObjectField> fieldMap =
            Schema.SObjectType.Account.fields.getMap();

        List<String> accessibleFields = new List<String>();
        accessibleFields.add('Id'); // Id is always accessible

        if (fieldMap.get('Name').getDescribe().isAccessible()) {
            accessibleFields.add('Name');
        }
        if (fieldMap.get('Industry').getDescribe().isAccessible()) {
            accessibleFields.add('Industry');
        }
        if (fieldMap.get('AnnualRevenue').getDescribe().isAccessible()) {
            accessibleFields.add('AnnualRevenue');
        }

        String query = 'SELECT ' + String.join(accessibleFields, ', ') +
                        ' FROM Account LIMIT 200';
        return Database.query(query);
    }
}

You can also check individual fields directly:

// Is the Name field on Account readable?
Boolean canReadName = Schema.SObjectType.Account.fields.Name.isAccessible();

// Is the AnnualRevenue field on Account editable?
Boolean canEditRevenue = Schema.SObjectType.Account.fields.AnnualRevenue.isUpdateable();

// Is the Rating field on Account creatable?
Boolean canCreateRating = Schema.SObjectType.Account.fields.Rating.isCreateable();

Field-Level Security Methods

MethodWhat It Checks
isAccessible()Can the user read this field?
isCreateable()Can the user set this field when creating a record?
isUpdateable()Can the user edit this field on an existing record?
isFilterable()Can the user filter by this field in a SOQL WHERE clause?
isGroupable()Can the user group by this field in a SOQL GROUP BY clause?
isSortable()Can the user sort by this field in a SOQL ORDER BY clause?

Using stripInaccessible with AccessType

The Security.stripInaccessible() method is the most practical approach for FLS enforcement. The AccessType enum controls what kind of access is checked:

// For reading — strip fields the user cannot see
SObjectAccessDecision decision = Security.stripInaccessible(
    AccessType.READABLE,
    records
);

// For creating — strip fields the user cannot set on insert
SObjectAccessDecision decision = Security.stripInaccessible(
    AccessType.CREATABLE,
    records
);

// For updating — strip fields the user cannot edit
SObjectAccessDecision decision = Security.stripInaccessible(
    AccessType.UPDATABLE,
    records
);

// For upserting — strip fields the user cannot upsert
SObjectAccessDecision decision = Security.stripInaccessible(
    AccessType.UPSERTABLE,
    records
);

You can also inspect which fields were removed:

SObjectAccessDecision decision = Security.stripInaccessible(
    AccessType.READABLE,
    accounts
);

// Get the map of removed fields per object type
Map<String, Set<String>> removedFields = decision.getRemovedFields();

if (removedFields.containsKey('Account')) {
    System.debug('Stripped fields: ' + removedFields.get('Account'));
}

Comparing FLS Enforcement Approaches

ApproachEffortGranularityGraceful Degradation
Manual Schema describe per fieldHigh — verbose, easy to miss fieldsPer-fieldYes — you control the response
WITH SECURITY_ENFORCEDLow — one keywordAll-or-nothingNo — throws exception
WITH USER_MODELow — one keywordPer-fieldYes — silently strips
Security.stripInaccessible()Medium — one method callPer-fieldYes — strips and reports

For most scenarios, WITH USER_MODE or Security.stripInaccessible() is the recommended approach. They provide per-field granularity with graceful degradation and minimal code.


Securing Queries Against SOQL Injection

SOQL injection is the Salesforce equivalent of SQL injection. It occurs when user-supplied input is concatenated directly into a SOQL query string, allowing an attacker to modify the query’s logic.

What SOQL Injection Looks Like

Consider this vulnerable code:

// VULNERABLE: User input is concatenated directly into the query
public with sharing class AccountSearch {
    public List<Account> searchByName(String userInput) {
        String query = 'SELECT Id, Name FROM Account WHERE Name = \'' + userInput + '\'';
        return Database.query(query);
    }
}

If a user passes a normal name like Acme, the query becomes:

SELECT Id, Name FROM Account WHERE Name = 'Acme'

That works fine. But if a malicious user passes ' OR Name != ', the query becomes:

SELECT Id, Name FROM Account WHERE Name = '' OR Name != ''

This returns every Account record in the org. The attacker has bypassed the intended filter.

Even worse, an attacker could pass ' OR Name LIKE '% to craft wildcard queries, or manipulate the query in other ways to extract data they should not see.

Defense 1: Use Bind Variables (Strongly Preferred)

The simplest and most effective defense is to use bind variables instead of string concatenation. Bind variables are never interpreted as part of the query structure — they are always treated as data.

// SECURE: Bind variable prevents injection
public with sharing class AccountSearch {
    public List<Account> searchByName(String userInput) {
        return [SELECT Id, Name FROM Account WHERE Name = :userInput];
    }
}

No matter what the user passes as userInput, it is treated as a literal string value. The query structure cannot be altered.

This is the recommended approach for all static SOQL queries.

Defense 2: String.escapeSingleQuotes()

When you must use dynamic SOQL (for example, when the fields or WHERE clauses are determined at runtime), use String.escapeSingleQuotes() to sanitize user input. This escapes single quotes by prepending a backslash, preventing the user from breaking out of a string literal.

// SECURE: escapeSingleQuotes prevents injection in dynamic SOQL
public with sharing class AccountSearch {
    public List<Account> searchByName(String userInput) {
        String sanitized = String.escapeSingleQuotes(userInput);
        String query = 'SELECT Id, Name FROM Account WHERE Name = \'' + sanitized + '\'';
        return Database.query(query);
    }
}

If the user passes ' OR Name != ', the sanitized value becomes \' OR Name != \', and the query looks for an account literally named ' OR Name != ' — which finds nothing.

Defense 3: Safe Dynamic SOQL Patterns

Sometimes you need dynamic SOQL for legitimate reasons — building queries based on user-selected filters, for instance. Here is a safe pattern:

public with sharing class DynamicAccountSearch {

    public List<Account> search(String nameFilter, String industryFilter) {
        String query = 'SELECT Id, Name, Industry FROM Account';
        List<String> conditions = new List<String>();
        List<Object> bindValues = new List<Object>();

        if (String.isNotBlank(nameFilter)) {
            // Use escapeSingleQuotes for dynamic SOQL
            conditions.add('Name LIKE \'%' +
                String.escapeSingleQuotes(nameFilter) + '%\'');
        }
        if (String.isNotBlank(industryFilter)) {
            conditions.add('Industry = \'' +
                String.escapeSingleQuotes(industryFilter) + '\'');
        }

        if (!conditions.isEmpty()) {
            query += ' WHERE ' + String.join(conditions, ' AND ');
        }

        query += ' WITH SECURITY_ENFORCED';
        query += ' LIMIT 200';

        return Database.query(query);
    }
}

SOQL Injection Prevention Summary

TechniqueWhen to UseEffectiveness
Bind variables (:variable)Static SOQL queries — always prefer thisFully prevents injection
String.escapeSingleQuotes()Dynamic SOQL where bind variables are not possiblePrevents injection via single-quote escaping
Whitelist validationWhen user input determines field names or object namesPrevents injection by restricting input to known-safe values
WITH USER_MODE / WITH SECURITY_ENFORCEDAll queriesDoes not prevent injection, but limits damage by enforcing security

What Not to Do

Never let user input control structural parts of a query without validation:

// EXTREMELY DANGEROUS: User controls the field name
public List<SObject> sortedQuery(String sortField) {
    String query = 'SELECT Id, Name FROM Account ORDER BY ' + sortField;
    return Database.query(query);
}

If the user passes Name; DELETE [SELECT Id FROM Account], the consequences could be severe. Always validate structural inputs against a whitelist:

// SECURE: Whitelist validation for structural query elements
public List<Account> sortedQuery(String sortField) {
    Set<String> allowedFields = new Set<String>{'Name', 'Industry', 'CreatedDate'};

    if (!allowedFields.contains(sortField)) {
        throw new SecurityException('Invalid sort field: ' + sortField);
    }

    String query = 'SELECT Id, Name, Industry FROM Account ORDER BY ' + sortField;
    return Database.query(query);
}

What Is Apex Custom Sharing?

Salesforce provides several declarative sharing mechanisms: org-wide defaults, the role hierarchy, sharing rules, manual sharing, and territory management. But sometimes these tools are not enough. When you need sharing logic that is too complex for declarative rules, Apex managed sharing (also called Apex custom sharing) lets you create sharing records programmatically.

The __Share Object

Every object in Salesforce has an associated Share object. For custom objects, the Share object is named by appending __Share to the custom object API name. For standard objects, it is the object name followed by Share.

  • Custom object Project__c has a Share object called Project__Share
  • Standard object Account has a Share object called AccountShare

Each Share record grants a specific user or group access to a specific record. The Share object has these key fields:

FieldDescription
ParentIdThe Id of the record being shared
UserOrGroupIdThe Id of the user or public group receiving access
AccessLevelThe level of access: Read, Edit, or All (All is owner-level, typically not used)
RowCauseThe reason for the share — identifies why this share record exists

Sharing Reasons (RowCause)

For custom objects, you can define custom sharing reasons. These are created in the object’s setup page under Apex Sharing Reasons. Each reason gets an API name that you reference in code.

The RowCause field can have several values:

  • Manual — Created by a user clicking the Share button, or by Apex code that does not specify a custom reason.
  • Rule — Created by a sharing rule.
  • Owner — The record owner’s implicit share.
  • ImplicitChild — Sharing inherited from a parent record.
  • Custom reasons — Your Apex sharing reasons, referenced as Schema.ObjectName__Share.RowCause.ReasonApiName__c.

Custom sharing reasons are important because they protect your share records. When a share record has a custom RowCause, only Apex code can modify or delete it. Users cannot remove these shares manually, and they are not deleted when the record owner changes.


When to Use Apex Custom Sharing

Use Apex custom sharing when declarative sharing cannot express your business requirements. Common scenarios include:

1. Complex Sharing Logic

When sharing depends on conditions that sharing rules cannot evaluate — for example, sharing a record with the account team of a related account, or sharing based on a combination of field values across multiple objects.

2. Time-Based Sharing

When records should be shared temporarily. For example, sharing a case record with an external consultant for 30 days. You can create the share record with Apex and schedule a batch job to remove it after 30 days.

3. Sharing Based on External Data

When the sharing decision depends on data from an external system. For example, sharing project records with users based on their role in an external project management tool.

4. Sharing Across Unrelated Objects

When you need to share records of one object based on ownership or access to records of a completely unrelated object. Declarative sharing rules can only reference the object’s own fields or its owner’s role.

5. Dynamic Sharing Based on Record Content

When the set of users who should have access changes based on the record’s field values — and those conditions are too complex for criteria-based sharing rules.


Standard Object Apex Custom Sharing Drawbacks

Apex managed sharing on standard objects has significant limitations compared to custom objects.

No Custom Sharing Reasons

Standard objects do not support custom Apex sharing reasons. You can only use Manual as the RowCause. This has serious consequences:

AspectCustom Object (Custom RowCause)Standard Object (Manual RowCause)
RowCauseCustom reason (e.g., Team_Access__c)Manual only
Owner change behaviorShare records are preservedShare records are deleted
Manual removal by usersNot possible — only Apex can modifyUsers with appropriate access can delete
IdentificationEasy to identify programmatic sharesCannot distinguish Apex shares from manual shares
RecalculationCan be recalculated independentlyMixed in with all manual shares

Share Records Deleted on Owner Change

This is the biggest drawback. When the owner of a standard object record changes, all share records with RowCause = Manual are deleted. This means any sharing you created via Apex is lost.

To work around this, you need to use a trigger on the standard object that detects owner changes and re-creates the share records.

// Trigger to re-create sharing after owner change on Account
trigger AccountOwnerChange on Account (after update) {
    List<Account> ownerChangedAccounts = new List<Account>();

    for (Account acc : Trigger.new) {
        Account oldAcc = Trigger.oldMap.get(acc.Id);
        if (acc.OwnerId != oldAcc.OwnerId) {
            ownerChangedAccounts.add(acc);
        }
    }

    if (!ownerChangedAccounts.isEmpty()) {
        AccountSharingService.recreateSharing(ownerChangedAccounts);
    }
}

Cannot Distinguish Programmatic From Manual Shares

Because all programmatic shares on standard objects use the Manual RowCause, you cannot distinguish between shares your code created and shares a user created by clicking the Share button. This makes it difficult to manage your programmatic shares without accidentally deleting user-created manual shares.


Apex Custom Sharing Example

Let us walk through a complete example. Suppose you have a custom object called Project__c. You want to share every project record with all members of the project’s associated team, stored in a related Project_Member__c junction object.

Step 1: Create a Custom Sharing Reason

In Setup, navigate to the Project__c object, scroll to Apex Sharing Reasons, and create a new reason:

  • Label: Team Member Access
  • Name: Team_Member_Access

Step 2: Build the Sharing Service

public without sharing class ProjectSharingService {

    // Grant access to all team members for the given projects
    public static void shareWithTeamMembers(Set<Id> projectIds) {
        // Query project members for all relevant projects
        List<Project_Member__c> members = [
            SELECT Project__c, User__c
            FROM Project_Member__c
            WHERE Project__c IN :projectIds
        ];

        // Build a list of share records to insert
        List<Project__Share> sharesToInsert = new List<Project__Share>();

        for (Project_Member__c member : members) {
            Project__Share share = new Project__Share();
            share.ParentId = member.Project__c;
            share.UserOrGroupId = member.User__c;
            share.AccessLevel = 'Edit';
            share.RowCause = Schema.Project__Share.RowCause.Team_Member_Access__c;
            sharesToInsert.add(share);
        }

        if (!sharesToInsert.isEmpty()) {
            // Use Database.insert with allOrNone = false
            // because duplicate shares will fail
            List<Database.SaveResult> results =
                Database.insert(sharesToInsert, false);

            for (Database.SaveResult result : results) {
                if (!result.isSuccess()) {
                    for (Database.Error err : result.getErrors()) {
                        System.debug(LoggingLevel.ERROR,
                            'Share insert failed: ' + err.getMessage());
                    }
                }
            }
        }
    }

    // Remove all team member shares for the given projects
    public static void removeTeamMemberSharing(Set<Id> projectIds) {
        List<Project__Share> existingShares = [
            SELECT Id
            FROM Project__Share
            WHERE ParentId IN :projectIds
            AND RowCause = :Schema.Project__Share.RowCause.Team_Member_Access__c
        ];

        if (!existingShares.isEmpty()) {
            delete existingShares;
        }
    }

    // Recalculate sharing for the given projects
    // Removes old shares and creates new ones
    public static void recalculateSharing(Set<Id> projectIds) {
        removeTeamMemberSharing(projectIds);
        shareWithTeamMembers(projectIds);
    }
}

Step 3: Call From a Trigger

trigger ProjectMemberTrigger on Project_Member__c (after insert, after delete) {
    Set<Id> projectIds = new Set<Id>();

    if (Trigger.isInsert) {
        for (Project_Member__c member : Trigger.new) {
            projectIds.add(member.Project__c);
        }
    }

    if (Trigger.isDelete) {
        for (Project_Member__c member : Trigger.old) {
            projectIds.add(member.Project__c);
        }
    }

    if (!projectIds.isEmpty()) {
        ProjectSharingService.recalculateSharing(projectIds);
    }
}

Why without sharing?

Notice that ProjectSharingService uses without sharing. This is intentional. The sharing service needs to see all project members and all existing share records to correctly calculate sharing. If it ran with sharing, a user who cannot see certain project members would create incomplete sharing.

Standard Object Equivalent

For a standard object like Account, the code looks similar but uses Manual as the RowCause:

public without sharing class AccountSharingService {

    public static void shareAccountsWithUsers(
        Map<Id, Set<Id>> accountToUserIds
    ) {
        List<AccountShare> sharesToInsert = new List<AccountShare>();

        for (Id accountId : accountToUserIds.keySet()) {
            for (Id userId : accountToUserIds.get(accountId)) {
                AccountShare share = new AccountShare();
                share.AccountId = accountId;       // Note: AccountId, not ParentId
                share.UserOrGroupId = userId;
                share.AccountAccessLevel = 'Edit'; // Note: AccountAccessLevel, not AccessLevel
                share.OpportunityAccessLevel = 'Read'; // Required for AccountShare
                share.RowCause = 'Manual';
                sharesToInsert.add(share);
            }
        }

        if (!sharesToInsert.isEmpty()) {
            Database.insert(sharesToInsert, false);
        }
    }
}

Note the differences for standard object shares: AccountShare uses AccountId instead of ParentId and AccountAccessLevel instead of AccessLevel. The AccountShare object also requires OpportunityAccessLevel to be set.


PROJECT: Setting Up an Apex Class to Respect Object, Field, and Record Security

Let us build a secure service class from the ground up. This class will enforce all three layers of security: record-level sharing, object-level security, and field-level security.

The Scenario

You are building a service class for an LWC component that allows users to search for and view account records. The component displays a list of accounts with selected fields, and users should only see records and fields they have access to.

Step 1: Create the Service Class

public with sharing class SecureAccountService {

    // Custom exception for security violations
    public class SecurityException extends Exception {}

    /**
     * Search accounts by name. Enforces all three security layers:
     * - Record-level: with sharing keyword
     * - Object-level: Schema describe check
     * - Field-level: WITH USER_MODE
     */
    public static List<Account> searchAccounts(String searchTerm) {
        // Layer 1: Record-level security is handled by "with sharing"

        // Layer 2: Enforce object-level security
        if (!Schema.sObjectType.Account.isAccessible()) {
            throw new SecurityException(
                'Insufficient permissions to access Account records.'
            );
        }

        // Validate and sanitize input to prevent SOQL injection
        if (String.isBlank(searchTerm)) {
            throw new IllegalArgumentException(
                'Search term cannot be blank.'
            );
        }

        String sanitizedTerm = '%' + String.escapeSingleQuotes(searchTerm.trim()) + '%';

        // Layer 3: Enforce field-level security with USER_MODE
        // USER_MODE silently strips inaccessible fields
        return [
            SELECT Id, Name, Industry, AnnualRevenue, Phone, Website,
                   BillingCity, BillingState
            FROM Account
            WHERE Name LIKE :sanitizedTerm
            WITH USER_MODE
            ORDER BY Name
            LIMIT 50
        ];
    }

    /**
     * Get a single account by Id.
     * Uses stripInaccessible so we can report which fields were hidden.
     */
    public static AccountResult getAccountById(Id accountId) {
        // Layer 2: Object-level check
        if (!Schema.sObjectType.Account.isAccessible()) {
            throw new SecurityException(
                'Insufficient permissions to access Account records.'
            );
        }

        // Layer 1: "with sharing" ensures user can see this specific record
        List<Account> accounts = [
            SELECT Id, Name, Industry, AnnualRevenue, Phone, Website,
                   BillingStreet, BillingCity, BillingState, BillingPostalCode,
                   NumberOfEmployees, Description
            FROM Account
            WHERE Id = :accountId
        ];

        if (accounts.isEmpty()) {
            return null; // Record not found or user cannot see it
        }

        // Layer 3: Strip inaccessible fields
        SObjectAccessDecision decision = Security.stripInaccessible(
            AccessType.READABLE,
            accounts
        );

        AccountResult result = new AccountResult();
        result.record = (Account) decision.getRecords()[0];

        // Report which fields were stripped
        Map<String, Set<String>> removed = decision.getRemovedFields();
        if (removed.containsKey('Account')) {
            result.hiddenFields = new List<String>(removed.get('Account'));
        } else {
            result.hiddenFields = new List<String>();
        }

        return result;
    }

    /**
     * Update an account record securely.
     */
    public static void updateAccount(Account acc) {
        // Layer 2: Object-level check for update
        if (!Schema.sObjectType.Account.isUpdateable()) {
            throw new SecurityException(
                'Insufficient permissions to update Account records.'
            );
        }

        // Layer 3: Strip fields the user cannot update
        SObjectAccessDecision decision = Security.stripInaccessible(
            AccessType.UPDATABLE,
            new List<Account>{ acc }
        );

        // Layer 1: "with sharing" ensures the user owns or has edit
        // access to this record
        update decision.getRecords();
    }

    /**
     * Wrapper class for returning an account with metadata about
     * hidden fields.
     */
    public class AccountResult {
        @AuraEnabled public Account record;
        @AuraEnabled public List<String> hiddenFields;
    }
}

Step 2: Write a Test Class

A proper test verifies that security enforcement actually works. You need to test with different user profiles to confirm that restricted users are blocked.

@IsTest
private class SecureAccountServiceTest {

    @TestSetup
    static void setup() {
        // Create test accounts as the admin user
        List<Account> testAccounts = new List<Account>();
        for (Integer i = 0; i < 5; i++) {
            testAccounts.add(new Account(
                Name = 'Test Account ' + i,
                Industry = 'Technology',
                AnnualRevenue = 1000000
            ));
        }
        insert testAccounts;
    }

    @IsTest
    static void testSearchAccounts_AdminUser() {
        // Admin users should see all matching records
        List<Account> results = SecureAccountService.searchAccounts('Test');
        System.assertEquals(5, results.size(),
            'Admin should see all 5 test accounts');
    }

    @IsTest
    static void testSearchAccounts_BlankInput() {
        try {
            SecureAccountService.searchAccounts('');
            System.assert(false, 'Should have thrown IllegalArgumentException');
        } catch (IllegalArgumentException e) {
            System.assert(e.getMessage().contains('cannot be blank'));
        }
    }

    @IsTest
    static void testGetAccountById_Found() {
        Account acc = [SELECT Id FROM Account LIMIT 1];
        SecureAccountService.AccountResult result =
            SecureAccountService.getAccountById(acc.Id);
        System.assertNotEquals(null, result,
            'Should return a result for a valid account');
        System.assertNotEquals(null, result.record,
            'Result should contain the account record');
    }

    @IsTest
    static void testGetAccountById_NotFound() {
        // Use a fake Id that does not exist
        Id fakeId = '001000000000000AAA';
        SecureAccountService.AccountResult result =
            SecureAccountService.getAccountById(fakeId);
        System.assertEquals(null, result,
            'Should return null for non-existent account');
    }

    @IsTest
    static void testUpdateAccount() {
        Account acc = [SELECT Id, Name FROM Account LIMIT 1];
        acc.Name = 'Updated Name';
        SecureAccountService.updateAccount(acc);

        Account updated = [SELECT Name FROM Account WHERE Id = :acc.Id];
        System.assertEquals('Updated Name', updated.Name,
            'Account name should be updated');
    }

    @IsTest
    static void testSearchAccounts_InjectionAttempt() {
        // Attempt SOQL injection — should return no results,
        // not all records
        List<Account> results =
            SecureAccountService.searchAccounts('\' OR Name != \'');
        System.assertEquals(0, results.size(),
            'Injection attempt should return no results');
    }
}

Step 3: Review What Each Layer Does

Let us trace through a call to searchAccounts('Acme') and see each security layer in action:

  1. Record-Level Security — The with sharing keyword on SecureAccountService means the SOQL query only returns Account records the running user has access to based on OWD, role hierarchy, sharing rules, and manual shares.

  2. Object-Level Security — The Schema.sObjectType.Account.isAccessible() check runs before the query. If the user’s profile does not grant read access to the Account object, a SecurityException is thrown before any query executes.

  3. Field-Level Security — The WITH USER_MODE keyword in the SOQL query ensures that any fields in the SELECT clause that the user cannot read are silently stripped from the results. The query still succeeds, but inaccessible fields return null.

  4. SOQL Injection Prevention — The String.escapeSingleQuotes() call sanitizes the search term. Additionally, using a bind variable (:sanitizedTerm) in the LIKE clause provides a second layer of protection.

The Security Checklist

Every time you write a new Apex class that accesses data, run through this checklist:

CheckQuestionHow to Enforce
Sharing keywordDoes this class have an explicit sharing keyword?Add with sharing, without sharing, or inherited sharing
Object accessDoes the code verify the user can access this object?Schema.sObjectType.ObjectName.isAccessible() and related methods
Field accessDoes the code enforce field-level security?WITH USER_MODE, WITH SECURITY_ENFORCED, or Security.stripInaccessible()
Input sanitizationIs user input sanitized before use in queries?Bind variables, String.escapeSingleQuotes(), whitelist validation
Sharing escalationDoes any method call escalate to without sharing?Review all helper class sharing keywords
Test coverageAre there tests that run as restricted users?System.runAs() with non-admin profiles

Summary

Apex runs in system context by default. If you do not explicitly enforce security, your code bypasses every permission the admin carefully configured. This post covered every tool Salesforce gives you to write secure Apex:

  • Sharing keywords control record-level visibility. Use with sharing for user-facing code, without sharing only when you have a clear reason, and inherited sharing for reusable utility classes.
  • Object-level security is enforced through Schema describe calls (isAccessible(), isCreateable(), isUpdateable(), isDeletable()).
  • Field-level security is enforced through WITH USER_MODE, WITH SECURITY_ENFORCED, Security.stripInaccessible(), or manual field describe checks.
  • SOQL injection is prevented by using bind variables in static SOQL and String.escapeSingleQuotes() in dynamic SOQL.
  • Apex managed sharing lets you create sharing records programmatically when declarative tools fall short. Custom objects support custom sharing reasons for durable shares; standard objects are limited to Manual RowCause with its associated drawbacks.

The project at the end demonstrated how to weave all three security layers together in a single service class. This is the pattern you should follow for every Apex class that touches data in a user-facing context.


In Part 44, we move to one of the most critical topics in Apex development — Apex Triggers in Salesforce. We will cover trigger events, trigger context variables, the trigger handler pattern, bulkification, order of execution, and building triggers that scale. See you there.