Calendly embeds look harmless. Paste a script, drop in a div, ship the feature. That’s exactly why they get teams into trouble.
I’ve seen this pattern more than once: a marketing or growth team wants faster scheduling, engineering wires up Calendly, and over time the embed becomes dynamic. Someone adds query parameters for prefilled name, email, company, campaign tags, maybe even a “custom message” field pulled from the URL. Now the embed code is no longer static. It’s generating HTML or URLs from user-controlled input.
That’s where XSS starts creeping in.
This case study walks through a real-world style failure mode: not that Calendly itself is executing your attacker’s JavaScript, but that your app wraps the embed unsafely and turns a scheduling widget into an XSS sink.
The setup
A SaaS company had a demo booking page:
- marketing campaign links prefilled prospect details
- the page read values from the query string
- those values were shown in a “Booking for…” heading
- the same values were passed into the Calendly embed config
The team assumed the risk was low because “it’s just Calendly.” The bug was entirely in their own integration.
The vulnerable version
Here’s a simplified version of the original code:
<div id="booking-summary"></div>
<div id="calendly-widget"></div>
<script src="https://assets.calendly.com/assets/external/widget.js"></script>
<script>
const params = new URLSearchParams(window.location.search);
const name = params.get("name") || "";
const email = params.get("email") || "";
const company = params.get("company") || "";
const note = params.get("note") || "";
document.getElementById("booking-summary").innerHTML = `
<h2>Booking for ${name}</h2>
<p>${company}</p>
<p>${note}</p>
`;
Calendly.initInlineWidget({
url: `https://calendly.com/acme-sales/demo?name=${name}&email=${email}&a1=${company}&a2=${note}`,
parentElement: document.getElementById("calendly-widget")
});
</script>
This code has two separate problems.
1. Direct DOM XSS through innerHTML
The obvious one:
document.getElementById("booking-summary").innerHTML = `
<h2>Booking for ${name}</h2>
<p>${company}</p>
<p>${note}</p>
`;
If name is:
<img src=x onerror=alert(1)>
you’ve got script execution in your origin, not Calendly’s.
2. Unsafe URL construction
This line is also sloppy:
url: `https://calendly.com/acme-sales/demo?name=${name}&email=${email}&a1=${company}&a2=${note}`
Even if this exact usage doesn’t immediately pop XSS by itself, it creates a dangerous habit: concatenating untrusted data into URLs and then feeding those URLs into third-party widgets, redirects, links, or iframes.
I’m opinionated on this one: if your code handles attacker-controlled data and builds HTML strings or URLs manually, you’re already on borrowed time.
How it got exploited
The exploit path was boring, which is usually how real incidents happen.
A campaign URL looked like this:
/demo?name=Taylor&company=Northwind¬e=Interested%20in%20enterprise
An attacker changed it to:
/demo?name=<img%20src=x%20onerror=alert(document.domain)>&company=Northwind¬e=Interested
The payload executed as soon as the booking page loaded. No need to click anything.
In the actual environment, the impact was worse than an alert box:
- session context on the app’s main domain
- access to page content and any non-HttpOnly tokens
- ability to fake UI around the booking flow
- potential theft of PII prefilled into the page
If the booking page lives on your primary app origin, XSS there is not “just a marketing bug.”
The first attempted fix that still wasn’t good enough
The team’s first patch was classic:
const safeName = name.replace(/</g, "<").replace(/>/g, ">");
document.getElementById("booking-summary").innerHTML = `
<h2>Booking for ${safeName}</h2>
<p>${company}</p>
<p>${note}</p>
`;
This is the kind of fix that passes a quick smoke test and still leaves holes:
- only one field was escaped
- only
<and>were handled innerHTMLwas still in use- future developers would assume the pattern was “safe enough”
Hand-rolled escaping is where security fixes go to die.
The real fix
They replaced string-built HTML with DOM APIs, and stopped manually building the Calendly URL.
After: safe rendering
<div id="booking-summary"></div>
<div id="calendly-widget"></div>
<script src="https://assets.calendly.com/assets/external/widget.js"></script>
<script>
const params = new URLSearchParams(window.location.search);
function getParam(name, maxLength = 200) {
const value = params.get(name) || "";
return value.slice(0, maxLength);
}
const booking = {
name: getParam("name", 100),
email: getParam("email", 150),
company: getParam("company", 100),
note: getParam("note", 500)
};
const summary = document.getElementById("booking-summary");
const title = document.createElement("h2");
title.textContent = `Booking for ${booking.name || "guest"}`;
const company = document.createElement("p");
company.textContent = booking.company;
const note = document.createElement("p");
note.textContent = booking.note;
summary.replaceChildren(title, company, note);
</script>
This change matters because textContent treats input as text, not markup. That one decision kills a whole class of DOM XSS.
After: safe Calendly URL building
<script>
const calendlyUrl = new URL("https://calendly.com/acme-sales/demo");
calendlyUrl.searchParams.set("name", booking.name);
calendlyUrl.searchParams.set("email", booking.email);
calendlyUrl.searchParams.set("a1", booking.company);
calendlyUrl.searchParams.set("a2", booking.note);
Calendly.initInlineWidget({
url: calendlyUrl.toString(),
parentElement: document.getElementById("calendly-widget")
});
</script>
This doesn’t just make the code cleaner. It guarantees proper encoding of parameter values and removes the temptation to splice raw strings into URLs.
Better still: validate before you render or forward
The final version added actual constraints instead of treating every param as acceptable input.
function cleanText(value, maxLength) {
return (value || "").trim().slice(0, maxLength);
}
function cleanEmail(value) {
const email = (value || "").trim().slice(0, 150);
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : "";
}
const booking = {
name: cleanText(params.get("name"), 100),
email: cleanEmail(params.get("email")),
company: cleanText(params.get("company"), 100),
note: cleanText(params.get("note"), 500)
};
That gave them three wins:
- reduced XSS exposure
- prevented garbage data from polluting Calendly events
- made abuse and weird edge cases easier to reason about
CSP helped, but it wasn’t the fix
They also added a Content Security Policy. Good move, but I’m going to be blunt: CSP is backup, not absolution.
A reasonable policy for this setup might look like:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://assets.calendly.com;
frame-src https://calendly.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self';
base-uri 'self';
object-src 'none';
frame-ancestors 'self';
If you’re rolling out CSP and need implementation guidance, https://csp-guide.com is useful. For browser behavior and directives, the official references are the MDN and browser docs.
Still, the page was vulnerable before because of unsafe DOM usage. CSP reduced blast radius; it didn’t remove the root cause.
The architectural fix that made future mistakes less likely
The strongest change wasn’t a regex or a CSP header. It was separating trusted config from untrusted content.
Before, the page mixed everything together:
- third-party widget setup
- URL parsing
- HTML rendering
- campaign personalization
After, they broke it into rules:
- Calendly base URL is hardcoded.
- Only specific query parameters are accepted.
- All displayed values go through
textContent. - All outbound widget params go through
URLandsearchParams. - No inline HTML templates with untrusted values.
That kind of guardrail survives team turnover.
A safer embed pattern I’d actually approve
If I were reviewing a PR today, I’d be happy with something like this:
<div id="booking-summary"></div>
<div id="calendly-widget"></div>
<script src="https://assets.calendly.com/assets/external/widget.js"></script>
<script>
const allowedFields = {
name: 100,
email: 150,
company: 100,
note: 500
};
const params = new URLSearchParams(location.search);
function getSafeField(field) {
const max = allowedFields[field];
if (!max) return "";
return (params.get(field) || "").trim().slice(0, max);
}
function getSafeEmail() {
const email = getSafeField("email");
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : "";
}
const data = {
name: getSafeField("name"),
email: getSafeEmail(),
company: getSafeField("company"),
note: getSafeField("note")
};
const summary = document.getElementById("booking-summary");
const heading = document.createElement("h2");
heading.textContent = data.name ? `Booking for ${data.name}` : "Book a demo";
summary.appendChild(heading);
if (data.company) {
const p = document.createElement("p");
p.textContent = data.company;
summary.appendChild(p);
}
const url = new URL("https://calendly.com/acme-sales/demo");
if (data.name) url.searchParams.set("name", data.name);
if (data.email) url.searchParams.set("email", data.email);
if (data.company) url.searchParams.set("a1", data.company);
if (data.note) url.searchParams.set("a2", data.note);
Calendly.initInlineWidget({
url: url.toString(),
parentElement: document.getElementById("calendly-widget")
});
</script>
No magic. No sanitizer dependency for plain text. No risky HTML insertion. Just disciplined handling of user input.
What developers usually miss with Calendly embeds
The mistake isn’t “using Calendly.” The mistake is wrapping a trusted third-party widget with untrusted local code.
The red flags I watch for:
innerHTMLnear query string values- string concatenation for embed URLs
- passing arbitrary custom fields from the URL
- booking pages hosted on the main authenticated origin
- no CSP, or a CSP full of
unsafe-inlineand broad wildcards
If your embed page personalizes scheduling based on URL params, treat it like any other attacker-controlled input surface. Because that’s exactly what it is.
Calendly didn’t create the XSS here. The integration did. That’s the lesson worth keeping.