SapotaCorp

Apex CRUD and FLS: SECURITY_ENFORCED vs stripInaccessible

Apex code that reads or writes records can bypass field-level security and CRUD permissions by default. Two patterns enforce these checks, each with different ergonomics and audit implications. Picking the right one matters for AppExchange security reviews and internal compliance.

Apex CRUD and FLS: SECURITY_ENFORCED vs stripInaccessible

Key takeaways

  • Apex bypasses field-level security and CRUD permissions by default. Code that runs in system mode can read and write fields the calling user has no permission to see. The default is convenient for admins and dangerous for ISV-grade code.
  • 2 patterns enforce CRUD and FLS: WITH SECURITY_ENFORCED (SOQL clause that filters out fields the user cannot access) and Security.stripInaccessible (programmatic check that strips inaccessible fields before DML). Each has different ergonomics and audit implications.
  • WITH SECURITY_ENFORCED is the modern default. Inline on the SOQL query, fails the query if any selected field is inaccessible, predictable behaviour. Use this on every Apex SOQL that returns data to a user-facing surface.
  • stripInaccessible is the alternative for cases where SECURITY_ENFORCED is too aggressive. Returns a sanitized list with inaccessible fields nulled rather than throwing. Useful for bulk processing where some records have accessible fields and some do not. Both patterns pass ISV Security Review; WITH SECURITY_ENFORCED is preferred for clarity.

A Salesforce user has profile-level access to the Account object but not to the Contact object. They visit a page that lists Accounts and, for each Account, shows the primary contact. The page should fail to show contact data for that user; instead, contact records appear. The bug: the Apex controller does not enforce field-level security or CRUD checks, so it returns data the user is not entitled to see.

This is the canonical CRUD/FLS bypass. Apex code runs with elevated permissions by default. The user's profile and permission sets matter for declarative work (page layouts, list views), but Apex queries and DML bypass these unless the code explicitly enforces them.

Two patterns enforce CRUD and FLS in Apex. They have different ergonomics, different audit implications, and different applicability. Knowing when to use which is the difference between Apex code that passes Salesforce's Security Review (required for AppExchange) and code that fails.

What "without enforcement" looks like

A typical Apex method that violates CRUD/FLS by default:

public with sharing class ContactController {
  @AuraEnabled
  public static List<Contact> getContactsForAccount(Id accountId) {
    return [SELECT Id, FirstName, LastName, Email, Phone
            FROM Contact
            WHERE AccountId = :accountId];
  }
}

The class uses with sharing, which enforces row-level sharing rules. But field-level security and object-level CRUD are not enforced. A user without read access to Contact.Email will see emails anyway. A user without object-level read on Contact will see Contacts (sharing-allowed records) anyway.

The with sharing keyword is necessary but not sufficient. CRUD and FLS need their own enforcement.

Pattern 1: WITH SECURITY_ENFORCED

Added to SOQL in 2019 and stable since. The query refuses to execute if the running user lacks CRUD on any queried object or FLS on any queried field.

public with sharing class ContactController {
  @AuraEnabled
  public static List<Contact> getContactsForAccount(Id accountId) {
    return [SELECT Id, FirstName, LastName, Email, Phone
            FROM Contact
            WHERE AccountId = :accountId
            WITH SECURITY_ENFORCED];
  }
}

Behavior:

  • User lacks read on Contact: QueryException at runtime.
  • User lacks FLS on Email: QueryException at runtime.
  • All accessible: query returns rows normally.

What's good: one keyword, all CRUD and FLS in scope. Compile-time errors for typos. The query either fully complies or fails loudly.

What's limited:

  • Only works in inline SOQL, not in Database.query.
  • Only enforces on read. DML still requires separate enforcement.
  • The failure is binary: query throws. The caller has to handle the exception. No partial result.

WITH SECURITY_ENFORCED is the right choice when the calling code expects all-or-nothing access. The user either sees all the data or sees an error.

Pattern 2: Security.stripInaccessible

Introduced shortly after WITH SECURITY_ENFORCED, this approach strips inaccessible fields from a record collection instead of throwing.

public with sharing class ContactController {
  @AuraEnabled
  public static List<Contact> getContactsForAccount(Id accountId) {
    List<Contact> contacts = [SELECT Id, FirstName, LastName, Email, Phone
                              FROM Contact
                              WHERE AccountId = :accountId];

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

    return decision.getRecords();
  }
}

Behavior:

  • User lacks read on Contact: decision.getRecords() returns an empty list.
  • User lacks FLS on Email: the Email field is stripped from every returned record. Other fields visible normally.
  • All accessible: every record and field returned.

What's good: graceful degradation. The user sees what they are entitled to see. Useful for read scenarios where partial access is meaningful.

What's tricky:

  • Works for read, create, update, and delete (AccessType.CREATABLE, etc.).
  • Result is wrapped in SObjectAccessDecision. Caller has to extract via getRecords() (or getRemovedFields() for diagnostics).
  • The stripped fields are unset on the returned records. Apex code that subsequently accesses them gets null. Test for this.

