A practical way to test authorization across UI API, GraphQL, Apex and Experience Cloud

Experience Cloud is Salesforce’s platform for customer and partner sites, including pages available to unauthenticated visitors. User Interface API (UI API) supplies record data and metadata for Salesforce interfaces while respecting the caller’s access permissions.

One of the more confusing Salesforce test results looks like this: a restricted Experience Cloud user opens an Account record but cannot see the AnnualRevenue field. The UI API leaves the field out, and the equivalent GraphQL query respects the same restriction. Then the page calls a custom @AuraEnabled method, and the field appears in the response.

In this example the same user requested the same record and field through different backend paths. That result makes sense once the request is traced past the URL. Salesforce applies record sharing, object permissions, field-level security, Apex class access, and database execution mode at different points. The API version of an Apex class can also change the default behavior of its database operations.

I refer to this comparison as Authorization Differential Testing. I hold the identity and request data steady, repeat the request through the paths exposed by the application, and investigate any change in the result.

TL;DR

  • Compare the same operation for the same user, record, and field across the paths exposed by the application. Investigate any difference in what the caller can read or change.
  • The with sharing declaration enforces record-level sharing. Object permissions and field-level security require separate attention when reviewing custom Apex.
  • Record the API version and database access mode of each Apex class. Classes compiled at API v66 or earlier and v67 or later have different database access defaults.
  • Test guest access to native APIs and custom Apex separately. Repeat the checks after Apex upgrades, permission changes, and Experience Cloud migrations.

Trace the request past the endpoint

An endpoint tells me where a request entered Salesforce.
It does not tell me which authorization checks ran after that.

For each request, I want to identify the:

  • Salesforce user
  • service or custom code that handled the request
  • applicable sharing model
  • database access mode
  • fields that were ultimately serialized back to the browser

The following table is shorthand used when planning that work:

The guest path is easy to misread. An Experience Cloud guest is still a Salesforce identity. Each site has a guest user with its own profile and access configuration. A native API request and a custom Apex call can start with that same identity and reach different enforcement points.

Execution pathExpected behaviorWhat I verify
UI API & Lightning Data ServiceEnforces object permissions, field-level security, and record sharingThe caller’s permissions, sharing configuration, and UI API support for the object
UI API GraphQLExposes UI API objects and fields according to the context user’s accessSchema visibility, field permissions, record sharing, and guest API settings
Apex at API v66 or earlierDatabase operations default to system mode unless the developer selects user modeSharing declaration, query and DML access mode, permission checks and sanitization
Apex at API v67 or laterDatabase operations default to user mode; elevated access must be selected explicitlyAny explicit system-mode operation and the class-sharing declaration
Experience Cloud guest pathBegins with the site’s guest identity, profile and sharing modelGuest API settings, Apex class access, and the authorization enforced by reachable methods

Testing GraphQL record and field access

During testing, I do not treat GraphQL relationship traversal as a bypass on its own. Salesforce’s GraphQL API is built on UI API, so the schema and returned data are shaped by the context user’s object, field, and record access.

I check whether both standard and aggregate GraphQL queries returned only the records available to the test user and continued to respect field-level restrictions. When they do, the native GraphQL path is enforcing the expected boundary for the identities and data in the test matrix.

That does not clear the rest of the feature. A Lightning Web Runtime (LWR) component can use GraphQL for one operation and an @AuraEnabled method for another. The person using the page sees one feature, while Salesforce may process the requests under different authorization rules.

When GraphQL withholds a field and Apex returns it, I focus on the Apex path. I check which method executed, the class API version, the sharing declaration, and the access mode used by its queries or DML.

The misleading part was with sharing

Consider a user who can open an Account record but is not allowed to read AnnualRevenue. UI API and GraphQL should omit the field or reject the request. Now compare those responses with this simplified controller compiled at API v66 or earlier:

The class uses with sharing, which helps enforce record-level sharing. For a system-mode database operation, that declaration does not by itself enforce the caller’s object permissions or field-level security.

public with sharing class AccountSummaryController {
    @AuraEnabled(cacheable=true)
    public static Account loadAccount(Id accountId) {
        return [
            SELECT Id, Name, AnnualRevenue
            FROM Account
            WHERE Id = :accountId
            LIMIT 1
        ];
    }
}

For example, with sharing can limit an Account query to records the user owns or has been granted access to. It does not grant access to the Apex class or determine which fields on those records the user may read; those are separate checks.

The user may therefore pass the record-sharing check and still receive a field that their profile or permission sets do not allow them to read. No Apex class-access control or sharing rule needs to be bypassed. The query simply omitted a separate part of the authorization model.

This is why with sharing can give a reviewer false confidence. The class may be legitimately exposed to the application, and the Account may be correctly shared with the user. Neither fact shows that the query checked field access.

public with sharing class AccountSummaryController {
    @AuraEnabled(cacheable=true)
    public static Account loadAccount(Id accountId) {
        return [
            SELECT Id, Name, AnnualRevenue
            FROM Account
            WHERE Id = :accountId
            WITH USER_MODE
            LIMIT 1
        ];
    }
}

If the query should run with the caller’s access, I make that choice explicit ( left )

User-mode database operations apply the caller’s sharing rules, object permissions and field-level security. When an operation should return the accessible portion of a result instead of failing, Security.stripInaccessible() may be a better fit.

Some business operations genuinely require elevated access. In those cases, I document why system mode is needed and test which callers can reach the operation.

Database access defaults in Apex API v67

Salesforce introduced a significant version change in API 67.0. Apex database operations in classes compiled at v67 or later run in user context by default. Classes at v66 or earlier retain the previous system-mode default unless the developer selects user mode.

