Security and SOQL: Ensuring Data Security in Your Queries
SOQL security problems surface at the worst possible moment. An Apex-backed Lightning component or integration query runs cleanly for the developer who wrote it, then returns different rows for two users, or throws a permission exception for one profile and not another. By the time anyone notices, the query is usually in production, and someone has either seen data they should not have, cannot see data they need, or is blocked by an error.
The confusion almost always comes from treating security as one switch. It is not. In Apex, three controls shape what a SOQL query returns: the class’s sharing declaration (which decides whether the org’s sharing model: OWD, role hierarchy, sharing rules, teams, etc. is enforced), the database operation’s execution mode (which decides whether object permissions and field-level security are enforced), and whatever you do to the results afterwards. Outside Apex, the authenticated caller’s permissions become the boundary instead. The controls overlap in specific ways, and the defaults for two of them changed in Summer ‘26.
This guide works through each control, what it does and does not protect, and how it behaves when access is missing. If you want a refresher first, start with SOQL fundamentals and advanced filtering patterns.
🧰 How the security controls differ
Section titled “🧰 How the security controls differ”These controls answer different questions and fail in different ways. Class sharing establishes which records are in scope, query mode determines whether the operation enforces the running user’s sharing, object permissions, and field-level security, and stripInaccessible() sanitises records after retrieval.
| Control | What it changes | Failure mode to test |
|---|---|---|
| Class sharing declaration | Determines record visibility through sharing; it does not enforce object or field permissions | with sharing may omit rows an elevated operation needs; without sharing may return rows the caller should never receive |
Database operation mode (WITH USER_MODE or WITH SYSTEM_MODE) | User mode enforces sharing, object permissions, and FLS; system mode bypasses object and field permissions while class sharing still controls record visibility | User mode may omit rows or throw when elevated access is required; system mode may expose restricted data when elevation was not intended |
Result sanitisation (Security.stripInaccessible()) | Sanitises fields on records already retrieved; it does not correct record-sharing mistakes | Partial records may break downstream logic; missing root-object access can still cause an exception |
Three limits are worth stating before the detail, because each one is a place teams assume they are covered and are not:
- Class sharing says nothing about fields. A
with sharingclass will happily hand back a field the user cannot read unless the query enforces field-level security too. stripInaccessible()does not repair record-level sharing mistakes. Querying rows under a broadwithout sharingand system-mode context, then stripping fields, still leaves the caller holding records they should never have received. Stripping fields from a row does not un-retrieve the row.- The interaction is deliberately asymmetric. User mode reapplies the running user’s sharing and can override a
without sharingcontext for that operation. System mode bypasses object and field permissions, but the enclosing class or trigger context still determines record visibility.
🚨 What changed in API 67.0
Section titled “🚨 What changed in API 67.0”Summer ‘26 moved the defaults toward safety, which is good news and also a migration hazard: the same class can behave differently depending on the API version it was compiled against.
For Apex compiled at API 67.0 and later:
- Database operations default to user mode. SOQL, SOSL, DML, and
Databasemethods now enforce the running user’s object permissions, field-level security, and sharing rules unless you opt out. At API 66.0 and earlier they defaulted to system mode. - Classes without a sharing declaration default to
with sharing. At API 66.0 and earlier, they generally defaulted towithout sharing, although some entry points behaved differently. Code that relied on an implicit bypass can return fewer rows once the component moves to API 67.0. WITH SECURITY_ENFORCEDno longer compiles.WITH USER_MODEis now its supported successor and a genuine upgrade, but it is not a simple rename or one-for-one replacement. It already existed alongside the older clause and applies a broader security model: it supports polymorphic fields, checks fields referenced inWHEREclauses, and reports the full set of access errors instead of only the first.
The important word in the diagram is default. The version changes what omission means; explicit class and operation keywords still determine the interaction described later in this guide.
Triggers are the important special case. A trigger always has a without sharing context and cannot declare a class sharing keyword. In API 67.0 and later, however, database operations inside the trigger default to user mode, and each operation can explicitly request user or system mode. User mode reapplies the running user’s sharing, object permissions, and field-level security. System mode leaves the trigger’s without sharing record context in place and bypasses object and field permissions. A handler class is still the cleaner home for business logic because it keeps the trigger focused on orchestration, makes the logic easier to test and reuse, and lets you declare the handler’s sharing intent explicitly. The trigger itself can still choose user or system mode for each database operation.
This is a versioned change. An Apex class or trigger still set to API 66.0 or earlier keeps the earlier defaults; compiling it again at the same API version does not adopt the API 67.0 behaviour. Update the component’s API version deliberately, then test it with representative users before deployment.
🤝 Sharing and row visibility
Section titled “🤝 Sharing and row visibility”Sharing controls which records are in scope for a query. Organisation-wide defaults set the baseline, while the role hierarchy, sharing rules, teams, and manual shares can extend access. This is a separate layer from field access: an inaccessible field can cause a QueryException, but a record hidden by sharing is normally omitted without an error. If those layers are unfamiliar, sharing data in Salesforce explains them in detail; here we focus on their effect on SOQL.
That quiet filtering can make a visibility problem look like missing data or a faulty WHERE clause. The same query can legitimately return 250 records for an operations manager and 12 for a regional rep because each has a different sharing scope. Test with the intended user and at least one more restricted user; an admin-only test cannot confirm production visibility.
📜 Declaring sharing on the class
Section titled “📜 Declaring sharing on the class”These examples assume API 67.0 and make both security decisions explicit: the class keyword establishes the record-sharing context, while the query keyword controls the access mode for that operation.
The first class serves a user-facing feature. with sharing keeps the class within the running user’s record-sharing rules, and WITH USER_MODE makes the query enforce the complete user access model, including sharing, object permissions, and field-level security:
// Enforces the running user's rows, object access, and field access.public with sharing class AccountService { public List<Account> getAccounts() { return [SELECT Id, Name FROM Account WITH USER_MODE]; }}The method therefore returns only Accounts the user is allowed to see. If the user cannot read Account or either selected field, the query throws rather than returning a partial result. API 67.0 would choose the same defaults if the keywords were omitted, but stating both makes that security behaviour visible and stable across refactoring.
The second class performs a deliberately elevated platform operation. without sharing tells the class not to enforce the running user’s record-sharing rules. WITH SYSTEM_MODE also bypasses object permissions and field-level security for this query:
// Deliberately bypasses record visibility, object access, and field access.public without sharing class AccountReconciliationService { public List<Account> getAllAccounts() { return [SELECT Id, Name FROM Account WITH SYSTEM_MODE]; }}Together, those choices let the query read Account records and fields regardless of the running user’s access. That can be appropriate for reconciliation jobs and roll-up calculations that need an organisation-wide view, but the result must not leak into a user-facing response. Keep an elevated service’s public surface small, document why it needs the bypass, and tightly control its callers and returned data.
inherited sharing is the caller-dependent third option. It tells a reusable class to adopt the calling code’s record-sharing context: a with sharing caller gives it the user’s restricted row scope, while a without sharing caller gives it the broader scope. This is clearer than omitting a sharing declaration because the dependency is intentional, but it does not guarantee one fixed record boundary. The query still needs an explicit operation mode: WITH USER_MODE reapplies the running user’s sharing, object permissions, and field-level security, while WITH SYSTEM_MODE keeps the inherited row scope but bypasses object and field permissions.
🔄 How class sharing and execution mode interact
Section titled “🔄 How class sharing and execution mode interact”The class declaration establishes the record-sharing context. The operation mode then either reapplies the user’s complete access model or leaves that class context in place while changing object and field enforcement:
| Class declaration | Query mode | Result |
|---|---|---|
with sharing | WITH USER_MODE | Fully enforced: user’s rows, user’s fields. The safe default |
with sharing | WITH SYSTEM_MODE | Sharing still limits the rows, but object permissions and FLS are bypassed |
inherited sharing | WITH USER_MODE | User mode reapplies the user’s sharing, object permissions, and FLS for this query |
inherited sharing | WITH SYSTEM_MODE | The caller’s inherited row scope remains, but object permissions and FLS are bypassed |
without sharing | WITH USER_MODE | User mode reapplies sharing, object permissions, and FLS for this query |
without sharing | WITH SYSTEM_MODE | Sharing, object permissions, and FLS are bypassed. Reserve this for deliberate platform operations |
The combinations that point in different directions are the ones that surprise people. A system-mode query inside a with sharing class can expose fields the user cannot read, but it does not discard the class’s row-sharing boundary. A user-mode query inside a without sharing class temporarily reapplies the user’s sharing and permissions for that operation. Where teams get caught is migrating logic from an admin-run batch job into a user-facing feature: the class keeps its without sharing declaration, another query opts into system mode, and what was appropriate for a scheduled job becomes overexposure in a UI.
👤 Object and field access
Section titled “👤 Object and field access”Object permissions and field-level security answer two different questions. Read access on the object determines whether the user can query that type of record at all. Field-level security determines which fields they can read on a record they are otherwise allowed to access. For example, a user may be allowed to read Account records but not Account.AnnualRevenue.
This is separate from sharing. Sharing may place an Account within the user’s record scope, but it does not grant Read access to the Account object or its fields. A query that enforces the running user’s access must pass all three checks: the object, the selected fields, and the record itself.
Page layouts control presentation, not access. Removing a field hides it from that record page, but users who retain field access can still retrieve it through reports, APIs, and custom components. Protect the data with field-level security.
It usually goes like this. Someone adds one field to a query that has worked for months, tests it with admin access, and ships. When that query enforces the running user’s access, a user who cannot read the new field gets a different result in production: not a blank value, but an exception before any records are returned.
🔐 Enforcing access at query time
Section titled “🔐 Enforcing access at query time”The clearest option is WITH USER_MODE. If the running user lacks Read access to an object or field referenced by the query, Salesforce throws a QueryException when the query runs, before returning any records:
// Enforces object permissions and FLS for the running user.// Throws System.QueryException if an object or queried field is inaccessible.List<Account> accounts = [SELECT Id, Name FROM Account WITH USER_MODE];Use this as the default for user-facing read paths. The exception is the point: it stops the operation rather than silently returning a partial shape that downstream code may treat as complete.
When the field set genuinely varies by role or feature flag, build the query dynamically and include only fields the user can read. This example assumes an Account.AnnualRevenue field that some profiles cannot see:
// Include an optional field only when the running user can read it.String query = 'SELECT Id, Name';if (Schema.sObjectType.Account.fields.AnnualRevenue.isAccessible()) { query += ', AnnualRevenue';}query += ' FROM Account WHERE Industry = :industry';
List<Account> accounts = (List<Account>) Database.queryWithBinds( query, new Map<String, Object>{ 'industry' => 'Technology' }, AccessLevel.USER_MODE);Three choices in that block are deliberate:
isAccessible()is checked before the field enters the query string, so the query never asks for something that would throw.Database.queryWithBindskeeps theindustryvalue out of the concatenated query string, so that value cannot change the query structure. Binds protect values; any dynamic object, field, operator, or sort direction still requires an allowlist. See dynamic SOQL techniques for the full injection-prevention pattern.AccessLevel.USER_MODEis passed explicitly rather than relying on the API version default, so the intent survives a recompile.
To verify it, run the same code as a user whose profile lacks AnnualRevenue and confirm the returned records omit the field rather than throwing.
🧩 Returning a partial response safely
Section titled “🧩 Returning a partial response safely”Security.stripInaccessible() is the alternative when the UI can safely work with optional fields. Instead of rejecting the whole query when one field is inaccessible, the service retrieves the records, removes fields the running user cannot read, and returns the sanitised result.
Two additional row filters matter to this choice. Restriction rules remove records from a user’s accessible set even when sharing would otherwise expose them. They are available only for a limited set of objects, so they cannot affect the Account or Contact example below. Scoping rules narrow supported query results to a working set without changing the user’s underlying record access. User-mode queries apply both; a system-mode query followed by field sanitisation is not an equivalent replacement when either one defines the expected response. The sharing guide covers the wider record-access model.
At a high level, the service below does four things:
- Rejects an empty request and confirms that the running user can read
Account. - Queries the requested Accounts and their Contacts.
WITH SYSTEM_MODEallows the query to retrieve the selected fields, whilewith sharingkeeps it inside the class’s record-sharing boundary. - Passes the raw query result to
stripInaccessible()to remove inaccessible fields, including relationship fields in the subquery result. - Returns only the sanitised records. The original query result never leaves the method.
The WHERE clause uses only the supplied Account IDs, not a business field that might be hidden by FLS. This avoids using system-mode access to filter on data the user cannot read.
public with sharing class AccountDirectoryService { public static List<Account> findByIds(Set<Id> accountIds) { if (accountIds == null || accountIds.isEmpty()) { return new List<Account>(); }
if (!Schema.sObjectType.Account.getDescribe().isAccessible()) { throw new NoAccessException('Account read access is required.'); }
List<Account> queriedAccounts = [ SELECT Id, Name, AnnualRevenue, (SELECT Id, LastName, Email FROM Contacts) FROM Account WHERE Id IN :accountIds WITH SYSTEM_MODE ];
SObjectAccessDecision decision = Security.stripInaccessible( AccessType.READABLE, queriedAccounts );
List<Account> safeAccounts = new List<Account>(); for (SObject sanitisedRecord : decision.getRecords()) { safeAccounts.add((Account) sanitisedRecord); }
return safeAccounts; }}🧾 What stripInaccessible() returns
Section titled “🧾 What stripInaccessible() returns”This line is the security boundary between the raw query result and the response:
SObjectAccessDecision decision = Security.stripInaccessible( AccessType.READABLE, queriedAccounts);AccessType.READABLE tells Salesforce to check the returned fields and relationship fields against the running user’s read access. The method returns an SObjectAccessDecision, which keeps the sanitised records and information about what was removed.
In this query, Account is the root object because it appears in the outer FROM clause. The two-argument version of stripInaccessible() enforces root-object CRUD by default. If the user lacks Read permission on Account, stripInaccessible() throws an exception because it can strip inaccessible fields, but not the root object itself. In the code above, the explicit precheck detects missing object access first, so stripInaccessible() is never called in that case. The service instead throws the clearer message Account read access is required.
The Contacts subquery is separate from that root-object check. The service treats this relationship as optional, so callers must check whether it is present on the sanitised Account before accessing it. A missing relationship means that Contact data was not included in the response; it is not the same as a present but empty Contacts collection. Passing the Account read check does not guarantee that the user can read Contact data.
There are two results to understand:
decision.getRecords()returns the sanitised record list with inaccessible fields removed. The originalqueriedAccountslist is unchanged and still contains the raw values, so it remains unsafe. On a sanitised record, useisSet()when you need to distinguish a stripped field from an ordinary businessnull.decision.getRemovedFields()returns aggregate metadata by object type and field, not a per-record audit log. For example, theContactentry can identifyEmailas removed. An object key can be absent when none of its fields were removed.
Use removal metadata for a test assertion or a documented partial-response decision. If callers need to know that the response is partial, return that status alongside the sanitised records in a response type designed for partial results. Otherwise, keep the metadata inside the service and route any production diagnostics through your server-side logging standard rather than relying on System.debug.
Finally, stripInaccessible() sanitises fields and relationship fields only. It does not repair a record-sharing mistake. A without sharing system-mode query could still retrieve rows the caller should never receive.
These patterns are far easier to apply consistently when queries live in service methods rather than scattered through controllers, which SOQL in Apex covers in detail.
📤 Queries outside Apex
Section titled “📤 Queries outside Apex”Outside Apex, the same object, field, and record boundaries still matter, but there is no class sharing declaration or per-query Apex access mode. The caller’s identity and the published query definition become the controls to inspect.
🔑 API queries use the caller’s permissions
Section titled “🔑 API queries use the caller’s permissions”When SOQL runs through REST, Bulk API 2.0, SOAP, or a Composite query subrequest, Salesforce evaluates it in the authenticated API user’s security context. That user’s object permissions, field-level security, and record sharing still apply. Unlike Apex, there is no class sharing declaration or user/system operation mode to choose; the integration user’s assigned access determines what the query can retrieve.
OAuth gives the client a way to call the API, but it does not widen the user’s access to Salesforce data. Use a dedicated, least-privilege integration user and test with that identity. An administrator’s token can make an unsafe field set look acceptable because the API faithfully enforces the administrator’s much broader permissions.
📌 Governed queries with Named Query API
Section titled “📌 Governed queries with Named Query API”For integration queries, Named Query API adds a useful security property: the Salesforce team defines the SELECT, FROM, relationships, filters, and limit once, then consumers call that definition with only the named parameters it accepts. Compared with allowing each client to submit free-form SOQL to the REST /query resource, this limits what the endpoint lets each client request, keeps the selected fields reviewable, and avoids client-built query strings. Parameter values cannot change the query shape, which reduces the risk of SOQL injection and accidental over-querying.
It is governance, not an independent authorisation layer. Named Query API uses the same API-user security context described above, so it does not make an over-permissioned integration user safe or remove that user’s ability to call other Salesforce APIs allowed by its credentials and OAuth policy.
🧭 Choose the response contract
Section titled “🧭 Choose the response contract”Before choosing an Apex control, define the service’s response contract: what callers with different access are allowed to receive and how missing access will be reported. The useful choices are a complete user-scoped result that rejects missing object or field access, a documented partial response that removes optional fields, or a deliberately elevated result that remains inside a trusted internal operation. The table maps each outcome to the class sharing declaration, query mode, and result handling needed to produce it. This is broader than choosing user mode or system mode alone; it defines both what data may leave the service and how access failures are reported.
| Response contract | Security pattern | What the caller receives |
|---|---|---|
| Complete user-scoped response. Every selected field is required | with sharing and WITH USER_MODE | Only records allowed by the user’s sharing, restriction, and scoping rules. Missing object or field access throws a QueryException before any records are returned |
| Partial user-facing response. Defined optional fields may be omitted | with sharing; a tightly bounded WITH SYSTEM_MODE query whose filters do not depend on inaccessible data; then stripInaccessible(). Return only decision.getRecords() | The class’s sharing scope limits the rows, and inaccessible fields and relationship fields are removed. Missing root-object access is rejected. Do not use this pattern when restriction or scoping rules form part of the response boundary |
| Deliberately elevated internal operation. The operation needs access beyond the running user’s permissions | A narrow without sharing service and WITH SYSTEM_MODE, with access to the service controlled separately | The trusted operation can access records and fields the running user cannot. Its raw result stays inside that boundary; only an explicitly designed safe output may leave it |
That choice also defines how the service reports access failures. For an all-or-nothing response, translate the permission exception into a stable service error without exposing sensitive permission details. For a partial response, document which fields are optional and return an explicit partial-response status if the caller needs to behave differently. For an elevated operation, constrain who can call it and what data is allowed to leave it. Do not let each controller decide these outcomes independently.
🧪 Test the response contract
Section titled “🧪 Test the response contract”An administrator-only happy-path test proves that the query runs; it does not prove that the response contract works. Test the production path with representative users, deliberately restricted data, and the permission combinations that should succeed or fail.
| Scenario | What the test should prove |
|---|---|
| Expected user | The service returns the intended records and every required field |
| Restricted record scope | Records inside the user’s sharing scope are returned, known out-of-scope record IDs are absent, and the omission does not cause an exception |
| Missing object or field access in a fixed user-mode response | The query throws QueryException and returns no records. Assert that promised failure, not only that some exception occurred |
| Missing access in the partial-response pattern | Missing root-object Read access is caught by the explicit precheck as NoAccessException. Optional inaccessible fields are absent from decision.getRecords(), and getRemovedFields() identifies the removal when the test needs to inspect it |
| Restriction or scoping rules | For each applicable rule, the production user-mode path excludes a known record that the rule filters out. Assert the excluded record ID, not only the result count |
| Elevated service | The approved internal path can reach the required records, while user-facing callers cannot invoke the service or receive its raw result |
| Mixed API versions | Tests cover the entry point and database operations at their deployed API versions. Before changing a class or trigger to API 67.0, compare row counts and permission failures so a changed default cannot pass unnoticed |
| API integration | An end-to-end test authenticates as the actual integration user and confirms the allowed rows and fields. An administrator token is not a substitute |
In Apex tests, use System.runAs() to execute the production path as representative users. runAs() establishes the representative user’s context for the test, but it does not override an explicitly selected database access mode. A query using WITH SYSTEM_MODE therefore still bypasses that user’s object permissions and FLS. Test object and field enforcement through the controls used by the production path: user mode, describe checks, or stripInaccessible().
Assert concrete outcomes: included and excluded record IDs, whether optional fields are set, the exception type for an all-or-nothing response, and the response presented to the caller. Security tests should prove what leaves the service, not merely cover the lines that apply the controls.
✅ Conclusion
Section titled “✅ Conclusion”Secure SOQL is not one setting. It is three decisions, and the most common production incidents come from assuming one of them covers another: a with sharing class containing a system-mode query that exposes restricted fields, a without sharing service returning system-mode rows to a user-facing caller, or stripInaccessible() used to clean up rows that should never have been queried.
The habit worth building is explicitness. Declare the class sharing model and the database access mode on every query, even on API 67.0 where the defaults now favour safety. It costs a few characters, it survives recompilation and mixed-version codebases, and it turns every deliberate bypass into something a reviewer can see rather than something they have to work out.
For implementation-focused next steps, continue with SOQL in Apex, then apply the same model in SOQL and Lightning and SOQL API integration. For how secure filtering choices behave at scale, see SOQL performance optimisation.