Skip to main content

Command Palette

Search for a command to run...

Salesforce Summer '26: Apex Database Operations Are Secure by Default

Updated
β€’9 min read
Salesforce Summer '26: Apex Database Operations Are Secure by Default

The Problem Nobody Liked Admitting

Here is something every Salesforce developer has bumped into at least once:

You write an Apex query. It works in your sandbox. You deploy. Then a reviewer asks the familiar question: "Where are the CRUD and FLS checks?" Because your code was quietly running in system mode the whole time.

Not a bug. Just the way Apex has always worked.

Apex runs with elevated privileges by default. Object permissions and FLS are not enforced like they are in the Salesforce UI unless the developer actively enforces them. Sharing behavior depends on the class declaration and execution context. Over the years, Salesforce added tools to help: WITH SECURITY_ENFORCED, Security.stripInaccessible(), the AccessLevel parameter. All useful, all opt-in.

The problem with opt-in security is that it depends on every developer on every team remembering to use it every time. That works only if every developer remembers every time. In a large org, that is wishful thinking.

Summer '26 changes the default.


What Changed in API 67.0

Three things shift when you compile an Apex class at API version 67.0:

1. Database operations run in user mode by default. Plain SOQL, SOSL, and DML now respect the running user's object permissions, FLS, and sharing rules without explicit user-mode syntax.

2. Classes without a sharing keyword default to with sharing. Previously, omitting with sharing meant the class inherited the sharing mode of whatever called it β€” which could be anything. In API v67, omission means with sharing. The ambiguity is gone.

3. Triggers always run in system mode. There were edge cases where sharing rules were unexpectedly enforced inside trigger contexts. That inconsistency is resolved β€” triggers are always system mode, regardless of API version.

And one deprecation: WITH SECURITY_ENFORCED is on its way out. Salesforce wants you using explicit USER_MODE / SYSTEM_MODE declarations instead, which are more consistent and support the full query β€” not just SELECT fields.

⚠️ These changes are version-specific. Existing classes on API v66 or earlier keep the old behavior. Your existing classes do not change behavior just because the org is upgraded β€” but once you bump a class to API v67, you should test it carefully.


Prerequisites

  • Salesforce Summer '26

  • API version 67.0 set on your class metadata

  • Salesforce CLI v2.x + VS Code with the Extension Pack

  • A sandbox, scratch org, or Developer Edition

  • Deploy permissions + a Standard User for realistic permission testing


The Code

User Mode (The New Default)

At API v67, a plain SOQL query and DML statement now run in user mode. The platform enforces the running user's object permissions, FLS, and sharing rules automatically: I’m using AnnualRevenue here because Name is required on Account and is less useful for demonstrating field-level access.

public with sharing class UserModeDatabaseOperations {

    public static void demonstrateUserMode() {
        try {
            List<Account> accounts = [SELECT Id, Name, AnnualRevenue FROM Account LIMIT 5];
            System.debug('Retrieved: ' + accounts.size());

            Account newAccount = new Account(Name = 'Test Account from User Mode', AnnualRevenue = 10500);
            insert newAccount;

        } catch (QueryException e) {
            System.debug('Query blocked by permissions: ' + e.getMessage());
        } catch (DmlException e) {
            System.debug('DML blocked by permissions: ' + e.getMessage());
        }
    }
}

If the running user lacks Read access on Account, Create access on Account, or field access to AnnualRevenue, this can throw. That is the point β€” the platform is now surfacing permission mismatches at runtime instead of silently bypassing them.


System Mode (Intentional Escalation)

System mode still exists. You just have to mean it:

public static void demonstrateSystemMode() {
    List<Account> accounts = Database.query(
        'SELECT Id, Name, AnnualRevenue FROM Account LIMIT 5',
        AccessLevel.SYSTEM_MODE
    );

    Account newAccount = new Account(Name = 'Test Account from System Mode', AnnualRevenue = 10500);
    Database.insert(newAccount, AccessLevel.SYSTEM_MODE);
}

