Apex CRUD and FLS enforcement is the question every Salesforce security review asks of every query and every write: does this code respect what the running user is allowed to see and change? Sharing keywords answer a different question (which records). Object permissions (CRUD) and field-level security (FLS) decide which objects and which fields, and for most of Apex's history the platform did not check them for you.
There are now four tools for the job, and they are not interchangeable. This post covers what each one does, where each one falls short, and which to reach for in new code and in old code.
Why does Apex not enforce CRUD and FLS by default?
Through API version 66.0, SOQL, SOSL, DML and Database methods run in system mode. A query returns every field it names, whether or not the running user could open that field on a record page. If a Lightning component, an Apex REST method or a Visualforce page returns the result, the user sees data their profile was meant to hide.
API version 67.0 (Summer '26) changes that default. Salesforce's Summer '26 architect highlights state that from v67.0 those operations enforce the running user's permissions, that classes with no sharing declaration default to with sharing, and that WITH SECURITY_ENFORCED is removed: a class on v67.0 that still uses it will not compile. Triggers stay in system mode.
The API version is set per class, so most orgs will run both models side by side for years. Code written for 66.0 and earlier still needs explicit enforcement, and code moving to 67.0 needs an audit.
WITH USER_MODE and AccessLevel.USER_MODE
User mode is the current recommendation. It runs a database operation as the running user: object permissions, field-level security and sharing rules all apply. Salesforce documents it in Set an Access Mode for Database Operations.
// Static SOQL
List<Contact> rows = [
SELECT Id, Name, Email, Birthdate
FROM Contact
WHERE AccountId = :accountId
WITH USER_MODE
];
// Dynamic SOQL
List<Contact> dyn = Database.queryWithBinds(
'SELECT Id, Name FROM Contact WHERE AccountId = :acct',
new Map<String, Object>{ 'acct' => accountId },
AccessLevel.USER_MODE
);
// DML
insert as user newContacts;
Database.update(changed, AccessLevel.USER_MODE);What makes it better than the older clause:
- It checks every clause of the query, including the
WHEREclause, so a user cannot probe a hidden field by filtering on it. - It handles polymorphic relationship fields.
- On failure,
QueryException.getInaccessibleFields()reports the full set of fields the user could not read, which makes the error actionable. - It covers writes as well as reads, through
as userandAccessLevel.USER_MODEonDatabasemethods. - It enforces sharing even inside a
without sharingclass. That is usually what you want on a user-facing path, and occasionally a surprise.
The trade-off is that user mode is all or nothing. If the user lacks access to one field, the query throws. That is right for a controller that must not return partial data, and wrong for a page that should render whatever the user can see.
Security.stripInaccessible
Security.stripInaccessible takes records you already have and removes the fields the running user cannot access for a given access type (READABLE, CREATABLE, UPDATABLE, UPSERTABLE). It returns an SObjectAccessDecision with the cleaned records and, through getRemovedFields(), a map of what was taken out.
List<Contact> raw = [SELECT Id, Name, Email, Birthdate FROM Contact
WHERE AccountId = :accountId];
SObjectAccessDecision d =
Security.stripInaccessible(AccessType.READABLE, raw);
return d.getRecords(); // Birthdate silently gone for users who cannot read itUse it when the right behaviour is to degrade gracefully rather than fail. It is also the standard fix for mass assignment: strip a caller-supplied record with AccessType.UPDATABLE before update, so fields the user cannot edit are dropped instead of written.
What it does not do: it works on fields of records already returned. It does not change which records the query returned, so record visibility still depends on the class's sharing mode. And it only protects the records you pass through it. A second query in the same method that skips the call is unprotected.
WITH SECURITY_ENFORCED, and why to migrate off it
WITH SECURITY_ENFORCED was the first declarative option, and a lot of code still uses it. The Apex reference lists its limits, and Salesforce now recommends WITH USER_MODE instead:
- It checks only fields and objects in the
SELECTandFROMclauses, not fields used only inWHERE. - Traversing a polymorphic relationship is not supported, apart from
Owner,CreatedByandLastModifiedBy. - It covers reads only. Writes need separate checks.
- It does not enforce sharing; the class keyword still decides that.
- It does not compile in a class saved at API 67.0.
Salesforce also advises against combining it with user mode in one query. Treat every occurrence as migration work: swap it for WITH USER_MODE, run the tests as a user with a restricted permission set, and fix the queries that now throw.
Manual describe checks
The oldest approach is to check describe results before acting:
if (!Schema.sObjectType.Contact.fields.Email.isUpdateable()) {
throw new AuraHandledException('You cannot edit email addresses.');
}It still works and gives the finest control over the error message, but it is easy to get wrong: a check on one field and a write to five, or a check in one branch and DML in another. Prefer user mode or stripInaccessible, and keep describe checks for the rare case where neither fits.
Which one should you use?
| Situation | Use |
|---|---|
| New user-facing query or write, data must be complete | WITH USER_MODE, as user, AccessLevel.USER_MODE |
| Show what the user can see, hide the rest | Security.stripInaccessible(AccessType.READABLE, ...) |
| Caller-supplied record before DML | Security.stripInaccessible(AccessType.UPDATABLE, ...) then DML |
Existing WITH SECURITY_ENFORCED |
Migrate to WITH USER_MODE |
| Deliberate system access (batch, aggregate, config) | WITH SYSTEM_MODE or AccessLevel.SYSTEM_MODE, in a small class with a comment saying why |
The last row matters as much as the others. On v67.0, system mode becomes the opt-out, so any code that genuinely needs it should say so explicitly instead of relying on a default that just changed.
How Vulkro checks this
Vulkro for Salesforce reads Apex locally and flags reads that return restricted fields with no WITH USER_MODE, stripInaccessible or describe check, and writes with no CRUD check or AccessLevel.USER_MODE. It also raises an advisory on each WITH SECURITY_ENFORCED so the migration has a list. Related rules are listed on the Apex detectors page: for example SF-PRIVESC-SYS-001 (a without sharing class writing request data with no CRUD or FLS check) and mass-delete-unfiltered-no-authz.
vulkro-sf scan ./force-appFAQ
What is the difference between WITH USER_MODE and WITH SECURITY_ENFORCED?
WITH USER_MODE enforces object permissions, field-level security and sharing on every clause of the query and reports all inaccessible fields. WITH SECURITY_ENFORCED checks only the SELECT and FROM clauses, does not enforce sharing, and no longer compiles at API 67.0.
Does Security.stripInaccessible enforce sharing?
No. It removes fields the user cannot access from records you already have. Which records were returned depends on the query's sharing mode, so pair it with with sharing or a user-mode query.
Do I still need CRUD and FLS checks after API 67.0?
Classes saved at 67.0 enforce the running user's permissions by default, but classes on older versions do not, and triggers always run in system mode. Declaring WITH USER_MODE explicitly keeps the intent clear whatever version a class is on.
Start a 14-day trial from /start/ to scan your Apex. Related reading: with sharing vs without sharing in Apex and Apex SOQL injection.