Anatomy of a guest user data leak
Open a public Experience Cloud page, a help centre or an application form, and watch the network tab. The page talks to Salesforce through one endpoint, /s/sfsites/aura, as the site's guest user. Nothing about that endpoint knows whether the request came from the site's own components or from a script. Whatever the guest user is allowed to read, anyone on the internet can read.
That is not a theory. In 2023 an independent researcher found hundreds of public Salesforce sites exposing records such as Social Security and bank account numbers to anonymous visitors. In March 2026 Salesforce warned customers that threat actors were mass-scanning public sites through that endpoint and extracting data, and said the cause was customer guest user configuration, not a platform flaw. This post walks the path a leak takes, hop by hop, and where each hop can be closed.
The guest user is a real user
Every site that allows public access runs anonymous traffic as one guest user, with its own profile (the site's public access settings) and any permission sets assigned to it. Every question below is a question about that one user: what it is granted, what records it can see, and what code it can run.
Salesforce has tightened the defaults a great deal. Since Winter '21 the guest security policies force the guest org-wide default to Private on every object, forbid manual and Apex managed sharing to guests, and allow read-only access only through guest sharing rules. Since Spring '21 guests cannot hold View All, Modify All, edit or delete on objects, even through a permission set. So a modern leak rarely comes from one wildly wrong switch. It comes from four ordinary hops lining up.
Hop 1: the site
The site decides who counts as a guest and what a guest can call.
- Active, with public access. An abandoned site that was never deactivated still serves its guest user, with whatever that user was granted years ago.
- Self-registration. It turns any visitor into an authenticated external user, whose access is then governed by the external org-wide defaults. Salesforce's March 2026 guidance is to set external defaults to Private and turn self-registration off where it is not needed. Public research on the Aura endpoint also showed that a self-registration link can be removed from the page while the feature stays enabled.
- Public API access. The setting "Allow guest users to access public APIs" and the API Enabled permission on the guest profile. Salesforce calls turning these off the highest-impact single change.
Hop 2: the guest profile and its permission sets
Read access is still allowed, and read access is the leak. The profile decides which objects the guest can read, which fields on them, which system permissions it holds and which Apex classes it can call.
<!-- profiles/Customer Portal Profile.profile-meta.xml (excerpt) -->
<Profile xmlns="http://soap.sforce.com/2006/04/metadata">
<classAccesses>
<apexClass>CaseLookupController</apexClass>
<enabled>true</enabled>
</classAccesses>
<fieldPermissions>
<editable>false</editable>
<field>Case.Description</field>
<readable>true</readable>
</fieldPermissions>
<objectPermissions>
<allowCreate>true</allowCreate>
<allowDelete>false</allowDelete>
<allowEdit>false</allowEdit>
<allowRead>true</allowRead>
<modifyAllRecords>false</modifyAllRecords>
<object>Case</object>
<viewAllRecords>false</viewAllRecords>
</objectPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ApiEnabled</name>
</userPermissions>
</Profile>
Nothing here is forbidden. A web-to-case form legitimately needs Create on Case. Read on Case, a readable Description field, API Enabled and access to a lookup class are each defensible on their own. Together they are the first half of a leak.
Hop 3: sharing
Object permission says the guest may read Cases. Sharing says which Cases. For a guest, that answer comes from three places.
Guest sharing rules. They are the only supported way to share records with a guest, and Salesforce's own documentation is blunt about them: a guest sharing rule allows "immediate and unlimited access to all records matching the sharing rule's criteria to anyone".
<!-- sharingRules/Case.sharingRules-meta.xml (excerpt) -->
<SharingRules xmlns="http://soap.sforce.com/2006/04/metadata">
<sharingGuestRules>
<fullName>Public_Web_Cases</fullName>
<accessLevel>Read</accessLevel>
<label>Public web cases</label>
<sharedTo>
<guestUser>Customer_Portal</guestUser>
</sharedTo>
<criteriaItems>
<field>Origin</field>
<operation>equals</operation>
<value>Web</value>
</criteriaItems>
<includeHVUOwnedRecords>false</includeHVUOwnedRecords>
</sharingGuestRules>
</SharingRules>
The intent was "show visitors the status of cases submitted on the site". The effect is every web case ever created, with every readable field, to anyone.
Residue from before Winter '21. The guest policies were not retroactive. Records the guest owned, or that were shared with it manually, through Apex sharing, or through a queue or public group, stay visible until someone cleans them up. Salesforce points to the free Authenticated and Guest User Access Report and Monitoring package to find them.
The record limit is not a control. Standard Aura list actions return at most 2,000 records per request, but public research showed a GraphQL controller, reachable by guests by default, that pages through every record with a cursor. If the guest can read it, assume all of it can be read.
Hop 4: the Apex code
The last hop is where a careful configuration still leaks. An @AuraEnabled method in a class the guest profile can call runs with whatever sharing and access mode its class declares.
// CaseLookupController.cls, saved at API version 62.0
public without sharing class CaseLookupController {
@AuraEnabled(cacheable=true)
public static List<Case> findCases(String email) {
return [
SELECT Id, CaseNumber, Subject, Description, SuppliedName, SuppliedPhone
FROM Case
WHERE SuppliedEmail = :email
];
}
}
Three facts from the Apex Developer Guide decide what this returns:
without sharingignores sharing, so the guest sharing rules above do not limit it.- For classes saved at API version 66.0 or earlier, database operations run in system mode by default, so object and field permissions are not checked either. From API 67.0 (Summer '26) the default is user mode, but code saved at older versions keeps the older behaviour, and an explicit
WITH SYSTEM_MODEstill bypasses permissions. - "Sharing declarations don't enforce object-level access or field-level security."
with sharingalone does not stop a field leak.
The method also treats an email address as a password. Anyone who knows or guesses a customer's email reads that customer's cases. Salesforce's guest guide is direct: if a guest can run an @AuraEnabled method, "always use the 'with sharing' keyword". Where a guest really must create and then update a record, the guide requires an encrypted record key and a check that the guest created the record, not a value an attacker can know.
public with sharing class CaseLookupController {
@AuraEnabled(cacheable=true)
public static List<Case> findCases(String email) {
return [
SELECT Id, CaseNumber, Subject
FROM Case
WHERE SuppliedEmail = :email
WITH USER_MODE
];
}
}
Now the query respects sharing, object and field permissions, and returns no more than the guest could see anyway. The better fix is usually to move case lookup behind a login.
One more trap: User fields. Experience Cloud's settings that hide members' personal information "aren't enforced in Apex, even with security features such as the WITH USER_MODE clause or the stripInaccessible method". Apex that returns User email or phone to a site returns it to everyone.
The four hops together
Put together, the path reads: a public site, a guest profile that can call a class, a sharing model the class ignores, and a sensitive field at the end. The explorer below walks a path of the same shape, with the evidence for each hop.
UserGuest user
Anyone on the internet is this user.
The Customer Portal site is active and serves unauthenticated visitors as its guest user. There is no login between a stranger and everything this user can reach.
<CustomSite><active>true</active><guestProfile>Customer Portal Profile</guestProfile><siteType>ChatterNetwork</siteType></CustomSite>
Break the path at the hop that carries it. Declare with sharing on InvoiceController and query WITH USER_MODE: one change in one class closes all five hops.
How to find it
By hand, for each site with public access:
- Confirm the site is meant to be live, and whether self-registration and public API access are on.
- Read the guest profile and every permission set assigned to the guest user: object and field access, API Enabled, Apex classes, Visualforce pages, Run Flows.
- List the guest sharing rules on every object and read their criteria as "anyone can read all of these".
- Run the Guest User Access Report package for records shared before Winter '21.
- For every Apex class the guest can call: its sharing keyword, its API version, the access mode of each query, and what it returns.
- Review Event Monitoring for unusual Aura traffic from the guest user.
With Vulkro for Salesforce, the source and metadata half runs on your own machine:
vulkro-sf entrypoints --guest-only ./force-applists every door a guest profile or permission set grants, with the class's sharing keyword, its resolved API version and the file that grants it.vulkro-sf scan ./force-appreports the hops as findings, among themguest-user-object-read,sf-guest-meta-sensitive-field-read,sf-access-guest-dangerous-perm,sf-access-guest-permset-escalation,sf-guest-meta-sharing-rule-sensitive,sf-selfreg-enabled,site-guest-public-api-access,sf-guest-aura-read-no-fls,sf-guest-user-pii-returnandapex-guest-enumerable-record-lookup.vulkro-sf exposure(Pro) joins them into one report per site, with a verdict and the proof behind each path. With--target-orgit adds live facts such as site status and record counts, read withSELECT COUNT(), never the records themselves.- In the live org (Pro),
vulkro-sf org guest-livechecks self-registration, guest access and Chatter guests on the sites that are actually serving (SF-GUEST-LIVE-001toSF-GUEST-LIVE-003), and the event log checkSF-EVENT-MON-007flags a guest user touching many records.
Vulkro Cloud for Salesforce, available by invitation, keeps this view for every org your team connects, in its Exposure section, and shows when a guest grant appears between scans. The VS Code extension flags the guest-reachable method on the line while you write it.
How to fix it, cheapest fix first
- Turn off API Enabled and public API access for the guest user unless the site needs them.
- Start the guest profile from zero: remove every object, field, class and page the site does not use.
- Narrow or remove guest sharing rules, and clean up access left over from before Winter '21.
- Turn off self-registration where it is not needed, and set external org-wide defaults to Private.
- Make every guest-callable class
with sharing, run its queries in user mode, and replace lookups keyed on guessable values. - Never return User contact fields from site-callable Apex.
- Keep watching: a new site, a new rule or a new class grant reopens the path.
To see the guest paths in your own org, start here.
Sources
- KrebsOnSecurity, Many Public Salesforce Sites are Leaking Private Data, April 2023.
- Salesforce, Protecting Your Data: Essential Actions to Secure Experience Cloud Guest User Access, March 2026.
- Salesforce, Share Securely with Guest Users, Winter '27 edition.
- Salesforce, Apex Developer Guide, version 68.0, Winter '27.
- Google Cloud threat intelligence blog, AuraInspector: Auditing Salesforce Aura for Data Exposure, January 2026.
