Cal.com embeds are convenient, but they also create one of my least favorite frontend security situations: third-party UI mixed with app code that was never designed for hostile input.

The Cal.com script itself isn’t the usual problem. The problem is everything around it: how teams build embed URLs, how they pass prefill data, how they read query params, and how they inject user-controlled values into the page next to the widget.

If you embed Cal.com on a marketing site, dashboard, or multi-tenant app, treat every bit of scheduling data as untrusted. Names, emails, booking notes, usernames, event slugs, and anything coming from location.search can become an XSS sink if you render it carelessly.

The risky parts of a Cal.com embed

A typical Cal.com embed looks roughly like this:

<div id="my-cal-embed"></div>
<script type="text/javascript">
  (function (C, A, L) {
    let p = function (a, ar) {
      a.q.push(ar);
    };
    let d = C.document;
    C.Cal =
      C.Cal ||
      function () {
        let cal = C.Cal;
        let ar = arguments;
        if (!cal.loaded) {
          cal.ns = {};
          cal.q = cal.q || [];
          d.head.appendChild(d.createElement("script")).src = A;
          cal.loaded = true;
        }
        if (ar[0] === L) {
          const api = function () {
            p(api, arguments);
          };
          const namespace = ar[1];
          api.q = api.q || [];
          if (typeof namespace === "string") {
            cal.ns[namespace] = cal.ns[namespace] || api;
            p(cal.ns[namespace], ar);
            p(cal, ["initNamespace", namespace]);
          } else {
            p(cal, ar);
          }
          return;
        }
        p(cal, ar);
      };
  })(window, "https://app.cal.com/embed/embed.js", "init");

  Cal("init", "booking", { origin: "https://app.cal.com" });
  Cal.ns.booking("inline", {
    elementOrSelector: "#my-cal-embed",
    calLink: "acme/demo"
  });
</script>

That’s fine if calLink is hardcoded and everything around it is static.

Things get sketchy when developers do this:

  • Build calLink from user input
  • Copy query params into the page with innerHTML
  • Reflect booking data in confirmation UI
  • Store embed config in CMS fields and render without validation
  • Use postMessage handlers without checking origin
  • Add permissive CSP like script-src * 'unsafe-inline'

Common XSS mistakes

1. Building HTML with untrusted values

This is the classic bug. I still see it everywhere.

Vulnerable

<div id="booking-header"></div>
<script>
  const params = new URLSearchParams(location.search);
  const name = params.get("name") || "Guest";

  document.getElementById("booking-header").innerHTML =
    `<h2>Book a call, ${name}</h2>`;
</script>

If name is:

<img src=x onerror=alert(1)>

you’ve got DOM XSS.

Safe

<div id="booking-header"></div>
<script>
  const params = new URLSearchParams(location.search);
  const name = params.get("name") || "Guest";

  const h2 = document.createElement("h2");
  h2.textContent = `Book a call, ${name}`;
  document.getElementById("booking-header").replaceChildren(h2);
</script>

If you only need text, use textContent. Most XSS bugs disappear when you stop generating HTML strings.


A lot of teams support multiple hosts or event types and do something like this:

Vulnerable

const params = new URLSearchParams(location.search);
const calLink = params.get("calLink") || "acme/default";

Cal("init", "booking", { origin: "https://app.cal.com" });
Cal.ns.booking("inline", {
  elementOrSelector: "#my-cal-embed",
  calLink
});

This may not directly become script execution inside your app, but it does let attackers control what gets embedded. That can become phishing, UI redress, tenant confusion, or a stepping stone to other bugs.

Safe: allowlist only

const allowedCalLinks = new Set([
  "acme/demo",
  "acme/support",
  "acme/sales"
]);

const params = new URLSearchParams(location.search);
const requested = params.get("type");
const calLinkMap = {
  demo: "acme/demo",
  support: "acme/support",
  sales: "acme/sales"
};

const calLink = calLinkMap[requested] || "acme/demo";

Cal("init", "booking", { origin: "https://app.cal.com" });
Cal.ns.booking("inline", {
  elementOrSelector: "#my-cal-embed",
  calLink
});

Don’t accept raw embed targets from the browser. Accept a small key and map it server-side or in hardcoded frontend config.


3. Injecting prefill values into HTML and JavaScript

Prefill data is useful, and it’s a common place to mess things up.

Vulnerable server-rendered template

<script>
  window.bookingPrefill = {
    name: "{{ .Name }}",
    email: "{{ .Email }}",
    notes: "{{ .Notes }}"
  };
</script>

If those values are not correctly escaped for JavaScript string context, you can break out of the object and run script.

Safer pattern: JSON script block

<script type="application/json" id="booking-prefill">
{
  "name": "Taylor",
  "email": "[email protected]",
  "notes": "Interested in enterprise pricing"
}
</script>

<script>
  const prefill = JSON.parse(
    document.getElementById("booking-prefill").textContent
  );

  Cal("init", "booking", { origin: "https://app.cal.com" });
  Cal.ns.booking("inline", {
    elementOrSelector: "#my-cal-embed",
    calLink: "acme/demo",
    config: {
      name: prefill.name,
      email: prefill.email
    }
  });
