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
calLinkfrom 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
postMessagehandlers 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.
2. Letting users control the Cal.com link
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, notinnerHTML, for surrounding UI - Serialize prefill/config as JSON, not inline JS string fragments
- Validate
postMessagewith strictoriginchecks - 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.