LWC security: five places Lightning Web Components go wrong
LWC security is mostly a question of where a component steps outside the framework. A Lightning web component that renders values through its template, reads records through the platform and calls Apex that checks access is hard to break. The trouble starts at the edges: an innerHTML assignment, a lwc:dom="manual" container, an Apex method that trusts its caller, a jQuery copy from 2019 in a static resource, a message listener that never asks who sent the message.
Each of those is a small decision a developer makes on an ordinary afternoon. None of them looks dangerous in a pull request. This post walks through the five that matter most, with vulnerable and fixed code for each, and says plainly what static analysis can and cannot tell you about them.
What does Lightning Web Security protect, and what does it not?
Salesforce runs component JavaScript inside a security architecture. The older one is Lightning Locker. The newer one is Lightning Web Security (LWS). Salesforce's comparison of the two says both block or modify APIs that are not secure, and that LWS supports things Locker blocks, such as third-party web components, while DOM access is contained by the browser's own Shadow DOM.
That is isolation between components and namespaces. It is not an HTML sanitizer for your own markup. If your component writes a string into the DOM as HTML, the sandbox does not decide whether that string is safe. Your code does.
Two metadata shapes are worth checking in every bundle:
- A bundle that opts out of LWS, or declares the legacy Locker marker, runs under weaker or older rules than you think.
- A bundle API version below 54.0 with no explicit marker defaults to Locker rather than LWS.
Vulkro for Salesforce reports both from the bundle metadata (lws-sandbox-opt-out, lws-legacy-locker-marker, lws-legacy-api-version), along with relaxed CSP settings such as lws-csp-unsafe-inline and lws-allow-unsafe-eval-opt-in. A component exposed to an Experience Cloud target that also relaxes its sandbox gets its own check, lws-exposed-permissive-combo, because that is the combination with the widest reach.
How does XSS happen in LWC with innerHTML and lwc:dom="manual"?
The LWC template engine escapes values you bind with {expression}. You lose that when you write HTML yourself. Salesforce's Top 20 vulnerabilities from the security review lists innerHTML and lwc:dom="manual" among the Lightning patterns that lead to cross-site scripting.
lwc:dom="manual" exists for a good reason. Salesforce's guide to third-party JavaScript libraries uses it so a library such as D3 can draw inside an empty element. The risk comes when the same container receives an HTML string built from data.
Vulnerable: a record field from @wire written as HTML.
<!-- caseSummary.html -->
<template>
<div class="summary" lwc:dom="manual"></div>
</template>
// caseSummary.js
import { LightningElement, api, wire } from 'lwc';
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
import DESCRIPTION from '@salesforce/schema/Case.Description';
export default class CaseSummary extends LightningElement {
@api recordId;
@wire(getRecord, { recordId: '$recordId', fields: [DESCRIPTION] }) record;
renderedCallback() {
const text = getFieldValue(this.record.data, DESCRIPTION) || '';
this.template.querySelector('.summary').innerHTML = text;
}
}
A Case description is often typed by a customer through a web form. Whatever markup they put there now renders in an agent's browser tab.
Fixed: let the template escape it.
<!-- caseSummary.html -->
<template>
<div class="summary">{description}</div>
</template>
// caseSummary.js
get description() {
return getFieldValue(this.record.data, DESCRIPTION) || '';
}
If you genuinely need formatted text, render it with lightning-formatted-rich-text, which restricts the tags it allows, rather than assigning HTML yourself.
Vulkro reports the @wire-to-innerHTML shape as lwc-wire-taint-into-innerhtml (High) and a lwc:dom="manual" template whose script writes an HTML string as lwc-dom-manual-bypasses-sandbox. The directive on its own is reported as Info: a D3 chart in a manual container is normal, and treating it as a vulnerability would bury the real ones.
Is an @AuraEnabled method called from LWC safe?
A component calling Apex looks like an internal function call. It is not. Salesforce's Aura developer guide tells developers to treat every @AuraEnabled method as a web service interface and to assume an attacker can call it with any parameters, whatever the client code does. Any user whose profile or permission set grants the Apex class can call the method directly. On a public site, that can include the guest user.
cacheable=true changes caching, not access. A cacheable method is still callable by anyone who has the class.
Vulnerable: a lookup that runs without sharing and trusts the id it is given.
public without sharing class OrderStatusController {
@AuraEnabled(cacheable=true)
public static Order__c getOrder(Id orderId) {
return [SELECT Id, Status__c, Customer_Email__c, Total__c
FROM Order__c WHERE Id = :orderId];
}
}
Fixed: run with the caller's access and enforce field-level security.
public with sharing class OrderStatusController {
@AuraEnabled(cacheable=true)
public static Order__c getOrder(Id orderId) {
return [SELECT Id, Status__c, Total__c
FROM Order__c WHERE Id = :orderId WITH USER_MODE];
}
}
The client side of this matters less than people expect. Validating orderId in JavaScript does nothing, because the attacker never runs your JavaScript. What matters is how the Apex runs and who can reach it.
That is why Vulkro joins the two sides. lwc-apex-bridge-tainted-arg notes an LWC passing user input to an Apex method, as Info, because on its own it proves nothing. lwc-apex-cross-language-taint (High) is reported only when the Apex taint engine proves that same method carries the value to a SOQL, SOSL, callout, XSS or deserialization sink. For guest access, sf-guest-aura-read-no-fls flags a guest-reachable method that returns records with no field-level security, and lwr-guest-apex-execute-reachable (Critical) flags data-accessing Apex the guest user can run on an LWR site. An LWC that can be placed on a site page and calls Apex running without sharing on sensitive data is reported as reachable by any visitor if published, even before a site exists. We walked a full example of that shape in the anatomy of a guest user data leak.
Are third-party libraries in static resources a risk?
LWC loads third-party JavaScript from static resources with loadScript. That is the supported pattern, and it has a side effect: the library is a file in your repository, not a dependency in a lockfile. Nobody gets an alert when it ages.
A vendored jQuery or AngularJS copy can sit unchanged for years. Many dependency scanners never see it, because they read manifests, and a static resource zip is not a manifest.
Vulkro for Salesforce reads staticresources/ directly. sf-sca-vulnerable-static-resource flags known vulnerable copies of libraries such as jQuery below 3.5.0, Lodash below 4.17.21, Bootstrap below 4.3.1 and end-of-life AngularJS 1.x. Secrets left in static resources get their own checks (staticresource-named-secret and others), since a static resource ships to every browser that loads it. If you also run RetireJS locally, vulkro-sf scan --include-retirejs merges its results into the same report.
The fix is boring: upgrade the file, record the version next to it, and delete libraries nothing loads any more.
How should an LWC handle postMessage?
Components that embed a payment widget, a map or a Visualforce page in an iframe often talk to it with window.postMessage. MDN is direct about the receiving side: verify the sender with the origin property, and still validate the message's content.
Vulnerable: any window can drive this component.
connectedCallback() {
window.addEventListener('message', (event) => {
this.applyTheme(event.data.theme);
this.redirectTo(event.data.nextUrl);
});
}
Fixed: an exact allowlist, then validation.
const ALLOWED = new Set(['https://pay.example.com']);
connectedCallback() {
window.addEventListener('message', (event) => {
if (!ALLOWED.has(event.origin)) return;
const { theme } = event.data || {};
if (theme === 'light' || theme === 'dark') this.applyTheme(theme);
});
}
Compare origins exactly. A regular expression or a startsWith check lets https://pay.example.com.attacker.net through. Vulkro reports a listener that never reads event.origin as postmessage-listener-no-origin-check, and tainted navigation targets as lwc-open-redirect-tainted-nav.
LWC security checklist
- No
innerHTML,outerHTMLorinsertAdjacentHTMLwith data. Bind values in the template. lwc:dom="manual"only for containers a library draws into, never for HTML strings.- Every bundle on Lightning Web Security, with no unsafe eval, no
unsafe-inlineand no wildcard origins. - Every
@AuraEnabledmethod written as a public endpoint:with sharing,WITH USER_MODEorSecurity.stripInaccessible, and no trust in parameters. - Know which classes the guest profile can call.
- Static resource libraries current, with the version recorded.
- Every
messagelistener checksevent.originagainst an exact allowlist.
What static analysis cannot tell you
A scan reads code and metadata. It can prove that a @wire value reaches innerHTML in the same component. It cannot prove that a given org has published the site that makes a component public, unless it reads that org, and it does not run your page in a browser. Where a check cannot judge something, Vulkro says it was not checked rather than calling it clean. The LWC and Aura checks list what each rule looks for.
Where to see it
The fastest feedback is in the editor. The VS Code extension shows these findings on the line in LWC JavaScript and templates as you save, with the reason and what to change. Fix security problems while you write the code describes that workflow, and security where Salesforce developers write code covers the extension in detail. If you are preparing a package, what fails the AgentExchange Security Review covers the rest of the reviewer's list.
vulkro-sf scan ./force-app
To run it on your own components, get started.
Frequently asked questions
Does Lightning Web Security prevent XSS in LWC?
Not on its own. Lightning Web Security isolates components and namespaces from each other and limits unsafe APIs, but if your component writes an HTML string into the DOM, the string is rendered. Bind values through the template, or use lightning-formatted-rich-text, instead of assigning HTML.
Is lwc:dom="manual" a vulnerability?
No. It is the documented way to let a third-party library such as D3 draw inside a component. It becomes a problem when the component writes HTML strings built from data into that container, which is why Vulkro reports the directive alone as Info and the directive plus an HTML write as Medium.
Does cacheable=true make an @AuraEnabled method safer?
No. It affects how the result is cached on the client, not who can call the method. Any user, including a site guest user, whose profile or permission set grants the class can call it directly with any parameters.
Can a guest user call my LWC's Apex?
If the guest profile has access to the Apex class, yes, whether or not the component is on a page they can see. Check which classes the guest profile grants, and write every method in those classes to enforce sharing and field-level security itself.