The version belongs to the class, so an org can contain both behaviors at the same time.

Two controllers may contain almost identical SOQL and still enforce permissions differently because one was upgraded and the other was not. For custom data paths, I record the class API version alongside its sharing declaration: with sharing, without sharing, or inherited sharing. I also record the access mode of each query and DML operation and any manual permission checks. That is enough to tell whether the result came from an intentional decision or from a default that nobody reviewed.

An API-version upgrade therefore requires authorization regression testing. The page may look unchanged while the server applies a different access decision.

Reviewing custom Apex after an LWR migration

LWR migrations create a similar blind spot. A team replaces an Aura site, the old /aura route disappears, and guest access to native public APIs is disabled. A scan aimed at the old behavior stops finding it.

That change does not rewrite the Apex classes used by the site. WebRuntime can still invoke an @AuraEnabled method when the guest has access to its class. Once the method starts, its queries and DML follow the rules attached to that Apex code.

I test the native API gate and the custom Apex gate separately. For the Apex path, I identify the classes available to the guest and then test what each reachable method can read or change. A class name found in a JavaScript artifact is only a lead; the server-side class access check determines whether the guest can actually invoke it.

A legacy v66 class illustrates the risk. If the guest can access the class and the method performs a system-mode query without checking field permissions, replacing Aura with LWR has not corrected that authorization path. The browser reaches it differently, but the server-side behavior remains.

How I run the comparison

I begin with a specific authorization claim: This Experience Cloud user may read this Account record but may not read AnnualRevenue.

I then keep the identity, record, field, and operation unchanged while I test the paths that the feature actually exposes.

My minimum test set includes a privileged user as a positive control and at least one restricted identity. For public Experience Cloud functionality, I add the site guest. Before sending requests, I select a record the restricted user can read, one they cannot, a readable field and a field hidden by field-level security. Those controls help separate an authorization result from a malformed request or unsuitable test data.

Fixed identity + target record + restricted field
↓
UI APIUI API GraphQLCustom Apex
Enforces field-level security (FLS)Enforces FLSSystem mode or user mode?
Access deniedAccess deniedAccess granted?
↓
Differential identified

That minimum is a starting point. Depending on the feature, I also include internal users with different roles or permission sets, customer and partner users, integration accounts, and each site’s guest user. Record ownership and sharing can create differences even between users with similar profiles. For automation, I record the effective user and execution context. I repeat the comparison for each identity and note any paths its license or API settings do not permit.

I replay the same operation through UI API, GraphQL, and every relevant custom path, including @AuraEnabled Apex, automation and integration endpoints used by the feature. For each Apex result, I capture the class and method, API version, sharing declaration, query or DML access mode, permission checks, and the data returned to the client.

Execution pathExpected behaviorWhat I verify
UI API and Lightning Data ServiceEnforces object permissions, field-level security, and record sharingThe caller’s permissions, sharing configuration, and UI API support for the object
UI API GraphQLExposes UI API objects and fields according to the context user’s accessSchema visibility, field permissions, record sharing, and guest API settings
Apex at API v66 or earlierDatabase operations default to system mode unless the developer selects user modeSharing declaration, query and DML access mode, permission checks and sanitization
Apex at API v67 or laterDatabase operations default to user mode; elevated access must be selected explicitlyAny explicit system-mode operation and the class-sharing declaration
Experience Cloud guest pathBegins with the site’s guest identity, profile and sharing modelGuest API settings, Apex class access, and the authorization enforced by reachable methods

Consistent denials are still evidence. If all tested paths withhold AnnualRevenue, the implementation behaved consistently for that user and record. If Apex returns the field while the native services withhold it, the review has a specific class and operation to investigate. I repeat the comparison after changes to a class API version, sharing declaration, database access mode, guest class access, guest API settings, or the site’s frontend framework. Each of those changes can alter the path without producing an obvious visual change in the application.

There is no need to map an entire org before using this technique. I usually start with one externally reachable feature and one restriction that is easy to verify. I inventory the @AuraEnabled classes used by that feature, note their API versions, and flag database operations that rely on a default access mode. Then I use a restricted identity to request the same inaccessible record or field through the native data service and the custom Apex path.

For an Experience Cloud site, I review guest Apex class access separately from the guest public API setting. I also add the same negative checks to regression testing before an LWR migration or an Apex API-version upgrade.

When responses differ, the most useful question is: which path enforced a different rule, and why?
The answer is usually in the Apex execution context rather than the URL the browser called.

Recommendations for administrators and developers

Administrators should review object and field permissions, record-sharing rules, permission sets, and guest Apex class access for the affected feature. Remove access that is no longer needed, and review guest public API settings separately from custom Apex access.

Developers should select database access modes explicitly and use user mode when an operation must respect the caller’s permissions. If an operation needs to omit inaccessible fields, use Security.stripInaccessible() and return the sanitized records, with appropriate record-sharing enforcement. Document any required system-mode access and restrict who can invoke it.

Keep the restricted-user and guest checks in regression testing, including both reads and writes where the feature supports them. Repeat them after permission changes, API-version upgrades, and site migrations, and rerun the same requests after a fix.

Note: The behavior described here depends on the caller, class API version, permissions, sharing configuration, and implementation. These examples are authorization-testing patterns; they are not claims that GraphQL, LWR, or Apex is insecure by default.

Ready to review your Salesforce Apex & Experience Cloud?

NetSPI offers Salesforce Security Assessments covering configuration, data storage, integrations, and authentication. Start scoping your Salesforce security testing needs today: