appexchange-review-patterns
Salesforce AppExchange Security Review patterns for 2GP managed packages — what manual reviewers accept/reject, anti-patterns to avoid, fix recipes. Derived from a real FlexibleTeamShare rejection (case study in body). TRIGGER when editing Apex (.cls/.trigger) or LWC (.js/.js-meta.xml/.html) in forc
Install
mkdir -p .claude/skills/appexchange-review-patterns && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/16855" && unzip -o skill.zip -d .claude/skills/appexchange-review-patterns && rm skill.zipInstalls to .claude/skills/appexchange-review-patterns
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
Salesforce AppExchange Security Review patterns for 2GP managed packages — what manual reviewers accept/reject, anti-patterns to avoid, fix recipes. Derived from a real FlexibleTeamShare rejection (case study in body). TRIGGER when editing Apex (.cls/.trigger) or LWC (.js/.js-meta.xml/.html) in force-app/**, or when the user mentions "security review", "AppExchange", "FLS", "sharing violation", "CRUD", or reads appexchange-artifacts/**. Use to proactively steer code toward review-passing patterns and to flag the recurring anti-patterns that get packages rejected.About this skill
AppExchange Security Review Patterns — FlexibleTeamShare
Reference knowledge for writing and reviewing Apex/LWC in this repo so that code does not repeat the patterns that caused prior Salesforce manual-review rejections.
Hard-won lessons (2026 submissions)
Two patterns were explicitly rejected by Salesforce manual reviewers across Checkmarx (1attempt) and the Product Security team's manual review (2attempt). Both had been dismissed as false positives in our own documentation. The FP doc's claims were wrong — do not repeat.
Lesson 1: CRUD alone is not enough. FLS must be enforced per field.
Reviewers will not accept Schema.sObjectType.X.isCreateable() / isUpdateable() / isDeletable() object-level guards as FLS enforcement on DML. They explicitly asked for one of:
insert x as user;/update x as user;/delete x as user;— ApexUSER_MODE(requires at-least Spring '23 API and compatible context)Database.insert(records, AccessLevel.USER_MODE)— equivalent for bulk DMLSecurity.stripInaccessible(AccessType.CREATABLE, records)followed by plain DML- Explicit per-field loop via
Schema.sObjectType.X.fields.getMap()checking each.getDescribe().isCreateable()/isUpdateable()on every field being written
This rule applies inside without sharing classes too. Reviewers no longer accept the legacy argument "without sharing + CRUD check is fine because sharing is intentionally bypassed." Use as user (which enforces FLS+CRUD without re-enabling sharing) or the per-field loop. The 2attempt report Findings 1–5 were all about exactly this.
Lesson 2: A comment is not an authorization gate.
The rejected code had inner without sharing classes (TeamMemberSelector, ElevatedDmlOperations) with comments like:
"users can only see team members for records they already have access to (recordId comes from the UI context)"
This is a claim about callers, not a control. @AuraEnabled methods are callable directly from any authenticated user with Apex execute permission and the argument (recordId, memberId, …) is fully controlled by the client. The reviewer demonstrated a sharing bypass by calling the endpoint with IDs the calling user did not have access to.
Server-side authorization must exist in code, not in comments. Acceptable patterns are listed in the Authorization Gate section below. See 2attempt Sharing Violation Findings 1–2.
Lesson 3: False-positive doc claims must be verifiable.
SECURITY-SCAN-FALSE-POSITIVES.md contained the claim "All read queries use WITH USER_MODE" — which was materially false for TeamMemberSelector and ElevatedDmlOperations. The reviewer verified by grep and rejected the justifications.
When writing a false-positive entry, the claim must be grep-provable. "Uses WITH USER_MODE" is grep-provable. "Users only reach this via UI" is not.
The FLS/CRUD decision tree
When writing a SOQL query:
Is the result returned to the client (LWC / API response)?
├─ Yes → MUST enforce FLS on read:
│ WITH USER_MODE (preferred — enforces sharing + FLS)
│ or WITH SECURITY_ENFORCED (legacy equivalent)
│ or per-field isAccessible() before building the result
└─ No (internal auth/logic only, result never leaves Apex context) →
WITH SYSTEM_MODE is acceptable *if* the class is without sharing
and the result is used only for authorization decisions
When writing DML:
Is the object a *Share / *__Share (platform share table)?
├─ Yes → plain DML is fine; FLS not applicable to share objects
└─ No →
Are you in an InstallHandler inserting PermissionSetAssignment?
├─ Yes → plain DML acceptable (install-time, by design system context)
└─ No → MUST enforce FLS:
`insert record as user;` (Apex USER_MODE on DML)
or `Database.insert(records, AccessLevel.USER_MODE)`
or `Security.stripInaccessible(...)` before plain DML
or explicit per-field loop
The @AuraEnabled attack-surface checklist
For every @AuraEnabled method (including @AuraEnabled(cacheable=true)):
-
Inputs are attacker-controlled.
recordId,memberId,userId,accessLevel,role— all of these come from the client and may be forged. Validate format and semantics; never trust that they correspond to records the caller is allowed to access. -
Reachability into
without sharingcode must be gated. If this method ultimately causes a SOQL/DML to execute inwithout sharingcontext (class-level or inner-selector), there must be a server-side authorization check at this method's entry, before the without-sharing call. See Authorization Gate below. -
Mutating methods (add/update/remove/create/save/delete) MUST have an authorization check server-side. UI-side gates like hiding a button are not enough —
@AuraEnabledis a public endpoint from the attacker's perspective. -
Outputs must respect FLS. Either the SOQL enforces FLS (see decision tree above), or the wrapper only exposes fields the caller is allowed to see, or
Security.stripInaccessible(AccessType.READABLE, records)is applied before building the response.
Authorization gate — acceptable patterns
Use one of these patterns at the entry of any @AuraEnabled method whose work depends on record-level authorization:
Pattern A — explicit boolean check with throw:
@AuraEnabled
public static TeamMemberWrapper updateTeamMember(String memberId, ...) {
ObjectTeamMember__c m = [SELECT Record_Id__c FROM ObjectTeamMember__c
WHERE Id = :memberId WITH USER_MODE LIMIT 1];
if (!isCurrentUserManager(m.Record_Id__c)) {
throw new AuraHandledException(Label.FTS_Error_NotAuthorized);
}
// ... proceed with without-sharing mutation
}
Pattern B — with sharing re-query of the parent record (returns 0 rows if caller lacks access):
// Re-query parent in with-sharing context; if no rows, caller has no access.
List<Account> accessibleParent =
new ParentAccessor().fetch(parentId); // class declared with sharing
if (accessibleParent.isEmpty()) {
throw new AuraHandledException(Label.FTS_Error_NotAuthorized);
}
Pattern C — profile check for admin-only operations:
Profile p = [SELECT Name FROM Profile WHERE Id = :UserInfo.getProfileId()
WITH SYSTEM_MODE LIMIT 1];
if (p.Name != 'System Administrator') {
throw new AuraHandledException(Label.FTS_Error_AdminRequired);
}
NOT acceptable as a gate:
- A comment claiming "UI prevents this"
- Object-level CRUD check alone (
isUpdateable()) — this only says "user has field access," not "user is allowed to affect this specific record" if (!Test.isRunningTest()) { … }or any condition that is not a per-record authorization decision- Relying on
with sharingat the controller class level when the actual SOQL/DML is delegated to awithout sharinginner class
without sharing — when it is OK
Acceptable uses:
- InstallHandler (
PostInstallHandler) — runs system-context at install time by design. Document in FP doc. - Flow-invocable actions (
@InvocableMethod) that must operate across all records regardless of caller — document the business reason. Example in this package:SyncOwnerInvocable(Salesforce reviewer accepted this justification in 2attempt). - Queueable/Batchable/Schedulable system jobs, when they must operate on records the caller does not own. Document.
- Private inner selector classes invoked from
with sharingouter classes, where the outer class has an authorization gate per Pattern A/B/C. The auth gate is what makes this safe;without sharingalone is not.
Always:
- Keep the class
private(inner) or tightly scoped. - Add justification in
SECURITY-SCAN-FALSE-POSITIVES.mdwith a grep-provable claim. - Keep CRUD checks where applicable and FLS enforcement on DML/SOQL per the decision tree.
Anti-patterns that previously failed review
From 2attempt findings — do not reintroduce:
| Anti-pattern | Why it failed | Fix |
|---|---|---|
SELECT … FROM ObjectTeamMember__c WHERE … in without sharing inner class, returned via @AuraEnabled | No FLS, no server-side auth. Any user with read on ObjectTeamMember__c could enumerate team members for records they couldn't see. | Add WITH USER_MODE to the SOQL and add Pattern A auth gate at the @AuraEnabled entry. |
ElevatedDmlOperations.updateMember(member) → plain update member; in without sharing, only object-level CRUD checked at caller | No FLS, no per-record auth. Any user with update on ObjectTeamMember__c could mutate any member org-wide. | update member as user; plus Pattern A auth gate verifying caller manages member.Record_Id__c. |
@SuppressWarnings('PMD.ApexCRUDViolation') on class with no FLS | Silences the static analyzer while the violation remains. Review team verifies manually, not via PMD output. | Remove the suppression, add real FLS per decision tree. Only keep suppression after a grep-provable FP-doc justification. |
| Comment-based "security note" as justification | Claim, not a control. | Add a concrete auth check in code per Authorization Gate. |
FP doc claim "uses WITH USER_MODE" not matching actual SOQL | Reviewer greps; claim false → immediate rejection. | Make the claim true (edit the SOQL) before writing it in the doc. |
What the Code Analyzer catches (and doesn't)
sf code-analyzer run --rule-selector AppExchange reliably flags:
ApexFlsViolation— FLS missing on SOQL/DML (matches Lesson 1)DatabaseOperationsMustUseWithSharing— class-level sharing issue (partial match for Lesson 2)
It does not reliably flag:
- Missing server-side authorization for client-controlled IDs (Lesson 2's real core — it's a logic issue, not a syntactic one)
- Stale false-positive doc entries (Lesson 3)
- Weak-justification patterns in comments
The `/appexchange-a
Content truncated.