</script>

If you render this from the server, use your template engine’s JSON serializer, not string concatenation.


4. Unsafe confirmation UI after booking

Teams often show a “Thanks, {{name}}” message next to the embed or after a booking callback.

Vulnerable

function showConfirmation(name, email) {
  document.getElementById("status").innerHTML = `
    <p>Thanks ${name}, confirmation sent to ${email}</p>
  `;
}

Safe

function showConfirmation(name, email) {
  const p = document.createElement("p");
  p.textContent = `Thanks ${name}, confirmation sent to ${email}`;
  document.getElementById("status").replaceChildren(p);
}

If you absolutely must support rich HTML, sanitize it with a well-maintained HTML sanitizer and keep the allowed tags tiny. Personally, I avoid this unless there’s a very good reason.

5. Trusting postMessage without origin checks

Embeds often involve postMessage. If you listen for messages from the Cal.com iframe or script, verify the sender.

Vulnerable

window.addEventListener("message", (event) => {
  if (event.data.type === "bookingComplete") {
    document.getElementById("status").innerHTML = event.data.message;
  }
});

Any window can send that message.

Safe

const CAL_ORIGIN = "https://app.cal.com";

window.addEventListener("message", (event) => {
  if (event.origin !== CAL_ORIGIN) return;
  if (!event.data || typeof event.data !== "object") return;

  if (event.data.type === "bookingComplete") {
    const status = document.getElementById("status");
    status.textContent = "Booking complete.";
  }
});

Check origin, validate the message shape, and don’t dump message data into innerHTML.

A safe baseline embed

Here’s a copy-paste setup I’d actually ship as a starting point:

<div id="booking-title"></div>
<div id="my-cal-embed"></div>
<div id="status" aria-live="polite"></div>

<script type="application/json" id="booking-config">
{
  "type": "demo",
  "name": "Taylor",
  "email": "[email protected]"
}
</script>

<script>
  (function (C, A, L) {
    let p = function (a, ar) { a.q.push(ar); };
    let d = C.document;
    C.Cal = C.Cal || function () {
      let cal = C.Cal;
      let ar = arguments;
      if (!cal.loaded) {
        cal.ns = {};
        cal.q = cal.q || [];
        d.head.appendChild(d.createElement("script")).src = A;
        cal.loaded = true;
      }
      if (ar[0] === L) {
        const api = function () { p(api, arguments); };
        const namespace = ar[1];
        api.q = api.q || [];
        if (typeof namespace === "string") {
          cal.ns[namespace] = cal.ns[namespace] || api;
          p(cal.ns[namespace], ar);
          p(cal, ["initNamespace", namespace]);
        } else {
          p(cal, ar);
        }
        return;
      }
      p(cal, ar);
    };
  })(window, "https://app.cal.com/embed/embed.js", "init");

  const cfg = JSON.parse(document.getElementById("booking-config").textContent);

  const calLinkMap = {
    demo: "acme/demo",
    support: "acme/support"
  };

  const calLink = calLinkMap[cfg.type] || "acme/demo";

  const title = document.createElement("h2");
  title.textContent = `Book your ${cfg.type} call`;
  document.getElementById("booking-title").replaceChildren(title);

  Cal("init", "booking", { origin: "https://app.cal.com" });
  Cal.ns.booking("inline", {
    elementOrSelector: "#my-cal-embed",
    calLink,
    config: {
      name: String(cfg.name || ""),
      email: String(cfg.email || "")
    }
  });

  window.addEventListener("message", (event) => {
    if (event.origin !== "https://app.cal.com") return;
    if (!event.data || typeof event.data !== "object") return;

    if (event.data.type === "bookingComplete") {
      document.getElementById("status").textContent = "Booking complete.";
    }
  });
</script>

CSP for Cal.com embeds

CSP won’t fix unsafe innerHTML, but it will reduce blast radius when someone makes a mistake. If you embed Cal.com, define a policy that only allows the specific script and frame origins you need.

A practical starting point looks like this:

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

Tune this to your app. If you’re using nonces or hashes for inline scripts, even better. For implementation details, the one external guide I’d point people to is https://csp-guide.com.

Cal.com embed XSS checklist

Use this before shipping:

  • Hardcode or allowlist calLink
  • Never pass browser-controlled values directly into embed config without validation
  • Use textContent, not innerHTML, for surrounding UI
  • Serialize prefill/config as JSON, not inline JS string fragments
  • Validate postMessage with strict origin checks
  • Add a restrictive CSP
  • Treat CMS-managed embed settings as untrusted input
  • Review tenant-specific branding fields for HTML injection bugs

Most XSS issues around Cal.com embeds are self-inflicted. The embed is just the place where unsafe app code and untrusted scheduling data meet. Keep the boundaries tight, avoid HTML string building, and you’ll dodge the usual mess.

For official embed behavior and options, check the Cal.com documentation you’re using internally and match your CSP to the exact resources required.