How to securely expose Salesforce Data Cloud data to CRM-only users
without giving those users direct Data Cloud access.
A common Salesforce architecture problem looks simple at first:
A Lightning component needs to display Data Cloud data, but the users
viewing the component are CRM-only users who don't have Data Cloud
access.
At first glance, it seems like ConnectApi.CdpQuery should solve the
problem. Unfortunately, the query runs in the context of the browsing
user. If that user doesn't have the required Data Cloud access, the
query is rejected.
This article walks through an alternative architecture using:
- Apex
- Lightning Web Components (LWC)
- Named Credentials
- External Credentials
- An External Client App
- A dedicated integration identity
- Custom Permissions
- Public Groups
- Apex-controlled row-level filtering
The result is a component that can show the same Data Cloud dataset to
CRM users while applying different row-level rules based on the
Salesforce user's public-group membership.
The Problem
Imagine a Data Cloud data model object containing member plans:
ssot__MemberPlan__dlm
Suppose the dataset contains:
EPO → 5 rows
HMO → 5 rows
POS → 5 rows
PPO → 5 rows
----------------
Total → 20 rows
Now imagine two CRM users:
User Data Cloud Access Public Group Expected Result
User 1 No Not a member All 20 plans
User 2 No Member_Plan_HMO_Only 5 HMO plans
Both users should be able to use the same Lightning component.
However, neither user should authenticate directly to Data Cloud.
That creates the core architecture challenge.
Why ConnectApi.CdpQuery Isn't Enough
A natural first attempt is to query Data Cloud directly from Apex:
ConnectApi.CdpQuery.queryDataCloud(...);
The problem is that the query executes as the current Salesforce user.
If User 2 is a CRM-only user without Data Cloud access, Data Cloud sees:
User 2
|
v
Data Cloud Query
|
v
Access denied
The component cannot simply "borrow" another user's Data Cloud
permissions.
We therefore need a different execution identity.
The Core Idea: Separate Execution Identity From Browsing User
The architecture separates two concepts:
Browsing user
The Salesforce CRM user who clicked the component.
Integration identity
A dedicated Salesforce user whose credentials are used to access Data
Cloud.
The browser user never receives the integration user's credentials.
Instead, Salesforce's Named Credential framework handles authentication
for the outbound request.
The resulting architecture looks like this:
CRM User
|
| Lightning Component
v
Apex Controller
|
|-- Gate 1: Custom Permission
|
| Named Credential
v
External Credential
|
| Client Credentials
v
External Client App
|
| Run As
v
Integration User
|
v
Data Cloud
This gives us an important separation:
Who is viewing the component?
↓
Salesforce CRM user
Who queries Data Cloud?
↓
Integration user
The Important Trap
There is an easy-to-miss permission boundary here.
A Named Credential can authenticate the outbound call using a named
principal, but the Apex transaction is still executing as the current
Salesforce user.
That means the browsing user must be permitted to use the stored
External Credential principal.
Without that grant, the callout can fail even when:
- The External Client App is configured correctly
- The integration user has Data Cloud access
- The Named Credential is configured correctly
- The current user is an administrator
This is one of the most common reasons this architecture appears
correctly configured but doesn't work.
The flow therefore has two different permission gates.
Gate 1
CRM User
|
| Custom Permission
v
Can the user call the feature?
Gate 2
CRM User
|
| External Credential Principal Access
v
Can the Apex transaction use the stored credential?
↓
Integration Identity
↓
Data Cloud
How the Identity Swap Works
Two pieces make the identity swap possible.
1. The Named Principal
The External Credential uses a named principal.
The authentication flow uses client credentials against a Salesforce
External Client App whose Run As user has the required Data Cloud
access.
The resulting token belongs to the integration identity.
It does not belong to the CRM user who clicked the component.
2. The Core Salesforce Domain
The Data Cloud Query API is accessed through the Salesforce core domain:
/services/data/v64.0/ssot/queryv2
The Named Credential points to the Salesforce My Domain.
The Apex code can therefore make one authenticated callout without
asking the CRM user to authenticate separately to Data Cloud.
The Request Flow
The complete request looks like this:
Step What Happens
1 User calls getMemberPlans()
2 Apex checks View_Member_Plan_Data
3 Apex calls the DataCloud_Core Named Credential
4 Salesforce verifies External Credential Principal Access
5 Named Credential injects the integration identity
6 Data Cloud executes the query as the integration user
7 Apex applies CRM-side row-level rules
8 LWC displays the permitted rows
The critical point is that Data Cloud sees the integration identity,
while Apex still knows who the actual CRM user is.
Row-Level Access in Apex
Because the integration user has broad access to the Data Cloud dataset,
Data Cloud itself cannot distinguish User 1 from User 2.
Therefore, row-level access must be enforced in Apex.
For this example, a public group determines whether the user is
restricted to HMO plans.
Member_Plan_HMO_Only
|
+---- User 2
The Apex controller determines the current user:
UserInfo.getUserId()
and checks whether that user belongs to the restricted public group.
The resulting behavior is:
User Group Membership Query Scope
User 1 Not in group All plan types
User 2 In group HMO only
Keep the Restriction Server-Side
One of the most important security principles in this design is:
The client never decides which rows it is allowed to see.
The restriction is derived inside Apex.
For example:
private static final String RESTRICTED_GROUP =
'Member_Plan_HMO_Only';
private static final String RESTRICTED_PLAN_TYPE =
'HMO';
The controller derives the restriction from:
UserInfo.getUserId()
rather than accepting a value from the LWC.
That means the client cannot simply send:
planType = "ALL"
and bypass the restriction.
Protecting the Query From Injection
There is another important consideration.
If the Data Cloud Query API accepts raw SQL and doesn't provide the same
bind-variable experience you might expect from ordinary Apex SOQL, query
construction needs additional protection.
Never blindly concatenate user-controlled values into the query.
For example, don't build a query like:
String query =
'SELECT ... WHERE PlanType = \'' +
userSuppliedValue +
'\'';
Instead, use strict allowlisting.
The principle should be:
Input
|
v
Is it an allowed value?
|
+-- No --> Reject
|
+-- Yes
|
v
Build query
For the restricted plan type, the value should come from server-side
constants rather than from the browser.
The same principle applies to any optional filter such as groupNumber.
Allowlist and reject. Don't sanitize and continue.
The Setup Architecture
The setup has several components:
External Client App
|
| OAuth Client Credentials
v
External Credential
|
| Named Principal
v
Named Credential
|
v
Apex Controller
|
v
Data Cloud Query API
There are also two Salesforce-side access controls:
Custom Permission
+
External Credential Principal Access
Together they determine whether a CRM user can invoke the feature and
use the stored integration credential.
Step 1 --- Choose the Run-As User
The Run-As user performs the Data Cloud queries.
This user's access therefore becomes the effective Data Cloud access
boundary for the integration.
For a production implementation:
Prefer a dedicated integration user with only the Data Cloud access
required by this feature.
Avoid using a highly privileged personal administrator account.
A good principle is:
Integration User
|
+-- Read required Data Cloud data
+-- Nothing unnecessary
This limits the blast radius if the integration is misconfigured.
Step 2 --- Create the External Client App
In Salesforce Setup:
Setup
→ External Client App Manager
→ New External Client App
Example configuration:
Setting Value
Name Data Cloud MemberPlan Integration
Enable OAuth Enabled
Callback URL Salesforce OAuth success URL
Scopes api, cdp_query_api, refresh_token / offline_access
Flow Enablement Client Credentials Flow
Then configure OAuth policies.
The important setting is:
Run As = Integration User
The client credentials flow uses this Run-As identity.
After creating the app, obtain the:
Consumer Key
Consumer Secret
Allow Time for Propagation
After creating or changing the External Client App, allow time for the
configuration to propagate before troubleshooting authentication.
An authentication failure immediately after configuration doesn't
necessarily mean the credentials are wrong.
Step 3 --- Create the External Credential
Navigate to:
Setup
→ Named Credentials
→ External Credentials
→ New
Example:
Setting Value
Label DataCloud_IntegrationUser
Name DataCloud_IntegrationUser
Authentication Protocol OAuth 2.0
Flow Type Client Credentials with Client Secret
Identity Provider URL <My Domain>/services/oauth2/token
Scope Leave blank
Important: Leave Scope Blank
For this architecture, don't populate the Scope field in the External
Credential.
The OAuth scopes are defined on the External Client App.
If the token endpoint rejects a scope parameter in this flow,
authentication can fail with an error such as:
invalid_request
scope parameter not supported
Create the External Credential Principal
Under Principals, create the named principal:
Parameter Name:
DataCloudIntegration
Then provide:
Consumer Key
Consumer Secret
Save the principal and authenticate it.
The principal should report:
Configured
before continuing.
Step 4 --- Create the Named Credential
Create a Named Credential:
Label:
DataCloud Core
Name:
DataCloud_Core
The name is important because the Apex controller references it
directly.
Example:
Setting Value
Label DataCloud Core
Name DataCloud_Core
URL <My Domain>
Enabled for Callouts Yes
External Credential DataCloud_IntegrationUser
Generate Authorization Header Yes
The Apex code can then reference:
callout:DataCloud_Core
The Named Credential handles authentication instead of requiring
credentials to be embedded in Apex.
Step 5 --- Grant External Credential Principal Access
This is the step that's easiest to miss.
The Apex transaction needs permission to use the stored External
Credential principal.
The permission set can contain an External Credential Principal Access
entry similar to:
<externalCredentialPrincipalAccesses>
<enabled>true</enabled>
<externalCredentialPrincipal>
DataCloud_IntegrationUser-DataCloudIntegration
</externalCredentialPrincipal>
</externalCredentialPrincipalAccesses>
The value follows this pattern:
<ExternalCredentialName>-<PrincipalName>
For this example:
DataCloud_IntegrationUser-DataCloudIntegration
The names must match the actual org configuration.
This access grant does not give the CRM user Data Cloud access.
It only allows the Apex transaction to use the stored credential.
Deploying the Metadata
Once the org-level configuration exists, deploy the Salesforce metadata.
For example:
sf project deploy start -o dataCloud \
-d force-app/main/default/classes \
-d force-app/main/default/lwc/memberPlanViewer \
-d force-app/main/default/customPermissions \
-d force-app/main/default/permissionsets \
-d force-app/main/default/groups
The important sequencing rule is:
External Client App
↓
External Credential
↓
Named Credential
↓
Metadata deployment
Don't deploy metadata that references an External Credential principal
before that principal exists.
Verifying the Principal Name
If you need to verify the External Credential principal in the org,
query the metadata:
sf data query -o dataCloud -t \
-q "SELECT ParameterName, ParameterType
FROM ExternalCredentialParameter
WHERE ExternalCredential.DeveloperName = 'DataCloud_IntegrationUser'"
Use the Named Principal row when determining the principal name.
Running the Tests
The controller tests can be fully mocked, meaning they don't need live
Data Cloud connectivity.
For example:
sf apex run test \
-o dataCloud \
-n MemberPlanDataCloudControllerTest \
-w 10 \
-r human
This is important because your unit tests should verify the Apex
behavior without depending on an external Data Cloud service.
Step 6 --- Assign the Permission Set
The CRM user needs the permission set that grants access to the feature.
For example:
sf org assign permset \
-o dataCloud \
-n Member_Plan_Viewer \
--on-behalf-of <user2-username>
Anyone who executes the Apex controller, including an administrator
testing the feature, must have the required permission set.
Configure the Restricted Public Group
The public group controls the row-level restriction.
Example:
Member_Plan_HMO_Only
Add the users who should only see HMO plans.
This membership is org data, not metadata.
Therefore, it does not automatically deploy with your source code.
You can manage membership through:
Setup
→ Public Groups
→ Member Plan HMO Only
→ Manage Members
Or through Salesforce CLI.
The key rule is:
User in group
↓
HMO only
User not in group
↓
All plans
Place the Component on a Lightning Page
Once the backend configuration is complete:
App Builder
↓
Lightning Page
↓
Member Plans (Data Cloud)
The LWC calls the Apex controller.
The user doesn't need to authenticate directly to Data Cloud.
Verifying the Architecture
Before running the complete demo, verify the authentication layer first.
Layer 1 --- Verify the Integration Identity
A simple callout can be used to inspect the identity:
HttpRequest r = new HttpRequest();
r.setEndpoint(
'callout:DataCloud_Core/services/oauth2/userinfo'
);
r.setMethod('GET');
System.debug(
LoggingLevel.ERROR,
new Http().send(r).getBody()
);
The important result is that the returned identity should be the
configured Run-As user rather than the CRM user executing the Apex
transaction.
That demonstrates that the identity swap is working.
Layer 2 --- Test the Complete Chain
Then call the actual controller:
System.debug(
LoggingLevel.ERROR,
JSON.serialize(
MemberPlanDataCloudController.getMemberPlans(null, 5)
)
);
You want to see something equivalent to:
success = true
rowCount > 0
This confirms the complete path:
CRM User
↓
Custom Permission
↓
Principal Access
↓
Named Credential
↓
Integration Identity
↓
Data Cloud
↓
Apex Row Filtering
↓
LWC
The Demo
The most useful demonstration uses two CRM users.
User 1
Permission:
Yes
Public Group:
No
Result:
20 rows
Plans:
EPO
HMO
POS
PPO
User 2
Permission:
Yes
Public Group:
Member_Plan_HMO_Only
Result:
5 rows
Plans:
HMO only
The important observation is:
Both users are querying Data Cloud through the same integration
identity.
The difference comes entirely from the CRM-side row-level rule.
The Most Important Comparison
If an existing dataCloudAccountViewer component uses:
ConnectApi.CdpQuery
it runs as the browsing user.
Therefore:
CRM-only User
|
v
ConnectApi.CdpQuery
|
v
Data Cloud
|
v
NO ACCESS
The new architecture behaves differently:
CRM-only User
|
v
Apex
|
v
Named Credential
|
v
Integration Identity
|
v
Data Cloud
That contrast demonstrates exactly why the Named Credential architecture
is necessary.
Security: Read This Before Shipping
This architecture changes where authorization happens.
Data Cloud's normal per-user access model does not determine which rows
this component returns once the query is executed under the integration
identity.
Therefore:
Every access decision made by the component must be deliberately
enforced in Salesforce Apex.
The important controls are:
Control Where It Is Enforced
Who can call the feature Custom Permission
Who can use the credential External Credential Principal Access
Which rows a user can see Public Group + Apex
Which columns are returned Explicit SELECT
Filter safety Strict allowlist
Data Cloud query identity Integration User
Principle of Least Privilege
The integration user is extremely important.
The integration identity can potentially see everything the component
queries.
Therefore, don't treat it like an ordinary application user.
In production:
- Use a dedicated integration identity.
- Grant only the required Data Cloud access.
- Limit the dataset the application can query.
- Keep the Apex authorization rules explicit.
- Avoid giving the integration user unrelated privileges.
The integration user's permissions define the potential blast radius of
the integration.
Never Trust Client-Supplied Authorization
A dangerous design would be:
getMemberPlans({
planType: 'ALL'
});
and then allowing Apex to decide whether the user is permitted based on
that value.
The client should never determine authorization.
Instead:
Current Salesforce User
|
v
UserInfo.getUserId()
|
v
Check Public Group
|
v
Determine Scope
|
v
Build Safe Query
The authorization decision belongs on the server.
Audit Consideration
There is an important trade-off with this architecture.
Data Cloud audit trails will see the integration identity performing the
query.
They won't automatically identify the CRM user who originally clicked
the component.
If business auditing requires end-user attribution, log the CRM user
separately on the Salesforce side.
For example:
UserInfo.getUserId()
can be included in an appropriate application audit record.
This gives you two identities:
Data Cloud audit
→ Integration User
CRM application audit
→ Actual Salesforce User
Troubleshooting
When something goes wrong, identify which layer failed.
Symptom Likely Cause
We couldn't access the credential(s)… External Credential Principal Access is
missing
View_Member_Plan_Data error Permission set isn't assigned
scope parameter not supported External Credential Scope field is
populated
invalid_grant during authentication Client Credentials Flow or Run-As
configuration is incorrect
HTTP 401 Consumer credentials are wrong or the
client app hasn't propagated
HTTP 403 Run-As user lacks Data Cloud access or
required OAuth scope
HTTP 404 API version or Data Cloud provisioning
issue
Query succeeds with 0 rows Data Cloud dataset contains no matching
data
Group member sees all plans Public Group membership isn't configured
correctly
Everyone sees HMO only Group membership or nested group
configuration is too broad
A Note About HTTP Status Codes
One detail is especially important for the Data Cloud Query API.
The /ssot/queryv2 endpoint can return:
HTTP 201
for a successful query.
Don't assume every successful query must return HTTP 200.
If your Apex query helper accepts both successful statuses, don't
accidentally tighten the check to:
statusCode != 200
and break valid Data Cloud responses.
Common Configuration Mistakes
Mistake 1: Forgetting Principal Access
Everything appears configured correctly, but every user gets:
We couldn't access the credential(s)
Check the permission set's External Credential Principal Access.
Mistake 2: Giving the CRM User Data Cloud Permissions
The goal of this architecture is not to give CRM users direct Data Cloud
access.
The CRM user only needs the Salesforce permissions required to invoke
the feature and use the stored credential.
The integration identity handles Data Cloud access.
Mistake 3: Putting the OAuth Scope in the Wrong Place
If the External Credential's Scope field is populated when the token
endpoint doesn't accept that parameter, authentication can fail.
Keep the configuration aligned with the OAuth flow supported by the
External Client App.
Mistake 4: Using a Personal Admin as the Integration Identity
This works for a demo but creates unnecessary risk in production.
Use a dedicated integration identity with least privilege.
Mistake 5: Trusting LWC Parameters
Never allow the browser to decide:
Which rows?
Which plan type?
Which user?
Which authorization level?
The browser is an untrusted boundary.
Derive authorization from the Salesforce execution context.
The Complete Architecture
Putting everything together:
CRM User
|
v
Lightning Web Component
|
v
MemberPlanDataCloudController
|
+---------+---------+
| |
v v
Custom Permission Public Group
Gate 1 Row Scope
| |
+---------+---------+
|
v
Named Credential
DataCloud_Core
|
v
External Credential
DataCloud_IntegrationUser
|
v
External Client App
|
| Run As
v
Integration User
|
v
Data Cloud
|
v
ssot__MemberPlan__dlm
|
v
Apex filtering
|
v
LWC
This architecture provides a clear separation between:
- Authentication
- Credential storage
- Feature authorization
- Row-level authorization
- Data Cloud access
- User experience
When This Pattern Makes Sense
This architecture is useful when:
- CRM users need selected Data Cloud data.
- Those users should not receive direct Data Cloud access.
- A controlled integration identity can safely access the required data.
- Row-level access can be expressed and enforced in Apex.
- The application needs a Lightning-based user experience.
It is especially useful for controlled internal applications where the
Data Cloud dataset and authorization rules are well understood.
When to Be Careful
This pattern should not be treated as a shortcut around Data Cloud
security.
If the integration identity can see sensitive information, your Apex
layer becomes part of the security boundary.
Before shipping, carefully review:
- Integration-user permissions
- Apex authorization
- Public-group membership
- Query construction
- Returned fields
- Audit requirements
- Error handling
- Logging
- Data classification
A credential swap doesn't eliminate authorization---it moves
responsibility for certain access decisions into your application.
Key Takeaways
The most important ideas are simple:
1. The browsing user and query identity can be different
The CRM user can use the component without directly authenticating to
Data Cloud.
2. Named Credentials provide the authentication boundary
The Named Credential supplies the integration identity for the outbound
request.
3. Principal Access is a separate permission
The executing Salesforce user must be allowed to use the stored External
Credential principal.
4. Row-level authorization must be server-side
If Data Cloud sees the integration identity, your Apex code must enforce
the CRM user's row-level restrictions.
5. Never trust client input for authorization
Derive access from the Salesforce execution context, such as:
UserInfo.getUserId()
6. Use least privilege
The integration identity should have only the Data Cloud access required
by the application.
7. Test each layer independently
Verify:
Credential
↓
Identity
↓
Data Cloud Query
↓
Apex Filtering
↓
LWC
before troubleshooting the entire chain at once.
Final Thoughts
Serving Data Cloud data to CRM-only users is less about finding a single
magic API and more about understanding identity, authorization, and
trust boundaries.
The key architectural decision is to separate:
Who is using the application?
from:
Who is querying Data Cloud?
A Named Credential and integration identity solve the authentication
problem.
A custom permission controls who can use the feature.
A public group provides the business rule for row-level scope.
Apex becomes the enforcement layer that connects all of them.
The result is a controlled architecture where a CRM-only user can view
Data Cloud information without being granted direct Data Cloud
access---and different users can receive different data from the same
component based on server-side authorization rules.
Authentication tells you who is making the call. Authorization
decides what they are allowed to see. In this architecture, keeping
those two responsibilities separate is the key to making the design
work safely.
Top comments (0)