Log Entry
Secure-by-default Apex in Summer '26
Summer '26 is the Apex security release I would not skim.
The individual changes are easy to read as separate notes, but together they point in one direction: Apex is becoming more secure by default, and elevated access now needs to be more deliberate.
That is good.
It is also exactly the kind of change that can surprise teams if they upgrade API versions casually.
Quick snapshot (What / Where / Why)
- What: Apex database operations default to user mode in API
67.0+. - What else: Apex classes without an explicit sharing declaration default to
with sharingin API67.0+. - Removal:
WITH SECURITY_ENFORCEDis removed for Apex classes saved on API67.0+; useWITH USER_MODE. - Trigger nuance: Apex triggers always run in system mode, so trigger code still needs intentional access boundaries.
- Why: fewer accidental data leaks, clearer privilege elevation, and code that is easier to reason about in review.
The important mental shift is this:
If code needs elevated access, say so in the code.
The old habit that becomes risky
For years, Apex made it easy to write code like this:
public class CaseSummaryController {
@AuraEnabled(cacheable=true)
public static List<Case> recentCases() {
return [
SELECT Id, Subject, Internal_Notes__c
FROM Case
LIMIT 20
];
}
}
That code has two hidden assumptions:
- the class does not say whether sharing rules apply
- the query does not say whether user permissions apply
In older API versions, that often meant more access than the running user had in the UI. The code might return records the user could not normally see, or fields they did not have field-level access to.
In API 67.0+, those omitted choices move towards the safer answer.
That is useful because the default failure mode changes from "maybe exposed too much" to "maybe returned less or failed because the user cannot access it".
I would much rather debug a permission failure than explain a data leak.
Example 1: User-facing controller
For an LWC or Aura controller, make the intended access model explicit.
public with sharing class CaseSummaryController {
@AuraEnabled(cacheable=true)
public static List<Case> recentCases() {
return [
SELECT Id, Subject
FROM Case
WITH USER_MODE
LIMIT 20
];
}
}
This says two useful things to the next developer:
with sharing: record visibility should follow sharing rulesWITH USER_MODE: object permissions, field-level security, and record access should be enforced for this query
The code now matches the shape of a normal user-facing feature. If the user cannot see a field or record in Salesforce, the Apex path should not casually expose it either.
Example 2: DML should be just as explicit
Read security gets most of the attention, but write security matters just as much.
public with sharing class OpportunityRatingService {
public static void markHot(Id opportunityId) {
Opportunity opp = [
SELECT Id, Rating__c
FROM Opportunity
WHERE Id = :opportunityId
WITH USER_MODE
LIMIT 1
];
opp.Rating__c = 'Hot';
update as user opp;
}
}
This makes the whole operation user-aware:
- the query can only read what the current user is allowed to read
- the update can only write what the current user is allowed to write
That is especially useful for service classes called from multiple places. The caller does not have to remember to perform separate CRUD and FLS checks before invoking the method; the database operation itself carries the access decision.
Example 3: Explicit system mode is still valid
Secure by default does not mean "never use system mode".
Some code really is platform plumbing: audit records, integration sync state, internal queue bookkeeping, denormalised rollups, repair jobs. The difference is that elevated access should now look intentional.
public without sharing class IntegrationSyncLogService {
public static void recordFailure(Id sourceRecordId, String message) {
Integration_Sync_Log__c log = new Integration_Sync_Log__c(
Source_Record_Id__c = String.valueOf(sourceRecordId),
Message__c = message.left(255)
);
insert as system log;
}
}
That is honest code. It tells reviewers that this write is deliberately elevated.
The trap is not system mode. The trap is accidental system mode.
Example 4: WITH SECURITY_ENFORCED migration
This is the mechanical bit teams can search for.
Before:
List<Account> accounts = [
SELECT Id, Name, Owner.Name
FROM Account
WITH SECURITY_ENFORCED
];
After:
List<Account> accounts = [
SELECT Id, Name, Owner.Name
FROM Account
WITH USER_MODE
];
That is the simple replacement in many cases, but do not treat it as a blind find-and-replace.
WITH USER_MODE is broader than WITH SECURITY_ENFORCED. It is the right direction, but it can expose behaviour you were not testing before: fields used in filters, relationship fields, record visibility, and write access on later DML.
So I would migrate in small batches and test with realistic low-permission users, not only admins.
Example 5: Handling inaccessible fields deliberately
If you want a hard failure when the query asks for fields the user cannot access, catch that clearly.
public with sharing class ContactDirectoryService {
@AuraEnabled(cacheable=true)
public static List<Contact> search(String term) {
String normalisedTerm = String.isBlank(term) ? '' : term.trim();
String likeTerm = '%' + normalisedTerm + '%';
try {
return [
SELECT Id, Name, Email, Salary_Band__c
FROM Contact
WHERE Name LIKE :likeTerm
WITH USER_MODE
LIMIT 50
];
} catch (QueryException e) {
throw new AuraHandledException(
'You do not have access to one or more fields used by this search.'
);
}
}
}
That message is boring, which is exactly what I want from a security error.
For UI code, do not leak the field name unless there is a product reason to reveal it. Log enough detail for admins or developers through your normal internal logging path, but keep the user-facing message plain.
Example 6: Dynamic SOQL needs the same decision
Dynamic queries are where security intent tends to disappear.
public with sharing class AccountSearchService {
public static List<Account> byRating(String rating) {
return Database.query(
'SELECT Id, Name FROM Account WHERE Rating = :rating',
AccessLevel.USER_MODE
);
}
}
That second argument is the important part. It means the dynamic query has the same access posture as a static query using WITH USER_MODE.
If the query genuinely needs elevated access, use AccessLevel.SYSTEM_MODE and make sure the method name, class declaration, and tests make that choice obvious.
The trigger surprise
Triggers are the part that can make people overcorrect.
Summer '26 says triggers always run in system mode. You also cannot declare a trigger as with sharing or without sharing.
That does not mean every trigger handler should ignore user access. It means the trigger is the entry point, and your handler should make access decisions where they matter.
trigger InvoiceTrigger on Invoice__c (before insert, before update) {
InvoiceTriggerHandler.beforeSave(Trigger.new);
}
public with sharing class InvoiceTriggerHandler {
public static void beforeSave(List<Invoice__c> invoices) {
Set<Id> accountIds = new Set<Id>();
for (Invoice__c invoice : invoices) {
if (invoice.Account__c != null) {
accountIds.add(invoice.Account__c);
}
}
Map<Id, Account> visibleAccounts = new Map<Id, Account>([
SELECT Id, Status__c
FROM Account
WHERE Id IN :accountIds
WITH USER_MODE
]);
for (Invoice__c invoice : invoices) {
if (
invoice.Account__c != null &&
!visibleAccounts.containsKey(invoice.Account__c)
) {
invoice.addError('You do not have access to the related account.');
}
}
}
}
That example is intentionally conservative. Not every trigger should block based on user visibility. Some trigger logic is system-owned and should stay system-owned.
The useful pattern is separation:
- trigger receives the platform event
- handler contains the business decision
- each query or DML operation declares user mode or system mode intentionally
What I would audit before API 67 upgrades
I would not mass-upgrade Apex classes to API 67.0 and hope the tests explain the fallout.
Start with these searches:
rg "WITH SECURITY_ENFORCED" force-app/main/default
rg "public class|global class|private class" force-app/main/default/classes
rg "Database\\.query|Search\\.query|insert |update |upsert |delete |undelete " force-app/main/default/classes
Then review the hits through this lens:
- Does this class have an explicit
with sharing,without sharing, orinherited sharingdeclaration? - Does this query or DML operation need user mode or system mode?
- Is the code user-facing, integration-facing, scheduled, batch, queueable, or trigger-driven?
- Do the tests run as a realistic non-admin user?
- Would returning fewer rows be acceptable, or does the code assume full visibility?
- Would DML fail if the user lacks field or object access?
The most suspicious code is usually old controller code, integration helper code, and async code that was written assuming "Apex sees everything".
Why this is useful
This change makes Apex code easier to review.
Before, a missing keyword often meant you had to know a pile of platform defaults and historical exceptions. Now, new API 67.0+ code leans towards the safer interpretation, and elevated access has to become visible.
That helps in a few practical ways:
- code review can focus on explicit access choices
- junior developers are less likely to leak fields accidentally
- tests can catch permission problems earlier
- security review conversations become less hand-wavy
- user-facing Apex behaves more like the UI security model people expect
It also makes architecture cleaner. A service that updates internal sync records can say as system. A controller that returns data to an LWC can say WITH USER_MODE. A shared domain service can use inherited sharing if it really should follow the caller.
Those are design decisions, not trivia.
My practical rule
Do not rely on Apex security defaults either way.
For new or upgraded code:
- put a sharing declaration on every class
- put an access mode on every meaningful query, search, and DML operation
- replace
WITH SECURITY_ENFORCEDbefore moving a class to API67.0+ - test with low-permission users before testing with admins
- keep trigger handlers explicit about which operations are user-mode and which are system-mode
The release makes Apex safer by default, but the best codebase is still explicit.
Source notes
Final take
I like this change a lot.
Not because it removes the need to understand Apex security. It does not.
I like it because it changes the default posture from "powerful unless you remembered the guardrails" to "safer unless you deliberately elevate".
That is a healthier baseline for real orgs, real teams, and real code written under deadline pressure.