For an app that customers install from AppExchange, Salesforce security best practices are mostly decisions in your own code. Your Apex has to respect each customer's access settings, your secrets have to stay out of code and logs, and the OAuth app that links their org to your service has to meet Salesforce's partner controls. MFA and session timeouts are real controls too. So are Shield and Event Monitoring, but the customer's admin owns all four, not your package.
That split matters for a SaaS company because the same package runs in every customer org you sell to. You build the package, pass security review and publish the listing, and from then on one unsafe query or a leaked API key is a problem in all of those orgs at once. Appnigma's marketplace apps team works on exactly this piece. Its page opens with "Your product, inside Salesforce or HubSpot." The offer is a native app plus listing preparation and "review support." Admins hardening their own org will get more from Salesforce's security best practices for customers, and admins choosing which apps to install want our guide to best practices when using AppExchange.
Updated October 2026. Checked against the Apex Developer Guide (Winter '27, API 68.0), the Secure Coding Guide, the ISVforce Guide and the 2GP packaging guide. The earlier version was a list of org-admin settings, told readers to "use Salesforce Shield" and to configure connected apps, and had no sources. This version separates what the package builder owns from what the customer's admin owns, and adds the API 67.0 change to Apex defaults, secrets storage, and the External Client App controls Salesforce now requires of partners.
Which security practices belong to the package, and which to the customer's admin
Most "Salesforce security best practices" lists are written for an admin securing one org. A package builder ships code into thousands of orgs it can't configure, so the useful question is who owns each control. This table sorts the common items.
| Practice | Who owns it | What the package builder does |
|---|---|---|
| Object, field and record access in code | Package builder | Run queries and DML in user mode and declare sharing on every class |
| Secrets such as API keys and tokens | Package builder | Store them in protected custom metadata, protected custom settings or external credentials |
| Callouts to your service | Package builder | Use named credentials and HTTPS, keep third-party JavaScript in static resources |
| The OAuth app customers authorize | Package builder | Build it as an External Client App with PKCE and refresh token rotation |
| Permission sets for your components | Package builder ships, admin assigns | Ship permission sets instead of profile settings |
| MFA, session timeout, login history | Customer's admin | Nothing in the package. Session settings aren't a packageable component |
| Shield Platform Encryption, Event Monitoring | Customer's admin (a paid Shield license) | Make sure the package still works when fields are encrypted |
| Validation rules | Customer or package, for data quality | Not a security control in any Salesforce secure coding page |
Salesforce's review pages state the expectation directly. The ISVforce page on access settings says "Customers expect that your solution doesn't violate the settings they have set in their orgs." The Shield overview tells users to "Ask your Salesforce administrator if Salesforce Shield is available in your org," which is why a package can't assume it.
Did Apex switch to user mode by default at API 67.0?
Yes, for classes saved at API 67.0 or later. The Apex Developer Guide page on access modes says "In API version 67.0 and later, Apex runs in user context by default" and "In API version 66.0 and earlier, system mode is the default." The same change applies to sharing. The sharing keywords page says "In API version 67.0 and later, classes without an explicit sharing declaration run in with sharing mode."
| Behavior | Class saved at API 66.0 or earlier | Class saved at API 67.0 or later |
|---|---|---|
| SOQL, SOSL and DML with no access mode | System mode, ignores the user's object and field permissions | User mode, enforces them |
| Class with no sharing keyword | Usually without sharing, with exceptions for Aura and LWC entry points | with sharing |
| WITH SECURITY_ENFORCED in SOQL | Compiles, but isn't allowed in AppExchange packages | Can't be used |
The catch for a package is that the behavior follows each class's own API version. A package built over several years usually holds classes saved at many versions, so older classes keep the system mode default until someone moves them to 67.0 and retests them. Moving a class forward also changes how it behaves in every customer org on the next upgrade. A query that used to return every record can start throwing errors for a user who lacks access to a field. The safe pattern is to stop relying on defaults at all and state the mode on every query and DML statement.
public with sharing class InvoiceSyncService {
public static List<Invoice__c> openInvoices(Id accountId) {
return [SELECT Id, Amount__c FROM Invoice__c
WHERE Account__c = :accountId AND Status__c = 'Open'
WITH USER_MODE];
}
public static void markSent(List<Invoice__c> invoices) {
update as user invoices;
}
}Two older patterns still turn up in packages under review. The first is WITH SECURITY_ENFORCED. The Secure Coding Guide's CRUD and FLS page says "Also, WITH SECURITY_ENFORCED isn't allowed in AppExchange packages," and the Apex versioned behavior page says that with API 67.0 and later "you cannot use the WITH SECURITY_ENFORCED clause in SOQL SELECT queries in Apex code." The second is hand-written describe checks before every query, which still work but are the most code to maintain. When a screen should show what the user can see instead of failing, Security.stripInaccessible "allows graceful degradation of the application by omitting fields, rather than failing outright."
One review detail saves a round trip. The access modes page warns that the Checkmarx scanner "isn't updated with this new Apex feature" and that until it is, "Checkmarx can generate false positives for field or object-level security violations that require exception documentation." If your code uses user mode, plan to document those findings as false positives in the submission.
Sharing declarations the security review expects
Record access is a separate decision from object and field access. The LWC guide on securing Apex puts it plainly. "The with sharing and without sharing keywords set record-level security. They don't enforce object-level and field-level security."
The review has its own rules for packages, listed on the ISVforce page on bypassing sharing. It says "Declare with sharing on all global classes or classes containing @NamespaceAccessible methods." Controllers that aren't global take with sharing or inherited sharing, and classes that touch data directly take with sharing. Triggers are the exception you can't declare away, because "Triggers always run implicitly in a without sharing context," according to the sharing keywords page. Keep trigger bodies thin and put the logic in a handler class with an explicit declaration.
without sharing is allowed when a feature genuinely needs it, such as writing to your own log object. Salesforce recommends it "only for classes that require system-level access," and the access settings page asks you to "document the policy as part of your AgentExchange security review submission" when your namespace's objects follow their own security policy.
Where should a managed package store API keys and tokens?
Never in code, and never in a public custom setting or a plain text field. The Secure Coding Guide on storing sensitive data gives the rule for package-wide secrets. "Secrets, which are common across all users of the package (such as an API key) must be stored in Managed Protected Custom Metadata Types." Protected custom settings fit secrets that each customer's admin enters, or that your code generates at install. Per-org OAuth tokens to your own service fit named credentials backed by external credentials.
| Secret | Where it goes | Why |
|---|---|---|
| A key that is the same for every customer | Protected custom metadata type | Only your namespace's code can read it |
| A key each customer's admin enters, or one generated at install | Protected custom setting | Set per org, hidden from subscriber code |
| OAuth tokens for calls to your service | Named credential plus external credential | Salesforce handles authentication for the callout |
| An encryption key for data you store in a custom object | Protected custom setting or protected custom metadata | Keeps the key apart from the encrypted value |
Named credentials have packaging limits worth knowing before you design setup. The named credentials packaging page says "External credential certificates and access tokens aren't packageable," so each customer authenticates after install. The same page notes a recent change. Starting in February 2026, packaged named credentials "default to developer control." The storing data guide adds a caveat for named credentials, noting that "Users with customized application permissions can view named credentials." If a secret must stay hidden from the customer's own admins, use protected custom metadata or a protected setting instead.
Logs are the other place secrets leak. The ISVforce page on debugging tells partners not to log secrets, keys or stack traces "in production environments," because "the profiles permitted to view logs aren't the same profiles that are permitted to view secrets." A System.debug line that prints a token ships to every customer who installs that version.
Injection, cross-site scripting and CSRF in packaged code
SOQL injection is the one most teams already know. The Apex guide's SOQL injection page says "use static queries and binding variables." When a query has to be dynamic, the Dynamic SOQL page documents Database.queryWithBinds, which takes the binds as a map and an access level in one call. Escaping is a fallback with limits. The Secure Coding Guide on injection warns that "If you have a boolean or otherwise unquoted field with user input, escaping single quotes won't help protect against injection." For a dynamic field or object name, check it against a list of names you allow.
Map<String, Object> binds = new Map<String, Object>{
'acctName' => searchTerm
};
List<Account> rows = Database.queryWithBinds(
'SELECT Id FROM Account WHERE Name = :acctName',
binds,
AccessLevel.USER_MODE
);Cross-site scripting comes from rendering untrusted data as HTML. Visualforce encodes merge fields for you except in script and style blocks and where escape="false" is set. The Secure Coding Guide on XSS also notes "no auto-html encoding is done by the platform when the DOM is rendered client-side." In Lightning components, the ISVforce escaping page says "Each component in a bundle is responsible for sanitizing the input provided to it by parent components, apps, or in URL parameters." Lightning Web Security helps without replacing that work. Its sanitization page says "LWS doesn't sanitize input text."
Cross-site request forgery (CSRF, a request the user didn't intend to send) usually comes from doing work on page load. The ISVforce CSRF page says "Don't perform any state change or DML operations in an Apex controller during instantiation of Lightning or LWC." Trigger state changes from a button the user clicks instead.
Callouts and front-end code that reaches outside Salesforce
Every call from the customer's org to your service should go through a named credential over HTTPS. Our post on integrating third-party APIs with an AppExchange app covers the callout design in more depth. The named credentials callout page says "Salesforce manages all authentication for Apex callouts that specify a named credential as the callout endpoint so that your code doesn't have to." Salesforce's secure communications guidance says "Salesforce requires exclusive use of TLS 1.2 or greater."
Front-end code has two packaging rules that surprise teams. Lightning web components can't call third-party APIs from JavaScript until the domain is a trusted URL, and the 2GP component list says "Because this component modifies security, we don't recommend including CspTrustedSite components in packages." Put the setup step in your install guide, or route the call through Apex. Libraries such as charting or date code also have to ship inside the package. The ISVforce page on third-party JavaScript says to avoid loading files from content delivery networks and "Instead, load the code from the static resources folder of your package." Aura components also need API version 40.0 or higher so Lightning Locker applies, per the Locker page.
The OAuth app that connects customer orgs to your service
If customers authorize your service to call their org, that OAuth client is part of your security surface. The Security Guide's connected apps page says "Connected apps creation is restricted as of Spring '26," and recommends external client apps instead. Existing connected apps keep working.
Salesforce also sets rules for partner OAuth apps that are "in use by more than two customer production orgs," on the ISVforce page for connected apps and External Client Apps. The page says "Partners must enable PKCE in the OAuth settings of their Salesforce Connected Apps and ECAs, regardless of whether the client is public or confidential." It also requires refresh token rotation and token requests from your allowed IP ranges. Under the same rules, "Salesforce invalidates the idle refresh token after 30 days." For a SaaS product, that last rule means a customer who stops syncing for a month has to reconnect, so your app should detect the expired token and prompt the admin rather than fail silently. Our post on the connected app security deadline covers the attestation steps.
Making the package work in orgs you don't control
Every customer org has different users, licenses and settings, and a secure package has to behave well in all of them. Ship permission sets for your own components. The 2GP guide on permission sets and profile settings says "Only use a profile setting if permission sets don't support the specific access you need to grant," and reminds you that "Subscribers must assign permission sets after installation." A permission set also installs only when the org has a matching user license.
Then test as a restricted user, not only as an admin. A package that runs every query in user mode will show errors to users who lack field access unless the code catches them, which is where stripInaccessible earns its place.
Shield Platform Encryption needs the same kind of testing. It is a customer's purchase, and Salesforce Help says it is "Available for free in Developer Edition," so you can test against it before a customer turns it on. The Shield encryption considerations page says "You can't include fields encrypted with the probabilistic encryption scheme in these SOQL and SOSL clauses and functions." The clauses listed are WHERE, GROUP BY and ORDER BY, along with aggregate functions. If your package filters or sorts on a standard field the customer might encrypt, that query is a support ticket waiting to happen.
Security review tests your service, not only the package
The review follows the customer's data wherever it goes. The required materials page says "To determine testing scope, we typically use a follow-the-data approach. Wherever the customer goes, we go." For a SaaS integration, your API, web app and the login customers use are all in scope.
That shapes what you submit. The scanner page says "If your solution connects to any non-Salesforce domains, also run a DAST scan on the external endpoints." A dynamic application security testing (DAST) scan probes a running app from the outside. For the package itself, the Code Analyzer AppExchange page gives the command that produces the report reviewers expect.
sf code-analyzer run --rule-selector AppExchange --rule-selector Recommended:Security --output-file CodeAnalyzerReport.htmlSalesforce also expects a written security program. The security policy requirements page asks partners to "Publish contact information so that it's easy for customers to get support and report security incidents." Our step-by-step security review walkthrough covers the submission itself, and the Code Analyzer guide covers reading the scan.
Mistakes that get packages returned from review
- Queries with no access mode in classes still saved at API 66.0 or earlier, or
WITH SECURITY_ENFORCEDleft in older classes. - Global classes or
@NamespaceAccessiblemethods with no sharing declaration. - An API key in a public custom setting, a text field or a static string in Apex.
System.debugstatements that print request bodies containing tokens or customer data.- JavaScript libraries loaded from a content delivery network, or outdated libraries with known vulnerabilities.
- DML in a controller constructor or on component load.
- A connected app or External Client App used by more than two customer orgs without PKCE or refresh token rotation.
- No DAST report for the external service the package calls.
Frequently Asked Questions
Is WITH SECURITY_ENFORCED still allowed in AppExchange packages?
No. Salesforce's Secure Coding Guide says it "isn't allowed in AppExchange packages," and classes saved at API 67.0 or later can't use it at all. Use WITH USER_MODE instead, which also reports every field error rather than only the first.
Should a managed package require Salesforce Shield?
No. Shield is a paid add-on the customer licenses, and their admin decides whether to turn on encryption or Event Monitoring. Your job is to test that the package still works when the customer encrypts fields, using a Developer Edition org where Shield Platform Encryption is free.
Where should we store the API key our package uses to call our service?
In a protected custom metadata type if the key is the same for every customer, or a protected custom setting if each customer's admin enters their own. Per-org OAuth tokens belong in a named credential backed by an external credential, which each customer authenticates after install.
Does the security review check our backend, or only the package?
Both, when Salesforce data or customer logins reach your service. Salesforce scopes the review by following the data, and asks for a DAST scan of any non-Salesforce domain the solution connects to.
Can Appnigma build a package that passes security review for us?
Appnigma's marketplace apps team builds the native Salesforce app, and its review support covers "Salesforce security review preparation and responses." Approval itself stays with Salesforce. Appnigma's done-for-you service puts a named integration engineer on your integrations and maintains them after launch.
Related Articles
- Salesforce security review step by step
- Salesforce Code Analyzer guide
- Integrating third-party APIs with your AppExchange app
- Security review for a Salesforce managed package
- Salesforce connected app security deadline
- External client apps in Salesforce
- Named credentials and external credentials for packaged apps
Sources
- Bypass Object-Level and Field-Level Access Settings, ISVforce Guide
- Salesforce Shield, Security Guide
- Set an Access Mode for Database Operations, Apex Developer Guide
- Use the with sharing, without sharing, and inherited sharing Keywords, Apex Developer Guide
- Protect Your Application from CRUD/FLS Vulnerabilities, Secure Coding Guide
- Versioned Behavior Changes, Apex Developer Guide
- Enforce Security with the stripInaccessible Method, Apex Developer Guide
- Secure Apex Classes, LWC Developer Guide
- Bypass Sharing Rules in Apex, ISVforce Guide
- Storing Sensitive Data, Secure Coding Guide
- Package Named Credentials
- Expose Secret Data When Debugging, ISVforce Guide
- SOQL Injection, Apex Developer Guide
- Dynamic SOQL, Apex Developer Guide
- SQL Injection, Secure Coding Guide
- Cross Site Scripting, Secure Coding Guide
- Insufficient Escaping in Lightning Components, ISVforce Guide
- Lightning Web Security Sanitization
- Cross-Site Request Forgery, ISVforce Guide
- Named Credentials as Callout Endpoints, Apex Developer Guide
- Secure Communications, Secure Coding Guide
- Components Available in Second-Generation Managed Packages
- Load JavaScript Files from Third-Party Endpoints, ISVforce Guide
- Lightning LockerService Disabled, ISVforce Guide
- Connected Apps, Security Guide
- Secure Your Connected Apps and External Client Apps, ISVforce Guide
- Permission Sets and Profile Settings in Packages, 2GP Developer Guide
- General Shield Platform Encryption Considerations, Salesforce Help
- Required Materials for Security Review Submission, ISVforce Guide
- Source Code Scanner, ISVforce Guide
- Code Analyzer AppExchange Reports
- Security Policy Requirements, ISVforce Guide



