Ask a group of Salesforce developers what with sharing protects and many will say “the data.” It only decides which rows a user sees. Field access and object access are separate checks, and Apex security in Summer ‘26 changed how all of them behave. From API v67.0, database operations run in user mode by default, classes with no sharing keyword default to with sharing, and WITH SECURITY_ENFORCED no longer compiles.
What follows is about how the keywords, clauses and methods fit together. If you are in the middle of a version bump and something just broke, the API v67 user mode scenario is the faster read. Org-wide defaults, roles and sharing rules are covered in the security model deep dive.
Access control has four layers
Each layer is independent. Getting one right tells you nothing about the others. Object and field permissions increasingly live in permission sets; the permission sets migration guide covers that move.
| Layer | What it controls | Where it’s set |
|---|---|---|
| Object (CRUD) | Whether the user can use this object at all | Profiles and permission sets |
| Field (FLS) | Which fields on the object the user can see or edit | Profiles and permission sets |
| Record (sharing) | Which rows the user can see | Org-wide defaults, role hierarchy, sharing rules |
| Exposure | Whether the code path can be called, and by whom | @AuraEnabled, Apex REST, invocable methods, guest user profile |
with sharing covers the record layer and nothing else. It doesn’t check object permissions or field-level security. “I added with sharing, so it’s secure” is the answer that fails security reviews.
What changed in API v67.0
| Behaviour | API 66 and earlier | API 67 and later |
|---|---|---|
| Default mode for SOQL, SOSL and DML | System mode: object permissions and FLS are bypassed | User mode: object permissions, FLS and sharing are enforced |
| Class with no sharing keyword | Depends on context (see the next section) | Runs with sharing |
WITH SECURITY_ENFORCED | Works | Compile error |
Three points that trip people up:
- The change applies per class. Upgrading the org changes nothing. Each class keeps its old behaviour until its own
apiVersionin the-meta.xmlfile is raised to 67.0. - Triggers always run in system mode, on every API version.
- Some classes are most likely to break on a version bump:
@AuraEnabledcontrollers,@InvocableMethodclasses, Apex REST services and batch classes. These often relied on system mode without anyone noticing. Expect insufficient-access errors or fewer rows.
What an omitted sharing keyword does on older versions
A common belief is that “no keyword means without sharing.” For entry points on older versions, that isn’t reliably true. The Apex Developer Guide lists the rules for a class with no declaration on API 66 or earlier, applied in this order:
- If any class in its inheritance chain is saved at API 67 or later, it runs
with sharing. - If it is an Aura controller, or an
@AuraEnabledmethod called from a Lightning web component, it runswith sharing. - If it isn’t an Apex entry point, it uses the sharing mode of the class that called it.
- Otherwise, it runs
without sharing.
So an Aura or LWC controller with no keyword still enforces sharing, while a Visualforce controller or an Apex REST service with no keyword falls through to rule 4 and runs without sharing.
This is easy to confuse with inherited sharing. A class declared inherited sharing runs with sharing whenever it is an entry point, including Visualforce controllers, Apex REST services and async Apex. Anonymous Apex always runs with sharing.
Working out the effective mode of an undeclared v66 class means tracing both its inheritance chain and its callers. That is why Salesforce recommends always declaring a sharing keyword on any class that queries or modifies data.
Choosing the mode per operation
// Static SOQL
List<Account> a1 = [SELECT Id FROM Account WITH USER_MODE];
List<Account> a2 = [SELECT Id FROM Account WITH SYSTEM_MODE];
// Dynamic SOQL
List<Account> a3 = Database.query(queryString, AccessLevel.USER_MODE);
List<Account> a4 = Database.queryWithBinds(queryString, binds, AccessLevel.USER_MODE);
// DML, keyword form
insert as user newAccounts;
update as system accountsToFix;
// DML, Database method form
Database.insert(newAccounts, AccessLevel.USER_MODE);
How the class keyword and the access mode combine
| Class keyword | Operation mode | Sharing enforced | Object permissions and FLS enforced |
|---|---|---|---|
with sharing | System mode | Yes | No |
with sharing | User mode | Yes | Yes |
without sharing | System mode | No | No |
without sharing | User mode | Yes | Yes |
The last row surprises people and comes up in interviews. An explicit without sharing on the class doesn’t override WITH USER_MODE on the query. User mode enforces sharing regardless.
WITH SECURITY_ENFORCED vs WITH USER_MODE
WITH SECURITY_ENFORCED (API 66 and earlier) | WITH USER_MODE | |
|---|---|---|
| Which parts of the query are checked | SELECT and FROM only | The whole query, including WHERE and ORDER BY |
| Record sharing | Not applied | Applied |
Polymorphic fields (such as Owner or Task.WhatId) | Not handled | Covered |
| DML support | Queries only | Yes, through as user or AccessLevel |
| On a violation | Throws | Throws |
The WHERE clause gap
With WITH SECURITY_ENFORCED, this query succeeds for a user who can’t read SSN__c, because the field appears only in the filter:
List<Account> accts = [
SELECT Id FROM Account
WHERE SSN__c != null
WITH SECURITY_ENFORCED
];
The field never shows up in the results, so nothing looks wrong. But a caller who can control the filter or sort order can page through results and work out the field’s values. WITH USER_MODE closes that gap by checking every clause.
The error messages differ on purpose
| Clause | Error on a restricted field | What it reveals |
|---|---|---|
WITH SECURITY_ENFORCED | ”Insufficient permissions: secure query included inaccessible field” | Confirms the field exists |
WITH USER_MODE | ”No such column ‘SSN__c’ on entity ‘Account‘“ | Doesn’t confirm the field exists |
User mode hides the schema from users who can’t see it.
Degrading gracefully with stripInaccessible
Both clauses throw when they hit a restricted field. When a feature should keep working with less data (typically a UI), use Security.stripInaccessible:
SObjectAccessDecision decision = Security.stripInaccessible(
AccessType.READABLE,
[SELECT Id, Name, SSN__c FROM Account WHERE Industry = :industry]
);
List<Account> visible = decision.getRecords(); // SSN__c removed, rows kept
Set<String> removed = decision.getRemovedFields().get('Account');
stripInaccessible handles object and field access only. It doesn’t enforce record sharing, so pair it with a with sharing class or a user-mode query.
Throw or degrade?
| Context | Choice | Reason |
|---|---|---|
| Integration or background job | Throw (WITH USER_MODE) | A loud failure is safer than silently incomplete data |
| UI component | Degrade (stripInaccessible) | The page keeps working with the fields the user can see |
Being able to explain why you picked one is what interviewers look for.
A worked example
Setup: a restricted user has no FLS on Test_Field__c. Account org-wide default is Private and the user owns 9 of 20 Accounts. The code is an Aura controller with no sharing keyword.
| Class API version | Query clause | Result |
|---|---|---|
| 66 or earlier (tested on v64) | None | 9 rows and no error. Sharing is enforced because it is an Aura entry point, but FLS isn’t, so the restricted field comes back |
| 66 or earlier | WITH SECURITY_ENFORCED | Throws “insufficient permissions” |
| 67 or later | None | Throws “No such column”, because user mode is now the default |
| 67 or later | WITH USER_MODE | Same error, confirming user mode was already in effect |
The first row is the lesson. The record layer worked and the field layer didn’t. And if the component doesn’t render that field, the page looks perfectly fine while the value sits in the network response for anyone who opens browser developer tools. A page that looks right is no evidence that access is right.
The exposure layer
Every @AuraEnabled method is a public endpoint. Any authenticated user can call it directly, whatever the UI shows, so hiding a component does nothing for access control. Apex REST services and invocable methods work the same way.
Least privilege applies to your SELECT clause too. If a component only shows names, don’t query SSN__c, because the value lands in the response whether you render it or not.
Lightning Data Service enforces object permissions, FLS and sharing automatically. Apex on API 66 or earlier doesn’t, and that difference answers most LWC record-access questions.
Injection is a separate risk that user mode doesn’t address. See the SOQL injection guide.
What reviewers and interviewers listen for
- Name the layer. “This is a field-level security gap; sharing is already working” is stronger than “I’d add
with sharing.” - State the API version. The correct answer often differs between v66 and v67, and saying so shows you are current.
- Don’t propose
WITH SECURITY_ENFORCEDas a fix. It no longer compiles on v67 and dates your knowledge.