← Back to blog

CRUD/FLS Enforcement: Why Most Apex Code Fails Security Review

·7 min read

If you've ever submitted a managed package to AppExchange security review and watched the scanner come back with 200 CRUD/FLS violations across code you thought was fine, you're not alone. It's the most common reason packages fail first submission. The fix isn't difficult, but it requires writing Apex with a security posture from the start — adding it later is more painful than just doing it right the first time.

I've shepherded multiple packages through security review and audited dozens more. The patterns that pass are consistent. The patterns that fail are also consistent. Here's what I've learned about getting through, and the small things that catch out experienced developers.

What the Scanner Actually Checks

The Salesforce security scanner runs static analysis over your Apex code looking for places where you query, insert, update, or delete records without explicitly checking that the running user has permission to do so. The default assumption — that running with sharing handles this — is wrong. with sharing only enforces record-level sharing, not object-level CRUD or field-level FLS.

The scanner doesn't simulate every code path. It pattern-matches. That means it flags code that looks unsafe even when the runtime check would never apply (e.g., admin-only setup classes). That's frustrating, but the bar to clear is "the scanner is satisfied," not "the code is theoretically safe in every path."

Practical implication: your job is to make the scanner happy first, then prove your reasoning to the security review team in the questionnaire if you've taken any shortcuts.

The Two Patterns That Pass

There are two enforcement patterns the scanner accepts. Both work. Pick one and apply it consistently across the package.

Pattern 1: Security.stripInaccessible

This is the modern, recommended approach. Before working with records, strip any fields the user doesn't have access to:

List<Contact> contacts = [SELECT Id, FirstName, LastName, Email, Phone FROM Contact LIMIT 100];

SObjectAccessDecision decision = Security.stripInaccessible(
    AccessType.READABLE,
    contacts
);

List<Contact> safeContacts = (List<Contact>) decision.getRecords();
// Now safe to return to UI, serialise, etc.

For DML operations, use AccessType.CREATABLE or AccessType.UPDATABLE:

SObjectAccessDecision insertDecision = Security.stripInaccessible(
    AccessType.CREATABLE,
    newContacts
);
insert insertDecision.getRecords();

The scanner recognises stripInaccessible calls and treats the wrapped operations as safe. The runtime cost is minimal — just a metadata lookup against the user's permissions.

Pattern 2: Manual isAccessible/isCreateable/isUpdateable checks

Older code uses explicit field-level checks before each operation:

if (!Schema.sObjectType.Contact.fields.Email.isAccessible()) {
    throw new SecurityException('Insufficient access to Contact.Email');
}
if (!Schema.sObjectType.Contact.fields.Email.isUpdateable()) {
    throw new SecurityException('Insufficient access to update Contact.Email');
}

This works, the scanner accepts it, and it's how most pre-Spring '20 code was written. The downside: it's verbose, and you have to remember to add it for every field you touch. Easy to forget. I prefer stripInaccessible for new code.

The other downside: explicit checks throw exceptions when access is missing, which is sometimes what you want and sometimes catastrophic. stripInaccessible silently filters, which the scanner prefers because the operation succeeds with reduced scope rather than failing entirely.

What the Scanner Always Flags

A short, opinionated list of patterns I've seen the scanner reliably catch:

1. SOQL queries on objects without prior access checks. Even simple queries like [SELECT Id FROM Account] get flagged unless wrapped or preceded by an isAccessible check. The scanner doesn't know your code only runs in admin context — it sees a query, it flags.

2. DML on records without stripInaccessible. insert myAccounts; will get flagged. insert Security.stripInaccessible(AccessType.CREATABLE, myAccounts).getRecords(); won't.

3. Returning SObject lists from @AuraEnabled methods without sanitisation. Even if the data is safe, the scanner wants to see explicit stripping before returning to a Lightning component. It assumes anything client-facing must be access-checked.

4. SOQL injection vectors. Any Database.query() call where any part of the query string is built from input — user-supplied or otherwise — gets flagged. The fix is bind variables (:variable), not concatenation.

