ToolJet is great for shipping internal tools fast. That speed is also exactly why people end up shipping XSS bugs fast.

I’ve seen the same pattern over and over: a team treats ToolJet like a safe abstraction layer, assumes the platform handles all rendering risks, then mixes user input into HTML, JavaScript, or query results. Now the “internal tool” is running arbitrary script in an admin’s browser.

That’s still XSS. Internal apps still hold production data, admin sessions, API tokens, and enough privileges to ruin your week.

Here are the common mistakes I keep seeing in ToolJet apps, and the fixes that actually work.

Mistake #1: Trusting {{ }} bindings too much

ToolJet makes it easy to bind values into components:

{{ components.textinput1.value }}

That convenience can make developers forget a basic rule: context matters. A value that is safe as plain text is not automatically safe inside HTML, JavaScript, URLs, or attributes.

A common bad pattern is taking user input and dropping it into a component that renders rich text or HTML.

<div>
  Welcome, {{ components.nameInput.value }}
</div>

If that component interprets the value as HTML instead of text, you’ve created an injection point.

Fix

Prefer plain text rendering whenever possible. If a component gives you a choice between rendering text and rendering HTML, pick text.

If you truly need formatted content, sanitize it first with a proven HTML sanitizer. Don’t try to strip <script> tags with regex. That’s how people end up allowing payloads in event handlers, SVG, MathML, or malformed markup.

Bad:

{{ components.commentInput.value.replace(/<script.*?>.*?<\/script>/gi, '') }}

Better:

import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(components.commentInput.value, {
  USE_PROFILES: { html: true }
});

Then render clean only in places explicitly meant for sanitized HTML.

Mistake #2: Using JavaScript mode like it’s a safe sandbox

ToolJet lets you write JavaScript for transformers, queries, and event handlers. That’s powerful, but it also means developers start assembling HTML strings or evaluating dynamic data.

I still find code like this:

const name = components.nameInput.value;
const html = `<img src="/avatar.png" alt="${name}">`;
return html;

That looks harmless until name contains a quote and an event handler payload.

Or worse:

eval(components.ruleInput.value)

If you are using eval, new Function, or building executable expressions from user-controlled input, you are not “being flexible.” You are wiring in code execution.

Fix

Never evaluate user-controlled input as code. Not in transformers. Not in custom components. Not anywhere.

If you need conditional behavior, define allowed operations explicitly:

const operations = {
  upper: (s) => s.toUpperCase(),
  lower: (s) => s.toLowerCase(),
  trim: (s) => s.trim(),
};

const selected = components.operationSelect.value;
const input = components.textInput.value;

return operations[selected] ? operations[selected](input) : input;

If you need to build DOM, use safe APIs in custom code:

const img = document.createElement('img');
img.src = '/avatar.png';
img.alt = components.nameInput.value;
container.appendChild(img);

Setting .textContent and safe element properties beats string-building every time.

Mistake #3: Rendering database content as “trusted”

A classic ToolJet workflow is: query database, show results in a table, detail panel, modal, or rich text widget.

The dangerous assumption is that data from your own database is trusted. It isn’t. If a support form, CSV import, webhook, or another admin tool can write into that database, stored XSS is on the table.

Example:

{{ queries.getTickets.data[0].description }}

If description contains attacker-controlled HTML and your component renders HTML, every viewer becomes a victim.

Fix

Treat database content as untrusted unless it has gone through a strict sanitization pipeline.

For user-generated content:

  • store raw input if you need auditability
  • sanitize on output for the exact rendering context
  • or sanitize on write and preserve a raw copy separately

If the field should never contain HTML, render it only as text.

For tables and lists, avoid custom HTML cell renderers unless you absolutely need them. Most of the time plain text cells are the right answer.

Mistake #4: Injecting values into URLs without validation

ToolJet apps often build links dynamically:

{{ `https://example.com/profile?user=${components.userInput.value}` }}

The bug shows up when developers allow full URLs from user input:

{{ components.redirectInput.value }}

Now someone enters:

javascript:alert(document.domain)

If that value lands in a link, button action, or iframe source, you’ve got a problem.

Fix

Validate URLs against an allowlist of protocols and, ideally, hosts.