Security.stripInaccessible is the right choice when the calling code can handle missing data gracefully (e.g., a list view that just shows fewer columns for users with restricted FLS).

Pattern 3: USER_MODE vs SYSTEM_MODE (newer)

Salesforce introduced explicit access mode modifiers in Spring 2023:

List<Contact> contacts = [SELECT Id, Email FROM Contact
                          WHERE AccountId = :accountId
                          WITH USER_MODE];

WITH USER_MODE is roughly equivalent to combining WITH SECURITY_ENFORCED plus sharing enforcement. The query runs as if the current user issued it: CRUD, FLS, and sharing rules all apply.

WITH SYSTEM_MODE is the inverse: explicit acknowledgment that the query bypasses all of those. Used when the code legitimately needs elevated access (e.g., a status-update operation that runs on behalf of all users).

For new code, prefer WITH USER_MODE as the default. Use WITH SYSTEM_MODE only when justified, and document why.

DML supports the same pattern via Database.insert(records, AccessLevel.USER_MODE) and similar.

When each pattern fits

A practical decision guide:

Scenario Pattern
Standard read query, all-or-nothing access WITH USER_MODE (preferred) or WITH SECURITY_ENFORCED
Read query, graceful degradation OK Security.stripInaccessible after the query
DML on user-provided data Database.insert(records, AccessLevel.USER_MODE) or Security.stripInaccessible before insert
Legitimately elevated operation (system job) WITH SYSTEM_MODE with explicit documentation
Dynamic SOQL (Database.query) Security.stripInaccessible (since WITH clauses do not work in dynamic SOQL)

The pattern selected should match the operational reality: does the user expect to see all the data or be told nothing is available, or can the UI handle partial visibility.

Sharing vs CRUD/FLS

Three orthogonal security layers in Apex:

Layer What it controls Enforced by
Sharing Which records the user can see (row-level) with sharing keyword
CRUD Which objects the user can read/create/update/delete WITH SECURITY_ENFORCED, USER_MODE, stripInaccessible
FLS Which fields the user can see on accessible objects Same as CRUD

All three should be enforced in standard Apex code. with sharing is necessary but only covers one layer. The other two need separate enforcement.

The without sharing keyword exists for cases where the code legitimately needs to bypass sharing (e.g., a customer service operation that needs to see all cases regardless of ownership). It is rarely the right answer; document the justification when used.

inherited sharing is a third option that inherits the calling context's sharing mode. Useful for utility classes called from many places.

Common security mistakes

Five patterns Sapota has seen in audits:

  • with sharing alone, no CRUD/FLS enforcement. Sharing covers rows but FLS is bypassed. Users see fields they should not.
  • without sharing as a default. Class declared without sharing "just in case." Every method runs with elevated access regardless of need.
  • Dynamic SOQL with no stripInaccessible. WITH clauses do not work in Database.query. The query runs as system. Add Security.stripInaccessible after.
  • DML without access checks. insert records; runs without checking if the user is allowed to create those records. Use Database.insert(records, AccessLevel.USER_MODE).
  • Security check, then ignored result. Security.stripInaccessible(...) called but the original (non-stripped) list is returned to the caller. The check was theater.

Security Review for AppExchange

Apex code in an AppExchange-bound package must pass Salesforce Security Review. The review specifically checks CRUD/FLS enforcement. Code that bypasses these by default fails the review.

The patterns that pass:

  • Every SOQL query uses WITH USER_MODE or WITH SECURITY_ENFORCED, or every result passes through stripInaccessible.
  • Every DML statement uses Database.insert/update with USER_MODE, or records pass through stripInaccessible before DML.
  • without sharing is used sparingly with documented justification.
  • with sharing is the default on all controller-style classes.

For ISVs preparing for Security Review, the audit pass should explicitly grep for unenforced queries and DML. Tools like Salesforce Code Analyzer (PMD-based) flag these patterns.

What good Apex security looks like

A Salesforce org or managed package with healthy security discipline:

  • with sharing (or inherited sharing) is the default on every controller-style class.
  • Every SOQL on user-touched data uses USER_MODE, SECURITY_ENFORCED, or stripInaccessible.
  • Every DML on user-provided data enforces CRUD via the same patterns.
  • without sharing and SYSTEM_MODE used with documented justification.
  • Code review checks security patterns explicitly.

Sapota's Salesforce team holds the Platform Developer I credential and treats CRUD/FLS enforcement as a code review gating criterion. The patterns are well-defined; the discipline is what keeps Apex code passing internal audits and Security Review.


Auditing Apex code for CRUD/FLS compliance or preparing for Security Review? Sapota's Salesforce team handles security audits, refactor design, and AppExchange Security Review prep on production engagements. Get in touch ->

See our full platform services for the stack we cover.

Contact Us Now

Share Your Story

We build trust by delivering what we promise – the first time and every time!

We'd love to hear your vision. Our IT experts will reach out to you during business hours to discuss making it happen.

WHY CHOOSE US

"Collaborate, Elevate, Celebrate where Associates - Create Project Excellence"

SapotaCorp beyond the IT industry standard, we are

  • Certificated
  • Assured quality
  • Extra maintenance

Tell us about your project