5. with sharing missing. Every Apex class that does DML or SOQL must explicitly declare with sharing. The scanner flags without sharing and inherited sharing for review (sometimes acceptable, sometimes not, depending on context). Default of "no annotation" is also flagged.

6. Dynamic SOQL with FLS bypassed. If you build a SOQL string and use Database.query, the scanner can't verify that the queried fields are accessible. You have to either (a) pre-check each field with isAccessible, (b) use WITH SECURITY_ENFORCED in the query, or (c) wrap the result in stripInaccessible.

WITH SECURITY_ENFORCED is worth knowing about:

List<Contact> contacts = [
    SELECT Id, FirstName, Email
    FROM Contact
    WHERE LastName = :surname
    WITH SECURITY_ENFORCED
];

This throws an exception at runtime if the user doesn't have access to any of the requested fields. The scanner accepts it.

The Centralised Utility Pattern

Across multiple packages I've worked on, the pattern that reduces violation count fastest is a centralised security utility. Every place that does CRUD goes through one of a handful of utility methods:

public class SecurityUtils {
    public static List<SObject> queryAccessible(String soql) {
        List<SObject> results = Database.query(soql + ' WITH SECURITY_ENFORCED');
        return results;
    }

    public static List<SObject> insertAccessible(List<SObject> records) {
        SObjectAccessDecision decision = Security.stripInaccessible(
            AccessType.CREATABLE,
            records
        );
        insert decision.getRecords();
        return decision.getRecords();
    }

    public static List<SObject> updateAccessible(List<SObject> records) {
        SObjectAccessDecision decision = Security.stripInaccessible(
            AccessType.UPDATABLE,
            records
        );
        update decision.getRecords();
        return decision.getRecords();
    }
}

Then everywhere in the package:

SecurityUtils.insertAccessible(newAccounts);

The benefits compound:

  • The scanner sees explicit security-checked operations and stops flagging.
  • New developers can't accidentally write unsafe code — the utility is the only path.
  • If the security model changes (say, Salesforce updates stripInaccessible semantics), you fix it in one place.
  • Code review becomes "did you go through SecurityUtils" — a yes/no question.

The downside: there's a small runtime overhead for every operation. In bulk processing, this matters less than you'd think because the metadata lookup is cached per transaction. I've never had a measurable impact in practice.

The Subjective Parts of Security Review

The scanner is deterministic. The human review is not.

The security review team will read your questionnaire — the document where you justify why certain patterns exist — and make judgement calls. I've seen reviewers accept without sharing on a class when the questionnaire explained it was for legitimate cross-tenant aggregation. I've seen the same pattern rejected when the questionnaire didn't explain.

Write the questionnaire seriously. For every scanner finding you've suppressed or every unusual pattern, write a paragraph explaining the use case, why a safer alternative doesn't fit, and what risk you've considered. The reviewers want to see thinking, not boilerplate. The package I prepared for Annature passed first review largely on the strength of a thorough questionnaire, despite some legitimately tricky patterns that the scanner flagged.

What I Skip

A few things that aren't worth the time:

Adding isAccessible checks alongside stripInaccessible. Pick one. Doubling up is just visual noise.

Defending against scanner false positives by trying every workaround. Sometimes the scanner flags something legitimately safe. Just suppress it and explain in the questionnaire. Don't refactor working code to satisfy a regex.

Premature optimisation of security utility performance. I've never seen the security wrappers be the bottleneck. The query they wrap usually is.

The Pattern That's Saved Me Time

The single biggest time-saver across packaging projects: write the security utility class first, before any business logic. Make it the rule that any new code calls through it.

When I've done this, scanner findings drop from hundreds to single digits, and the security questionnaire writes itself. When I've inherited code that didn't do this, half the project becomes retrofitting security into existing classes — and the velocity of feature work drops to zero until it's done.

If you're building a managed package from scratch, this is the first architectural decision. Get it right and the rest of the security work is mechanical. Get it wrong and you'll be paying for it through every submission.

The shortest summary: enforce CRUD/FLS at the boundary, do it consistently, and write your questionnaire as if the reviewer is reading 50 packages this week — because they are.

Liked this?

Get one Salesforce insight per week. No spam.