SfSkills Domains Docs GitHub

Home / apex / apex-stripinaccessible-and-fls-enforcement

apex-stripinaccessible-and-fls-enforcement

Use Security.stripInaccessible to enforce CRUD/FLS on user-supplied records before DML, and to scrub query results before returning them to clients. Covers AccessType.READABLE/CREATABLE/UPDATABLE/UPSERTABLE, the SObjectAccessDecision API, and when to prefer WITH USER_MODE on the SOQL itself. NOT for the with sharing / without sharing keyword choice — use apex/apex-with-without-sharing-decision. NOT for SOQL injection review — use apex/soql-security.

Open the skill folder on GitHub


Apex stripInaccessible and FLS Enforcement

Activate when Apex code accepts records from a less-privileged context (LWC, Aura, REST, Visualforce) and writes them to the database, OR when query results need to be scrubbed of fields the running user cannot read. Security.stripInaccessible(AccessType, records) removes inaccessible fields from a record collection and returns an SObjectAccessDecision you must operate on instead of the original list.


Before Starting


Questions to Ask Before Configuring

Every row below exists because a gotcha in references/gotchas.md bit someone who skipped it.

Ask the requesterWhy it mattersWhat a good answer addsTraces to
Which caller supplies these records, and can that caller set every field in the payload?stripInaccessible only earns its CPU cost on records that crossed a trust boundary; on trigger-context or Apex-constructed records it is ceremony.A marked list of entry points, so the strip sits at the boundary instead of being sprinkled through the call stack.SKILL.md § Before Starting
Which DML runs on the result — insert, update, or upsert?The AccessType enum must match the operation, and UPSERTABLE is the intersection of creatable and updatable, not the union.The right enum per entry point, and an early warning where a create-only field would vanish on every upsert.Gotcha 4
Is a field stripped silently acceptable here, or must the caller be told?WITH USER_MODE throws and stripInaccessible removes-and-continues; picking the wrong one turns a visible error into silent data loss, or vice versa.A per-path decision plus a logging or warning contract for getRemovedFields().SKILL.md § When stripInaccessible is the right choice
Which permission set does the running user hold in production, and does this deployment contain it?The test suite is the first user-mode caller this code ever has, and it runs at validation time as the deploying user — who has no FLS on fields created by the same deployment.The permission set API name to assign inside @TestSetup, which is what makes the suite deployable at all, not merely meaningful.Gotchas 5, 6
What is the apiVersion in the .cls-meta.xml, and is it 67.0 or later?67.0 flips the default access mode to user mode and removes WITH SECURITY_ENFORCED; a class pinned to 58.0 in the same org keeps the old behaviour.The correct idiom for this class rather than for the org's release, and a list of classes whose behaviour changes on an API-version bump.SKILL.md § Before Starting
How many records pass through the strip in the worst case?FLS is evaluated per field per record against the 10-second synchronous CPU limit; it costs no SOQL or DML.A chunking threshold agreed up front instead of a CPU-limit incident.SKILL.md § Performance

A proper enforcement design differs from "add stripInaccessible everywhere" in what it buys later: you can say which boundary each call guards, what the caller is told when a field is removed, and which permission set the behaviour actually depends on — so a permission-set regression fails a test instead of quietly widening or narrowing access in production.


Core Concepts

The four AccessType enum values

AccessType is the first argument to Security.stripInaccessible. Each value evaluates a different permission:

EnumWhat it stripsWhen to use
AccessType.READABLEFields the user cannot read.AFTER a SOQL query whose results you'll return to a less-privileged caller.
AccessType.CREATABLEFields the user cannot create.BEFORE insert.
AccessType.UPDATABLEFields the user cannot update.BEFORE update.
AccessType.UPSERTABLEFields the user cannot create OR update (strict intersection — a field must be both creatable AND updatable to survive).BEFORE upsert.

Picking the wrong AccessType is a real bug. READABLE before an update will leave create-only or update-only fields in the payload that the user could not have set themselves.

SObjectAccessDecision — getRecords, getRemovedFields, getModifiedRecords

Security.stripInaccessible(...) returns an immutable SObjectAccessDecision. You cannot mutate the original list and call it safe — you must operate on the decision's outputs:

Canonical pattern wrapping DML

public void createCases(List<Case> userSupplied) {
    SObjectAccessDecision decision =
        Security.stripInaccessible(AccessType.CREATABLE, userSupplied);

    if (!decision.getRemovedFields().isEmpty()) {
        ApplicationLogger.warn('createCases',
            JSON.serialize(decision.getRemovedFields()));
    }

    insert decision.getRecords();   // NEVER `insert userSupplied;`
}

