Skip to main content

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 Database methods 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 sharing rather than inheriting the caller's mode.
  • WITH SECURITY_ENFORCED is 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:

SignalWhat it asserts
apex-crud-fls-not-enforced-on-request-reachable-classA request reachable class performs data operations with zero enforcement signals anywhere in it.
apex-crud-fls-gap-exposed-methodThe 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-operationEnforcement exists but does not cover every operation, for example an upsert checked only for update.
apex-crud-fls-data-opOne row per data operation: the SObject, the verb, the line, the permission it needs, and whether a marker enforces it.
apex-crud-fls-api-versionWhich 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.