Tally is a nice form tool. It’s fast to embed, easy to customize, and teams ship it without much ceremony. That convenience is also where people get sloppy.
The Tally form itself is usually not the weak point. The mess tends to happen in the code around it: unsafe embed options, rendering form answers back into your app, trusting webhook payloads, or mixing user-controlled values into the DOM. That’s where XSS shows up.
If you use Tally on a marketing site, dashboard, or internal tool, here’s how I’d think about it.
Where XSS happens with Tally forms
There are a few common patterns:
- Embedding Tally with unsafe dynamic values
- Displaying submitted form data in your own frontend
- Processing webhooks and later rendering the data in admin panels
- Passing query parameters into forms and reflecting them elsewhere
- Using
innerHTMLfor convenience
The critical point: Tally collects input, but your app usually renders it somewhere later. Stored XSS becomes very realistic if you don’t encode output.
A classic example:
<div id="feedback"></div>
<script>
// Pretend this came from a Tally webhook or your database
const submission = {
name: 'Alice',
comment: '<img src=x onerror=alert("XSS")>'
};
document.getElementById('feedback').innerHTML = `
<h3>${submission.name}</h3>
<p>${submission.comment}</p>
`;
</script>
That’s game over. If a user submits malicious HTML or script-bearing attributes, your page executes it.
Safe Tally embeds
A basic Tally embed often looks harmless:
<iframe
src="https://tally.so/r/your-form-id"
width="100%"
height="500"
frameborder="0"
marginheight="0"
marginwidth="0"
title="Contact form">
</iframe>
That’s generally fine. The trouble starts when developers build the src dynamically from user input.
Bad: building the iframe URL from untrusted input
const formId = new URLSearchParams(location.search).get('form');
document.getElementById('form-container').innerHTML = `
<iframe src="https://tally.so/r/${formId}"></iframe>
`;
If you’re using innerHTML, you’ve already made this worse than it needed to be. Even if the domain is fixed, attribute injection bugs happen when values aren’t tightly controlled.
Better: create DOM nodes safely and allowlist values
const allowedForms = new Set(['abc123', 'xyz789']);
const params = new URLSearchParams(location.search);
const formId = params.get('form');
const safeFormId = allowedForms.has(formId) ? formId : 'abc123';
const iframe = document.createElement('iframe');
iframe.src = `https://tally.so/r/${encodeURIComponent(safeFormId)}`;
iframe.width = '100%';
iframe.height = '500';
iframe.title = 'Contact form';
document.getElementById('form-container').appendChild(iframe);
That fixes two things:
- No HTML string concatenation
- Only known form IDs are allowed
If you want to sanity check the security headers on pages where you embed third-party content, I like using HeaderTest to quickly inspect CSP and framing-related headers.
The real problem: rendering Tally submissions
Most teams eventually do this:
- Accept a form submission in Tally
- Send it through a webhook, Zapier, Make, or custom backend
- Store it
- Show it in an internal dashboard or CRM-like UI
That dashboard is where stored XSS usually lands.
Bad: injecting form answers with innerHTML
async function renderSubmission(id) {
const res = await fetch(`/api/submissions/${id}`);
const submission = await res.json();
const container = document.getElementById('submission');
container.innerHTML = `
<h2>${submission.fields.name}</h2>
<div>Email: ${submission.fields.email}</div>
<div>Message: ${submission.fields.message}</div>
`;
}
If message contains <svg onload=alert(1)>, your admin panel becomes an attack surface.
Better: use textContent
async function renderSubmission(id) {
const res = await fetch(`/api/submissions/${id}`);
const submission = await res.json();
const container = document.getElementById('submission');
container.innerHTML = '';
const title = document.createElement('h2');
title.textContent = submission.fields.name;
const email = document.createElement('div');
email.textContent = `Email: ${submission.fields.email}`;
const message = document.createElement('div');
message.textContent = `Message: ${submission.fields.message}`;
container.append(title, email, message);
}
This is boring code, which is exactly why it’s good.
If you must support formatting, sanitize HTML
Sometimes teams want rich text. Maybe a Tally textarea is later shown with clickable links or basic formatting. Raw HTML from users is not acceptable. Sanitize it first.
On the server, I’d rather sanitize once before rendering than trust every frontend developer to remember the rules.
Example with Node.js and sanitize-html
npm install sanitize-html
import sanitizeHtml from 'sanitize-html';
function cleanUserHtml(input) {
return sanitizeHtml(input, {
allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
allowedAttributes: {
a: ['href']
},
allowedSchemes: ['http', 'https', 'mailto']
});
}
Then render only the sanitized result:
app.get('/submission/:id', async (req, res) => {
const submission = await db.getSubmission(req.params.id);
res.json({
...submission,
fields: {
...submission.fields,
messageHtml: cleanUserHtml(submission.fields.message || '')
}
});
});
And in the frontend:
messageContainer.innerHTML = submission.fields.messageHtml;
Notice the rule: only sanitized HTML goes into innerHTML. Never raw user input.
Webhooks are not trusted content
Tally webhooks are useful, but developers often mentally classify them as “system data.” That’s a mistake.
A webhook payload may contain user-submitted text. Treat every field as untrusted, even if it came from a legitimate Tally event.
Example Express webhook handler
app.post('/webhooks/tally', express.json(), async (req, res) => {
const payload = req.body;
// Verify webhook authenticity if your integration supports it
// Then still treat all submitted fields as untrusted
const submission = {
formId: payload.formId,
respondentId: payload.responseId,
name: payload.fields?.name ?? '',
email: payload.fields?.email ?? '',
message: payload.fields?.message ?? ''
};
await db.saveSubmission(submission);
res.sendStatus(204);
});
Saving it is fine. Rendering it later without encoding is the bug.
Query params and hidden fields can still bite you
A lot of Tally setups prefill fields from the page URL. That’s convenient for campaign tracking or passing context like ?name=Chris&plan=pro.
The mistake is reflecting those values elsewhere on your site.
Bad
const params = new URLSearchParams(location.search);
document.getElementById('welcome').innerHTML =
`Welcome back, ${params.get('name')}`;
Good
const params = new URLSearchParams(location.search);
document.getElementById('welcome').textContent =
`Welcome back, ${params.get('name') || 'friend'}`;
If you copy tracking values into hidden Tally fields and later show them in dashboards, reports, or confirmation pages, they need the same output encoding as any other user input.
CSP is your backup, not your excuse
A good Content Security Policy won’t fix unsafe DOM rendering, but it can make XSS much harder to exploit.
For pages embedding Tally, I’d usually start with something like this and then tune it:
Content-Security-Policy:
default-src 'self';
script-src 'self';
frame-src https://tally.so;
child-src https://tally.so;
img-src 'self' data: https:;
style-src 'self' 'unsafe-inline';
connect-src 'self';
base-uri 'self';
object-src 'none';
frame-ancestors 'self';
If your page needs Tally’s embed script or other third-party assets, adjust the policy carefully. Don’t just slap https: into script-src and call it a day.
If you need a deeper CSP rollout strategy, csp-guide.com is a solid reference.
A few opinions from experience:
object-src 'none'should be standardbase-uri 'self'blocks some weird abuse cases people forget about- Avoid broad
script-srcexceptions - Report-only mode is useful, but don’t leave it there forever
Server-side validation still matters
Validation does not replace output encoding, but I still want it.
For Tally-connected backends:
- Enforce max lengths
- Reject unexpected field types
- Normalize text where appropriate
- Store raw and sanitized versions separately if you need auditability
Example with Zod:
npm install zod
import { z } from 'zod';
const SubmissionSchema = z.object({
name: z.string().max(100),
email: z.string().email().max(320),
message: z.string().max(5000)
});
app.post('/webhooks/tally', express.json(), async (req, res) => {
const parsed = SubmissionSchema.safeParse({
name: req.body.fields?.name,
email: req.body.fields?.email,
message: req.body.fields?.message
});
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid payload' });
}
await db.saveSubmission(parsed.data);
res.sendStatus(204);
});
This won’t stop <script> strings from existing in input, and that’s fine. Validation keeps data sane. Output encoding keeps users safe.
A practical checklist
If I were reviewing a Tally integration for XSS, I’d check these first:
- Are any Tally values inserted with
innerHTML? - Are confirmation pages reflecting query params unsafely?
- Are webhook payloads treated as trusted?
- Are admin dashboards rendering submissions without encoding?
- Is any rich text sanitized with a proven library?
- Is there a CSP limiting script execution and framing?
- Are embed URLs built from strict allowlists instead of raw input?
That’s usually enough to find the real bugs.
Tally forms are not uniquely dangerous. They just sit at a point where untrusted input enters your system, and that always deserves paranoia. The fix is the same as every other XSS fix: treat input as hostile, encode on output, sanitize when HTML is unavoidable, and lock things down with CSP so one mistake doesn’t become an incident.