function safeUrl(input) {
  try {
    const url = new URL(input, 'https://app.example.com');
    const allowedProtocols = ['http:', 'https:'];
    const allowedHosts = ['example.com', 'app.example.com'];

    if (!allowedProtocols.includes(url.protocol)) return null;
    if (!allowedHosts.includes(url.hostname)) return null;

    return url.toString();
  } catch {
    return null;
  }
}

Then use only validated output:

const url = safeUrl(components.redirectInput.value);
if (!url) throw new Error('Invalid URL');
return url;

Same rule for image sources, iframe URLs, file previews, and redirect destinations.

Mistake #5: Forgetting that custom components are just web apps

The moment you drop into a custom component, you are back in normal frontend security territory. ToolJet is no longer saving you from bad DOM manipulation.

I’ve seen custom components do this:

document.getElementById('preview').innerHTML = data.description;

That’s the usual XSS sink. Same bug, different wrapper.

Fix

Use textContent for text:

document.getElementById('preview').textContent = data.description;

If HTML is required, sanitize it first:

import DOMPurify from 'dompurify';

document.getElementById('preview').innerHTML =
  DOMPurify.sanitize(data.description);

Also watch for dangerous attribute assignment patterns:

element.setAttribute('onclick', userInput);

Don’t do that. Bind event listeners with functions you control:

button.addEventListener('click', () => {
  runApprovedAction();
});

Mistake #6: Building SQL safely but forgetting the UI is still vulnerable

A lot of teams correctly parameterize SQL queries and then assume the security work is done.

Example:

SELECT id, name, note
FROM customers
WHERE id = {{ components.customerId.value }}

Maybe the backend query is safe because ToolJet parameterizes it or because you used bindings correctly. Great. But if note later gets rendered into HTML unsafely, the app is still vulnerable.

Fix

Separate injection classes in your head:

  • SQL injection: fix with parameterized queries
  • XSS: fix with output encoding, safe rendering, sanitization, and CSP

One fix does not cover the other.

Mistake #7: No Content Security Policy

CSP won’t fix a bad innerHTML, but it does make many XSS bugs much harder to exploit. On internal tools, I consider CSP one of the highest-value controls because the blast radius is usually admin-heavy.

A good starting point looks like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';
  img-src 'self' data: https:;
  style-src 'self' 'unsafe-inline';
  connect-src 'self' https://api.example.com;

If you need help tuning a policy, https://csp-guide.com is a solid reference.

And before shipping headers, I usually run a quick check with HeaderTest to catch missing or weak security headers. It’s fast, and it saves the usual back-and-forth over whether the policy actually made it to production.

Fix

Set CSP at the reverse proxy or app server level. Start in report-only mode if you need to gather violations without breaking the app:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m';
  object-src 'none';
  base-uri 'self';
  report-to default-endpoint;

Then tighten until you can enforce it.

Mistake #8: Assuming “internal only” means low risk

This one is more cultural than technical, but it causes plenty of XSS.

Teams think:

  • only employees can access the app
  • SSO is enabled
  • nobody malicious is inside the company

That’s fantasy. Internal tools are prime targets because they often expose admin actions, customer records, billing controls, and production operations. A low-privileged attacker, compromised employee account, or poisoned upstream data source is enough.

Fix

Threat model internal apps like real apps.

That means:

  • sanitize any HTML you intentionally allow
  • render untrusted content as text by default
  • validate URLs and action targets
  • avoid dangerous sinks like innerHTML, outerHTML, insertAdjacentHTML, eval, and inline event handlers
  • use CSP
  • review custom components like any other frontend code

A practical review checklist for ToolJet apps

When I review a ToolJet app for XSS, I look for these first:

  1. Any component rendering HTML or rich text
  2. Any {{ }} binding inside HTML-ish contexts
  3. Custom components using innerHTML
  4. User-controlled links, redirects, iframes, or image URLs
  5. Database content displayed in modal/detail views
  6. JavaScript transformers building markup strings
  7. Any use of eval, new Function, or dynamic script execution
  8. Missing CSP and weak response headers

If you fix those, you eliminate most of the real-world XSS bugs I see in low-code and internal tool platforms.

ToolJet doesn’t create XSS by itself. People do, usually by forgetting that untrusted data stays untrusted all the way to the browser. Once you keep that mental model straight, the fixes are pretty boring: render text, sanitize HTML, validate URLs, avoid dangerous DOM APIs, and back it up with CSP. Boring is good. Boring is how you keep admin sessions from turning into incident reports.