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-src for Calendly script host
  • style-src for Calendly CSS
  • frame-src for the embedded booking frame
  • connect-src for Calendly requests
  • maybe img-src if 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.com unless 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:

  1. Keep the existing restrictive CSP structure.
  2. Add only assets.calendly.com and calendly.com where needed.
  3. Use a nonce for any inline initialization code.
  4. Avoid wildcards unless the browser console proves you need them.
  5. 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.