The value of making SYSTEM_MODE explicit is that it shows up in a code review. Anyone reading this has to ask: why does this operation need elevated access? That is exactly the conversation you want teams having.

Valid reasons to use system mode include admin-owned automation, audit logging, data repair utilities, and trusted integration logic. The key word is deliberate.


Mixed Mode

You can use both access levels within a single transaction. A common pattern: read records within the user's permission boundary, then write a system-owned record the user has no direct Create access to:

public static void demonstrateMixedMode() {
    // User only sees records they have access to
    List<Account> userAccounts = Database.query(
        'SELECT Id, Name, AnnualRevenue FROM Account LIMIT 2',
        AccessLevel.USER_MODE
    );

    // Trusted platform write β€” intentional escalation
    Account systemAccount = new Account(Name = 'System Mode Account', AnnualRevenue = 10500);
    Database.insert(systemAccount, AccessLevel.SYSTEM_MODE);
}

Use this pattern deliberately. A good rule: user mode for anything that represents the user's action, system mode only for trusted backend logic.


Understanding FLS Enforcement

This is where a common misconception trips developers up, so it is worth being precise.

User mode throws on FLS violations β€” it does not silently strip fields.

If a field in your query is inaccessible to the running user, user mode raises a QueryException. You get an exception, not a result with null fields:

public static void demonstrateFieldLevelSecurity() {
    try {
        List<Account> accounts = Database.query(
            'SELECT Id, Name, AnnualRevenue, Phone, BillingCity FROM Account LIMIT 1',
            AccessLevel.USER_MODE
        );

        if (!accounts.isEmpty()) {
            Account acc = accounts[0];
            System.debug('Name: ' + acc.Name);
            System.debug('AnnualRevenue: ' + acc.AnnualRevenue);
            System.debug('Phone: ' + acc.Phone);
            System.debug('BillingCity: ' + acc.BillingCity);
        }

    } catch (QueryException e) {
        // Thrown if any queried field is inaccessible to the running user
        System.debug('FLS violation: ' + e.getMessage());

        // Use getInaccessibleFields() to identify which fields failed
        Map<String, Set<String>> blocked = e.getInaccessibleFields();
        System.debug('Blocked fields: ' + blocked);
    }
}

This is meaningfully different from Security.stripInaccessible(), which is designed for graceful degradation β€” it removes inaccessible fields from results and lets execution continue. Salesforce's own documentation describes stripInaccessible() as the right choice "instead of failing on an access violation when using WITH USER_MODE."

So the choice is deliberate:

User mode         β†’ strict enforcement, throws on violation
stripInaccessible β†’ graceful degradation, removes inaccessible fields and continues

For most new code, start with user mode. Reach for stripInaccessible() when your design requires partial results or controlled fallback behavior rather than a hard failure.


Testing with System.runAs

Without System.runAs, your tests can miss the permission boundary that real users experience.

@isTest
public class UserModeDatabaseOperationsTest {

    @TestSetup
    static void setupTestData() {
        Database.insert(new Account(Name = 'Test Account', AnnualRevenue = 10000), AccessLevel.SYSTEM_MODE);
    }

    @isTest
    static void testUserModeOperations() {
        System.runAs(createStandardUser()) {
            UserModeDatabaseOperations.demonstrateUserMode();
        }
    }

    @isTest
    static void testSystemModeOperations() {
        System.runAs(createStandardUser()) {
            UserModeDatabaseOperations.demonstrateSystemMode();

            List<Account> inserted = [SELECT Id FROM Account WHERE Name = 'Test Account from System Mode'];
            Assert.isTrue(inserted.size() > 0, 'System mode insert should bypass user permissions');
        }
    }

    @isTest
    static void testMixedModeOperations() {
        System.runAs(createStandardUser()) {
            UserModeDatabaseOperations.demonstrateMixedMode();

            List<Account> result = [SELECT Id FROM Account WHERE Name = 'System Mode Account'];
            Assert.isTrue(result.size() > 0, 'System-mode write should succeed in mixed transaction');
        }
    }

