Zapier integrations look harmless until you remember what they really do: move untrusted data between systems at high speed. Names, emails, form answers, ticket content, CRM notes, webhook payloads, markdown blobs, HTML snippets — it all gets piped around and eventually lands in somebody’s UI.
That’s where teams get burned. They think, “Zapier just passes data through,” and forget that passthrough data becomes dangerous the moment they render it in a browser, email preview, admin panel, or embedded app.
Here are the XSS mistakes I see most often in Zapier integrations, and how I’d fix them.
Mistake #1: Trusting mapped fields because they came from Zapier
A Zapier field might look structured in the editor, but the value is still untrusted input. If a user controls the source app, they control the string. That includes trigger data, search results, webhook input, and custom fields.
I’ve seen developers do this in internal dashboards:
app.get('/zap-runs/:id', async (req, res) => {
const run = await getZapRun(req.params.id);
res.send(`
<h1>${run.title}</h1>
<div>${run.notes}</div>
`);
});
If run.title or run.notes contains <img src=x onerror=alert(1)>, you’ve got stored XSS.
Fix
Escape output based on context. For HTML text nodes, encode special characters before rendering.
function escapeHtml(value = '') {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
app.get('/zap-runs/:id', async (req, res) => {
const run = await getZapRun(req.params.id);
res.send(`
<h1>${escapeHtml(run.title)}</h1>
<div>${escapeHtml(run.notes)}</div>
`);
});
Better yet, use a templating system that escapes by default.
Mistake #2: Rendering webhook payloads as “debug HTML”
Teams love debug pages for webhook testing. They also love dumping raw payloads into the DOM.
app.post('/webhooks/zapier', express.json(), (req, res) => {
savePayload(req.body);
res.sendStatus(200);
});
app.get('/debug/latest', async (req, res) => {
const payload = await getLatestPayload();
res.send(`
<h2>Latest Payload</h2>
<pre>${JSON.stringify(payload, null, 2)}</pre>
`);
});
This looks safe until someone sends a string containing </pre><script>alert(1)</script>.
Fix
Escape before embedding JSON in HTML.
app.get('/debug/latest', async (req, res) => {
const payload = await getLatestPayload();
const pretty = escapeHtml(JSON.stringify(payload, null, 2));
res.send(`
<h2>Latest Payload</h2>
<pre>${pretty}</pre>
`);
});
If you need interactive debugging in the browser, send JSON with the correct content type instead of building HTML around it.
app.get('/debug/latest.json', async (req, res) => {
const payload = await getLatestPayload();
res.json(payload);
});
That’s boring. Boring is good.
Mistake #3: Using innerHTML for mapped Zapier data
This one shows up in embedded admin UIs, React escape hatches, and custom setup pages.
const output = document.getElementById('output');
output.innerHTML = zapData.message;
If zapData.message came from a webhook, form submission, or third-party app, that’s game over.
Fix
Use textContent for plain text.
const output = document.getElementById('output');
output.textContent = zapData.message;
If you actually need HTML, sanitize it first with a well-maintained sanitizer and keep the allowed tags tiny. Most teams don’t need rich HTML here; they just haven’t admitted it yet.
Mistake #4: Treating markdown as safe
Zapier workflows often move markdown between tools: help desks, docs systems, note apps, CRMs. Developers render it for previews and assume markdown is “just text.”
It isn’t. Many markdown parsers allow raw HTML by default.
const html = marked.parse(zapInput.description);
preview.innerHTML = html;
If the input contains <script>, onerror, or javascript: URLs and your parser/sanitizer setup is weak, you’ve created XSS through a very friendly-looking feature.
Fix
Disable raw HTML in the parser if possible, or sanitize the rendered output.
import DOMPurify from 'dompurify';
import { marked } from 'marked';
const rawHtml = marked.parse(zapInput.description);
const safeHtml = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'code', 'pre', 'blockquote'],
ALLOWED_ATTR: ['href']
});
preview.innerHTML = safeHtml;
Also validate links. A sanitized <a> tag is still a problem if href="javascript:alert(1)" slips through because of bad config.
Mistake #5: Injecting Zapier data into JavaScript strings
This is a classic server-rendered app bug. Somebody wants to pass Zapier data into the page and does this:
res.send(`
<script>
window.zapConfig = {
customerName: "${run.customerName}",
note: "${run.note}"
};
</script>
`);
If run.note contains ";alert(1);//, your script block becomes executable input.
Fix
Serialize safely for JavaScript context. Don’t hand-roll this.
res.send(`
<script>
window.zapConfig = ${JSON.stringify({
customerName: run.customerName,
note: run.note
})};
</script>
`);
Even then, be careful with inline scripts. A strong Content Security Policy helps reduce blast radius. If you’re implementing CSP, https://csp-guide.com is a practical reference. Official browser documentation is also worth keeping handy.
Mistake #6: Building links and attributes without validation
A lot of Zapier-connected apps render links from mapped fields:
res.send(`<a href="${run.profileUrl}">View profile</a>`);
Escaping quotes is not enough. If profileUrl is javascript:alert(1), clicking the link executes code.
Same problem with image sources, iframe URLs, and form actions.
Fix
Validate URLs by scheme and, when possible, by host.
function safeUrl(input) {
try {
const url = new URL(input, 'https://example.com');
const allowedProtocols = ['http:', 'https:'];
if (!allowedProtocols.includes(url.protocol)) {
return null;
}
return url.href;
} catch {
return null;
}
}
const profileUrl = safeUrl(run.profileUrl);
res.send(
profileUrl
? `<a href="${escapeHtml(profileUrl)}">View profile</a>`
: `<span>Invalid profile URL</span>`
);
If the field should only ever point to your own app, enforce that. Don’t accept arbitrary destinations because it’s “more flexible.”
Mistake #7: Assuming internal Zapier-only tools are safe
Internal tools are where I see some of the worst XSS. The thinking goes like this:
- only staff can access it
- the data comes from business systems
- it’s behind SSO
- nobody would attack this
Then a support ticket body, CRM field, or webhook test payload contains script, and now your internal admin panel runs attacker-controlled code with employee privileges.
Zapier increases this risk because it aggregates data from many places, including places with weak input controls.
Fix
Treat internal tools like public apps. Same output encoding rules. Same sanitization rules. Same CSP mindset. Same code review standards.
If your staff dashboard renders anything sourced from Zapier, I’d assume it is attacker-controlled until proven otherwise.
Mistake #8: Sanitizing on input only
Some teams try to clean data as it enters through Zapier and call it done.
const cleanNotes = sanitize(req.body.notes);
saveToDatabase(cleanNotes);
That sounds nice, but it breaks down fast:
- different output contexts need different protections
- sanitizers change over time
- one field might be used as text in one place and HTML in another
- you may need the original raw value for logs or exports
Fix
Validate on input, encode on output.
A practical pattern looks like this:
- validate type, length, and format when receiving Zapier data
- store raw data unless you have a strong reason not to
- apply context-specific escaping or sanitization when rendering
For example:
function validateZapComment(input) {
if (typeof input !== 'string') return '';
return input.slice(0, 5000);
}
app.post('/webhooks/zapier', express.json(), async (req, res) => {
const comment = validateZapComment(req.body.comment);
await saveComment({ rawComment: comment });
res.sendStatus(200);
});
app.get('/comments/:id', async (req, res) => {
const comment = await getComment(req.params.id);
res.send(`<p>${escapeHtml(comment.rawComment)}</p>`);
});
That’s predictable and hard to misuse.
Mistake #9: No CSP backup layer
CSP won’t fix bad encoding, but it can stop some mistakes from turning into full compromise. If your Zapier-fed UI accidentally injects a script payload, a strict CSP can block inline script execution and reduce damage.
A basic Express example:
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
);
next();
});
If your app still relies on inline scripts, fix that first. A weak CSP full of exceptions is mostly decorative.
For implementation details, https://csp-guide.com is useful, and official browser documentation covers directive behavior.
A simple checklist I’d use for Zapier integrations
When data enters from Zapier, I’d ask:
- Can a user or third-party system control this value?
- Will this value ever be rendered in HTML?
- Will it be inserted into attributes, URLs, or JavaScript?
- Are we using
innerHTML, markdown rendering, or rich text preview? - Do our templates escape by default?
- Do debug tools and admin views follow the same rules?
- Do we have a CSP that blocks inline script?
If you only fix one thing, fix the habit of trusting Zapier-mapped fields. Zapier is a transport layer, not a sanitizer. The dangerous part isn’t the automation. The dangerous part is what your app does with the data after automation makes it feel routine.
That’s how XSS sneaks in: not through some exotic payload, but through a “helpful” integration field that nobody treated like hostile input.