CSP for Calendly Booking Widget
Table of Contents
Calendly is one of those embeds that looks simple until CSP gets involved. You paste their widget script, add a button or inline container, and suddenly the browser starts screaming about blocked frames, styles, and network requests.
I’ve had to wire this up a few times, and the tricky part is not “make it work.” The tricky part is making it work without blowing a giant hole in your policy with default-src * or a lazy script-src 'unsafe-inline' https:.
Here’s how I’d approach a CSP for a Calendly booking widget on a real site.
What Calendly needs
A typical Calendly embed uses:
- a remote JavaScript file from Calendly
- styles from Calendly
- an iframe for the booking UI
- network requests back to Calendly APIs/assets
A common embed looks like this:
<link
href="https://assets.calendly.com/assets/external/widget.css"
rel="stylesheet"
/>
<script
src="https://assets.calendly.com/assets/external/widget.js"
async
></script>
<a href="#" id="book-demo">Book a demo</a>
<script nonce="{{ .CSPNonce }}">
document.getElementById("book-demo").addEventListener("click", function (e) {
e.preventDefault();
Calendly.initPopupWidget({
url: "https://calendly.com/your-team/demo"
});
});
</script>
Or the inline widget version:
<div
class="calendly-inline-widget"
data-url="https://calendly.com/your-team/demo"
style="min-width:320px;height:700px;"
></div>
That means your CSP usually needs to allow:
script-srcfor Calendly script hoststyle-srcfor Calendly CSSframe-srcfor the embedded booking frameconnect-srcfor Calendly requests- maybe
img-srcif Calendly loads remote images
Start from a sane baseline
Don’t start with a giant permissive policy. Start tight.
A decent baseline looks like this:
Content-Security-Policy:
default-src 'self';
base-uri 'self';
object-src 'none';
frame-ancestors 'none';
form-action 'self';
img-src 'self' data: https:;
font-src 'self';
script-src 'self' 'nonce-{{RANDOM_NONCE}}';
style-src 'self' 'nonce-{{RANDOM_NONCE}}';
connect-src 'self';
frame-src 'self';
That won’t work with Calendly yet, but it gives you a clean foundation.
If your site already has a production CSP, keep it and add only what Calendly actually needs.
Real-world context: existing CSPs are already messy
Most production policies are not clean-room examples. They already include analytics, consent tooling, and app endpoints.
For example, this real header from headertest.com already has a lot going on:
content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-MDI5MGE0NmYtNTlhYS00MzEzLWE2NjctNWRlOTcyYWYzYTMw' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
frame-src 'self' https://consentcdn.cookiebot.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'
That’s a realistic starting point: lots of third parties, nonce-based scripts, and some legacy looseness like style-src 'unsafe-inline'.
If you’re adding Calendly to a policy like that, don’t rewrite everything. Extend it carefully.
Minimal CSP additions for Calendly
In practice, I’d expect to add Calendly hosts to these directives:
script-src https://assets.calendly.com
style-src https://assets.calendly.com
frame-src https://calendly.com
connect-src https://calendly.com https://assets.calendly.com
img-src https://assets.calendly.com https://calendly.com
A full example:
Content-Security-Policy:
default-src 'self';
base-uri 'self';
object-src 'none';
frame-ancestors 'none';
form-action 'self';
script-src 'self' 'nonce-{{RANDOM_NONCE}}' https://assets.calendly.com;
style-src 'self' 'nonce-{{RANDOM_NONCE}}' https://assets.calendly.com;
img-src 'self' data: https: https://assets.calendly.com https://calendly.com;
font-src 'self';
connect-src 'self' https://calendly.com https://assets.calendly.com;
frame-src 'self' https://calendly.com;
That’s the version I’d test first.
If you want ready-made policy patterns to compare against, https://csp-examples.com is useful for structure and syntax checks.
If your site uses a nonce, keep using it
A lot of developers accidentally weaken their whole policy when they add a widget. They already have a nonce-based setup, then they toss in 'unsafe-inline' because one inline Calendly.initPopupWidget(...) block got blocked.
Don’t do that.
Use your existing nonce on the inline script:
<script nonce="{{ .CSPNonce }}">
Calendly.initInlineWidget({
url: "https://calendly.com/your-team/demo",
parentElement: document.getElementById("calendly-widget")
});
</script>
And in the header:
script-src 'self' 'nonce-{{RANDOM_NONCE}}' https://assets.calendly.com;
If your app framework supports per-request nonces, that’s the cleanest route.
Example: extending the headertest-style CSP
Let’s say your current header looks similar to the headertest.com one. Here’s how I’d extend it for Calendly without changing unrelated directives:
Content-Security-Policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-{{RANDOM_NONCE}}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com https://assets.calendly.com;
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com https://assets.calendly.com;
img-src 'self' data: https: https://assets.calendly.com https://calendly.com;
font-src 'self';
connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com https://calendly.com https://assets.calendly.com;
frame-src 'self' https://consentcdn.cookiebot.com https://calendly.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
That’s the pragmatic version.
A couple of opinions here:
- I would not add Calendly to
default-src. Keep third-party hosts scoped to the directives that need them. - If you still have
style-src 'unsafe-inline', Calendly isn’t the reason to keep it forever. Remove it later if your app can tolerate nonce- or hash-based styles. - Don’t add wildcards like
https://*.calendly.comunless you’ve confirmed they’re actually needed.
Nginx example
If you’re setting this at the reverse proxy layer:
add_header Content-Security-Policy "
default-src 'self';
base-uri 'self';
object-src 'none';
frame-ancestors 'none';
form-action 'self';
script-src 'self' 'nonce-$request_id' https://assets.calendly.com;
style-src 'self' 'unsafe-inline' https://assets.calendly.com;
img-src 'self' data: https: https://assets.calendly.com https://calendly.com;
font-src 'self';
connect-src 'self' https://calendly.com https://assets.calendly.com;
frame-src 'self' https://calendly.com;
" always;
One caveat: $request_id is not automatically a valid CSP nonce strategy for every app stack. You need a nonce that is actually injected into matching script tags for that response. I’ve seen teams set a header nonce at the proxy and forget the HTML layer entirely. Then they wonder why every inline script still fails.
Express example with a real nonce
This is the pattern I prefer in Node apps:
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString("base64");
res.locals.cspNonce = nonce;
res.setHeader(
"Content-Security-Policy",
[
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
`script-src 'self' 'nonce-${nonce}' https://assets.calendly.com`,
`style-src 'self' 'unsafe-inline' https://assets.calendly.com`,
"img-src 'self' data: https: https://assets.calendly.com https://calendly.com",
"font-src 'self'",
"connect-src 'self' https://calendly.com https://assets.calendly.com",
"frame-src 'self' https://calendly.com"
].join("; ")
);
next();
});
app.get("/", (req, res) => {
res.send(`
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="https://assets.calendly.com/assets/external/widget.css">
</head>
<body>
<a href="#" id="book-demo">Book a demo</a>
<script src="https://assets.calendly.com/assets/external/widget.js" async></script>
<script nonce="${res.locals.cspNonce}">
document.getElementById("book-demo").addEventListener("click", function (e) {
e.preventDefault();
Calendly.initPopupWidget({
url: "https://calendly.com/your-team/demo"
});
});
</script>
</body>
</html>
`);
});
app.listen(3000);
Common CSP errors with Calendly
These are the ones I usually see.
1. Refused to load the script
You forgot:
script-src https://assets.calendly.com
2. Refused to frame
You forgot:
frame-src https://calendly.com
3. Refused to connect
You forgot:
connect-src https://calendly.com
Sometimes you’ll need to inspect the exact blocked URL in DevTools and add the specific host rather than guessing.
4. Inline script blocked
You used this:
<script>
Calendly.initPopupWidget(...);
</script>
But your policy only allows nonced or external scripts.
Fix it with a nonce or move the code into a bundled local script.
Use Report-Only first
If this is a live site, don’t ship CSP changes blind. Start with Content-Security-Policy-Report-Only and collect violations while testing the booking flow.
That’s especially useful when your site already has a crowded policy like the headertest.com example. Third-party widgets tend to trigger side effects you won’t catch by just loading the page once.
Final practical advice
My default approach for Calendly is:
- Keep the existing restrictive CSP structure.
- Add only
assets.calendly.comandcalendly.comwhere needed. - Use a nonce for any inline initialization code.
- Avoid wildcards unless the browser console proves you need them.
- Test popup widget and inline widget separately, because they don’t always behave the same.
If your policy starts turning into a graveyard of random third-party domains, stop and clean it up. CSP works best when every allowed source has a reason to exist. Calendly can fit into a tight policy just fine, but only if you resist the usual “just allow everything from https:” shortcut.
For the official reference on directive behavior and browser handling, check the MDN and browser CSP documentation you already rely on, plus your framework’s header configuration docs.