Apex CRUD and FLS: the top review failure
Object and field permission enforcement is the single most common reason an Apex codebase fails a Salesforce Security Review. It is also the failure that is hardest to see from inside your own org, because the org you develop in is the org that hides it.
This page covers what a reviewer actually checks, the API version fork that changed the correct answer in 2026, the remediation shapes ranked by what you should reach for first, and how to get the complete list for a codebase in one offline pass.
For the detector-level reference, see Apex detectors. For the whole submission checklist, see AppExchange readiness.
1. What the reviewer actually checks
CRUD and FLS are two separate questions the platform answers separately, and a review treats them as one mandatory item.
- CRUD is object level: can this user create, read, update or delete this object at all.
- FLS is field level: of the fields on that object, which ones may this user see, and which ones may this user write.
Neither is enforced for you in Apex by default below API version 67.0.
A plain SOQL query runs in system mode. Plain DML runs in system mode.
with sharing does not help here: sharing is record level, so it
decides which rows the user sees and says nothing about which
objects and fields they are entitled to. A class can be with sharing, correct on every record filter, and still hand an unprivileged
caller a field they have no read permission on.
The reviewer reads the code for exactly this. Every request reachable
entry point (@AuraEnabled, @RestResource, webservice, a
Visualforce controller, a Flow invocable) is walked to every data
operation it can reach, and each operation has to be enforced or
explicitly justified.
Here is the shape that fails:
@RestResource(urlMapping='/cases/*')
global with sharing class CaseApi {
@HttpGet
global static List<Case> listByStatus() {
String status = RestContext.request.params.get('status');
return [
SELECT Id, Subject, Description, SuppliedEmail
FROM Case
WHERE Status = :status
];
}
@HttpPost
global static Id create(String subject, String internalNote) {
Case c = new Case(Subject = subject, Description = internalNote);
insert c;
return c.Id;
}
}
The record filter is fine. The class is with sharing. It still ships
Description and SuppliedEmail to any authenticated caller who can
reach the endpoint, whatever their field permissions say, and it still
writes Description for a user who has no write access to it.
Why testing as a System Administrator hides all of it
The System Administrator profile has read and write on effectively every object and every field. Run this endpoint as yourself and every permission check that would have failed passes silently. The code behaves identically whether the enforcement is there or not, so your tests are green, your manual clickthrough is clean, and the defect is invisible until a reviewer opens the source or a customer runs it as a real user.
The cheap habit that catches it: create a permission set with the
minimum access the feature needs, assign it to a user with a low
privilege profile, and run the endpoint as that user with
System.runAs. The habit that catches it every time, on every class,
is reading the code statically, which is what the review does and what
a scanner does.
2. The API version fork
This is the part most guidance on the internet is now wrong about, and getting it wrong costs you a deploy.
API 67.0 (Summer '26) inverted the default. The boundary is pre-67.0 versus 67.0 and later. It is not 61.0.
At API 67.0 and above:
- SOQL, SOSL, DML and
Databasemethods run in user mode by default. CRUD and FLS are enforced unless you explicitly opt out. - A class with no sharing declaration defaults to
with sharingrather than inheriting the caller's mode. WITH SECURITY_ENFORCEDis removed. It is not deprecated, it is gone. A class pinned to 67.0 or later that still carries the clause will not compile.
Below API 67.0, none of that is true: plain operations run in system mode and enforce nothing on their own.
The consequence for a real codebase is that the same unannotated line
is a finding in one class and correct in another, and the only thing
that separates them is the API version that class compiles at. That
version comes from the class's sibling *.cls-meta.xml, falling back
to sourceApiVersion in sfdx-project.json. An org with a mixed
version history has both regimes live at once.
At 67.0 and later, the thing worth hunting is the opt out:
WITH SYSTEM_MODE on a query, AccessLevel.SYSTEM_MODE on a
Database call, as system on DML, or a without sharing
declaration. Those are now the only ways an operation still runs
unenforced, so each one needs a reason.
3. Remediation shapes, ranked
These are not four equivalent options. They are a first choice, a fallback, a precision tool, and a legacy clause you are migrating off.
First choice: user mode
WITH USER_MODE on reads and AccessLevel.USER_MODE on writes is the
current answer. It is available from API 55.0, it compiles at every
version from there up including 67.0 and later, it enforces CRUD and
FLS together, it supports polymorphic lookup fields, and its exception
reports exactly which fields were inaccessible through
getInaccessibleFields().
@RestResource(urlMapping='/cases/*')
global with sharing class CaseApi {
@HttpGet
global static List<Case> listByStatus() {
String status = RestContext.request.params.get('status');
return [
SELECT Id, Subject, Description, SuppliedEmail
FROM Case
WHERE Status = :status
WITH USER_MODE
];
}
@HttpPost
global static Id create(String subject, String internalNote) {
Case c = new Case(Subject = subject, Description = internalNote);
insert as user c;
return c.Id;
}
}
The Database forms take the access level as an argument, which is
what you want when you need partial success handling or dynamic SOQL:
List<Case> rows = Database.query(soql, AccessLevel.USER_MODE);
List<Database.SaveResult> results =
Database.insert(cases, false, AccessLevel.USER_MODE);
Note that user mode makes the operation fail loudly. That is the point, and it is why this change belongs in a release you test, not in a hotfix.
Second: strip the fields when the query must stay broad
Sometimes the query genuinely has to read fields the caller cannot see, because the class computes over them and returns something derived. In that case run the query as is and strip before the data reaches the caller:
public with sharing class CaseExport {
public static List<Case> readableCases(Set<Id> caseIds) {
List<Case> rows = [
SELECT Id, Subject, Description, SuppliedEmail, Origin
FROM Case
WHERE Id IN :caseIds
];
SObjectAccessDecision decision =
Security.stripInaccessible(AccessType.READABLE, rows);
return (List<Case>) decision.getRecords();
}
}
decision.getRemovedFields() tells you what was taken out, which is
useful for logging and for proving to a reviewer that the step is real.
Use AccessType.CREATABLE before an insert and AccessType.UPDATABLE
before an update.
Third: explicit describe checks for what the platform modes miss
Reach for isAccessible(), isCreateable(), isUpdateable() and
isDeletable() when you need to make a decision before touching the
data: return a different response, hide a UI affordance, or fail with
your own message rather than a platform exception.
public with sharing class CaseArchiver {
public class AccessException extends Exception {}
public static void archive(List<Case> cases) {
Schema.DescribeSObjectResult caseInfo = Case.SObjectType.getDescribe();
if (!caseInfo.isUpdateable()) {
throw new AccessException('No update access on Case.');
}
if (!Schema.SObjectType.Case.fields.Status.isUpdateable()) {
throw new AccessException('No write access to Case.Status.');
}
for (Case c : cases) {
c.Status = 'Closed';
}
update as user cases;
}
}
The as user on the DML is deliberate: the describe checks decide the
control flow and produce your own error message, and the platform still
enforces the write itself. Describe checks are a complement to user
mode, not a substitute for it.
Two traps live here. A check on one object says nothing about another,
so a method that reads Account and writes Case needs both. And an
upsert performs a create and an update, so it needs isCreateable()
and isUpdateable(). Partial coverage is the most common way a
class that looks FLS aware still fails.
Legacy only: WITH SECURITY_ENFORCED
Do not write this clause in new code. Migrate off it in old code.
// Legacy. Works below API 67.0. Removed at 67.0, will not compile.
List<Case> legacy = [
SELECT Id, Subject FROM Case WITH SECURITY_ENFORCED
];
// Replacement. Compiles from API 55.0 onward, including 67.0 and later.
List<Case> current = [
SELECT Id, Subject FROM Case WITH USER_MODE
];
Beyond the compile break, WITH SECURITY_ENFORCED never covered DML at
all and never handled polymorphic fields. Treat every occurrence as a
migration task with a deadline attached to your next API version bump.
4. Getting the full list in one offline pass
Reading every class by hand does not scale past a small package, and the version fork means the answer is per class rather than per repo.
Vulkro for Salesforce walks every *.cls and
*.trigger in an SFDX project, resolves each class's API version,
applies the matching semantics, and traces request reachable entry
points through to their data operations. It runs entirely on your
machine: the source never leaves it.
vulkro-sf scan .
vulkro-sf scan . --format json > sf-findings.json
So the output is auditable rather than promotional, here are the
evidence signals the CRUD and FLS detector emits, which appear verbatim
in the JSON under each finding's evidence[].signal:
| Signal | What it asserts |
|---|---|
apex-crud-fls-not-enforced-on-request-reachable-class | A request reachable class performs data operations with zero enforcement signals anywhere in it. |
apex-crud-fls-gap-exposed-method | The class is FLS aware elsewhere, but one exposed method reaches a data operation with nothing enforcing it on the call path. |
apex-crud-fls-gap-per-operation | Enforcement exists but does not cover every operation, for example an upsert checked only for update. |
apex-crud-fls-data-op | One row per data operation: the SObject, the verb, the line, the permission it needs, and whether a marker enforces it. |
apex-crud-fls-api-version | Which version semantics were applied to this class, and where the version was read from. |
That last signal is the one to read first when a result surprises you. It tells you whether the class was judged under pre-67.0 or 67.0 and later rules, and whether the version came from the class metadata, the project manifest, or neither.
Reachability is interprocedural and crosses classes, so a method that
delegates its check to a helper is not reported. vulkro-sf scan
follows the standard exit code contract: 0 for no findings, 1 for
findings reported, 2 for an error.
If you are preparing a submission,
vulkro-sf appexchange-report renders the same findings against the
checklist structure a reviewer works from. That command requires Pro,
and the 14-day trial of the full product covers a submission cycle.
Start a 14-day trial of the full product from Vulkro for Salesforce. Licenses are issued directly by our team at license@vulkro.com.
See also: Apex detectors, cross method Apex analysis, AppExchange readiness, methodology.