The single most common bug is calling insert userSupplied; after the strip — the strip then has zero effect.

When stripInaccessible is the right choice vs WITH USER_MODE

GoalPrefer
Querying records to return/manipulate as the running userWITH USER_MODE on the SOQL itself
DML on records assembled from user input (e.g., REST body)stripInaccessible(CREATABLE/UPDATABLE/UPSERTABLE, ...)
Apex constructed records, no SOQL involvedstripInaccessible
Class pinned to API 47 or earlierWITH SECURITY_ENFORCED (legacy — removed in 67.0, does not compile there)
Need granular per-field reporting (which fields were removed)stripInaccessible (USER_MODE throws, doesn't strip)

Rule of thumb: WITH USER_MODE is THROW-on-inaccessible, stripInaccessible is REMOVE-and-continue. Pick based on whether silent partial success is acceptable.

Performance — per-record FLS evaluation

stripInaccessible evaluates FLS for every field on every record. For large lists (10k+) it consumes CPU. It does NOT count against SOQL/DML governor limits, but it does count against the 10-second sync CPU limit. Profile before strip-then-DML on huge collections; consider chunking.

Relationship handling: subquery children ARE stripped; re-queried records are not

Get this the right way round — the common belief that stripInaccessible ignores everything below the top level is wrong, and coding around a limitation that does not exist wastes effort.

What it does cover. The Apex Developer Guide's worked example passes a collection queried as SELECT Name, (SELECT LastName, Phone FROM Contacts) FROM Account and shows the child Contact.Phone field stripped from the subquery results when the running user lacks read access to it. Child records returned inside the collection you pass are evaluated. The guide also states the method "checks the source records for lookup or master-detail relationship fields to which the current user doesn't have access" and removes those.

What it does not cover. stripInaccessible sanitises the sObject collection you hand it, and nothing else. Records fetched by a separate query, or traversed by code after the strip, are untouched — the decision object has no reach into them. The supported nesting depth for relationship traversal is not stated in the documentation; do not assume a specific depth in either direction. Verify against your own org with System.runAs if a design depends on it.

Practical rule: strip each collection at the point you query it, rather than assuming one strip covers records you obtained elsewhere.

// Children returned inside the queried collection: ONE strip covers them.
List<Account> accts = [SELECT Name, (SELECT LastName, Phone FROM Contacts) FROM Account];
SObjectAccessDecision d = Security.stripInaccessible(AccessType.READABLE, accts);
// d.getRecords() has Phone removed from the child Contact rows too.

// Records from a SEPARATE query: strip them at their own query site.
List<Contact> extra = [SELECT LastName, Phone FROM Contact WHERE AccountId IN :ids];
SObjectAccessDecision d2 = Security.stripInaccessible(AccessType.UPDATABLE, extra);
update d2.getRecords();

Interaction with Schema.SObjectField.isCreateable / isUpdateable / isAccessible

Schema.DescribeFieldResult.isCreateable() is a CHEAP proactive check — use it to fail fast before assembling a payload. stripInaccessible is an EXPENSIVE post-hoc scrub — use it as the enforcement gate. They are complementary, not redundant.

if (!Schema.sObjectType.Account.fields.AnnualRevenue.isCreateable()) {
    throw new AuraHandledException('You cannot set AnnualRevenue.');
}
// later, still call stripInaccessible before insert as defense-in-depth.

Testing with System.runAs

Without runAs, test code runs as whoever is running the test — normally an admin holding every field permission — so the strip returns the input unchanged and the test passes for the wrong reason. (Below API 67.0 the class also ran in system mode by default; at 67.0+ database operations default to user mode. Neither changes stripInaccessible, which evaluates the running user's FLS either way.) To prove it actually strips, create a non-admin user with limited permissions and wrap the assertion in System.runAs(testUser).


Common Patterns

Pattern: REST endpoint — strip before DML

When to use: @RestResource accepting a JSON body of records.

How it works: Deserialize, strip with the appropriate AccessType, log removed fields for audit, DML on the stripped result.

Why not the alternative: Trusting the JSON payload assumes the running user can set every field they sent — they often cannot.

Pattern: Aura/LWC controller — strip on the way out

When to use: @AuraEnabled method returning records to the client.

How it works: Query with WITH USER_MODE (preferred) OR strip the result with AccessType.READABLE before returning. Returning unscrubbed records lets the client see fields the user cannot read in the UI.

Pattern: Defense-in-depth with WITH USER_MODE + stripInaccessible

When to use: The query already uses USER_MODE, but you also need to write back updates from a partially user-controlled object.

How it works: USER_MODE protects the read; stripInaccessible(UPDATABLE) protects the write. Each guards a different operation — do not collapse them.


Decision Guidance

SituationRecommended ApproachReason
REST/LWC inserts user-supplied recordsstripInaccessible(CREATABLE, ...) then DML on getRecords()Records originate from a less-trusted client
Aura controller returns recordsWITH USER_MODE on the SOQLThrows on inaccessible read, no silent strip
Upsert from a user-supplied liststripInaccessible(UPSERTABLE, ...)Intersects creatable AND updatable
Internal trigger handler updating fields the running user can edit anywaySkip — not user-suppliedstripInaccessible cost not justified
Need to know WHICH fields got removed for auditstripInaccessible + getRemovedFields()USER_MODE throws, gives no per-field detail
Class pinned below API 48WITH SECURITY_ENFORCEDstripInaccessible needs 48.0+, WITH USER_MODE needs 57.0+. Never emit WITH SECURITY_ENFORCED into a 67.0+ class — it was removed and fails to compile

  1. Identify entry points where records cross from less-privileged caller (LWC, Aura, REST, Visualforce) into Apex. Mark every parameter.
  2. For each entry point, pick the correct AccessType based on the DML you'll perform (CREATABLE for insert, UPDATABLE for update, UPSERTABLE for upsert, READABLE for outbound payloads).
  3. Replace the raw DML on the input list with DML on Security.stripInaccessible(type, input).getRecords(). Capture the decision in a local variable so you can also call getRemovedFields().
  4. Strip child collections separately if relationships are populated — parent strip does NOT recurse.
  5. Log getRemovedFields() (or surface as a warning to the caller) so silent stripping is observable.
  6. Add a test under System.runAs(nonAdminUser) that proves a restricted field IS stripped. The same test with no runAs — running as the privileged test user — should be a no-op.
  7. Run python3 scripts/check_apex_stripinaccessible_and_fls_enforcement.py over the changed .cls files to catch the "stripped-then-DML-on-original" mistake.

Review Checklist


Salesforce-Specific Gotchas

  1. DML on the original list silently bypasses the strip. stripInaccessible(...).getRecords() returns a NEW list. Calling insert userSupplied; after the strip line does nothing — the strip's effect is in getRecords().
  2. Subquery child records ARE stripped — separately queried records are not. The documented worked example strips Contact.Phone from the child rows of SELECT Name, (SELECT LastName, Phone FROM Contacts) FROM Account, so a strip on the parent collection does cover children returned inside it. What it cannot cover is any record obtained by a different query or traversal; strip those at their own query site. The supported traversal depth is not documented — do not design around an assumed limit.
  3. Tests pass regardless of FLS unless you supply a restricted user. Without System.runAs(nonAdminUser) the running user is the admin executing the test, so every field is accessible and your strip call is a no-op — the test proves nothing. Unchanged at 67.0+, where user mode still enforces that same unrestricted user's permissions.
  4. AccessType.UPSERTABLE is the intersection of CREATABLE and UPDATABLE. A field accessible for create but not update gets stripped on UPSERTABLE — this is correct behavior for upsert but surprising if you expected union semantics.
  5. SObjectAccessDecision is immutable. You cannot mutate getRecords() results and have those changes reflect anywhere; call DML directly on the returned list.
  6. The test suite is the first user-mode caller, and it runs at deploy time. When the fields ship in the same request as the enforcing code, tests that stay in the deploying admin's context do not merely prove nothing — they fail the validation deploy outright with No such column 'X__c' on entity 'Y' or Operation failed due to fields being inaccessible on Sobject Y. references/gotchas.md Gotcha 6 has the mechanism; the rule, a worked class and the checker that enforces it are in skills/apex/test-class-standards/ (SKILL.md workflow step 3, Gotcha 13, examples.md Example 5).

Output Artifacts

ArtifactDescription
Sanitized DML payloaddecision.getRecords() — the only safe input to insert/update/upsert
Removed-fields auditdecision.getRemovedFields() map for logging or warnings
FLS enforcement testTest under System.runAs(nonAdmin) proving restricted field stripped
AccessType decision noteDocumented choice of CREATABLE / UPDATABLE / UPSERTABLE / READABLE per entry point

Package files