    @isTest
    static void testFieldLevelSecurity() {
        System.runAs(createStandardUser()) {
            // Wrap in try/catch β€” user mode throws a QueryException on FLS violations
            try {
                UserModeDatabaseOperations.demonstrateFieldLevelSecurity();
            } catch (QueryException e) {
                // Expected if the Standard User lacks access to queried fields
                Assert.isTrue(
                    e.getInaccessibleFields() != null,
                    'FLS violation should surface inaccessible fields'
                );
            }
        }
    }

    private static User createStandardUser() {
        Profile p = [SELECT Id FROM Profile WHERE Name = 'Standard User' LIMIT 1];
        User u = new User(
            FirstName = 'Test', LastName = 'User',
            Email = 'testuser@example.com',
            Username = 'testuser' + DateTime.now().getTime() + '@example.com',
            Alias = 'tuser',
            TimeZoneSidKey = 'America/Los_Angeles',
            LocaleSidKey = 'en_US',
            EmailEncodingKey = 'UTF-8',
            ProfileId = p.Id,
            LanguageLocaleKey = 'en_US'
        );
        Database.insert(u, AccessLevel.SYSTEM_MODE);
        return u;
    }
}

For real enterprise coverage, go further: test users with different profiles, with and without permission sets, and validate both expected failures and expected successes. The point is not code coverage β€” it is proving your Apex behaves correctly at the permission boundary.


Project Structure

force-app/
└── main/
    └── default/
        └── classes/
            β”œβ”€β”€ UserModeDatabaseOperations.cls
            β”œβ”€β”€ UserModeDatabaseOperations.cls-meta.xml
            β”œβ”€β”€ UserModeDatabaseOperationsTest.cls
            └── UserModeDatabaseOperationsTest.cls-meta.xml

Why This Matters Beyond the Classroom

In CI/CD, upgrading to API v67 can surface permission assumptions your pipeline was silently passing for years. That is uncomfortable short-term and valuable long-term. A validation run with RunAllTestsInOrg before a Summer '26 release window is not optional.

In enterprise orgs, security should not depend on every developer remembering every platform nuance. API v67 moves secure behavior to the default. SYSTEM_MODE appearing in a code review is now a flag worth pausing on, rather than a behavior that could go undetected for months.

For managed packages, explicit access levels make your security intent legible to Salesforce's security review team. The less implicit behavior in your package, the fewer questions during review.

Under pressure, a developer writing a hotfix at 2am is not going to remember every security nuance. API v67 means forgetting is less dangerous than it used to be.


What to Remember

  • API v67 makes user mode the default β€” object permissions, FLS, and sharing rules are enforced on plain SOQL, SOSL, and DML without explicit user-mode syntax.

  • User mode throws on violations β€” a QueryException is raised when a queried field is inaccessible. Use getInaccessibleFields() on the exception to see which fields failed.

  • stripInaccessible() is for graceful degradation β€” it removes inaccessible fields and lets execution continue. It is a different tool for a different design intent.

  • SYSTEM_MODE must be intentional β€” it bypasses user permissions and should always have a clear justification.

  • The sharing keyword default changed β€” no declaration means with sharing in API v67.

  • Triggers are always system mode β€” this is now consistent across all API versions.

  • WITH SECURITY_ENFORCED is deprecated β€” migrate to explicit USER_MODE or SYSTEM_MODE access patterns.

  • Old classes are unaffected unless you explicitly bump their API version to 67.

  • System.runAs is not optional if you want tests that actually validate security behavior.


πŸ“š Resources


One Question Before You Go

When you start bumping your org's Apex classes to API v67, where do you expect to find the most unintentional system-mode dependencies β€” service classes, utility libraries, triggers, or managed package code?

Drop your answer in the comments. Especially curious to hear from teams already running against Summer '26 preview orgs.