I’ve had to fix this exact problem more than once: design wants an InVision prototype embedded on a marketing page or internal docs site, everything works locally, then production CSP blocks it hard.

The usual bad fix is “just allow everything from https: in frame-src and script-src.” That gets the demo unblocked and quietly wrecks the value of CSP.

A better fix is boring and precise: figure out exactly what the embed needs, add the minimum policy changes, and verify you didn’t loosen unrelated directives.

Here’s a real-world style case study for a developer audience building on csp-examples.

The setup

We had a page with an InVision prototype embed like this:

<iframe
  src="https://invis.io/AB12CD34EFG"
  width="100%"
  height="800"
  frameborder="0"
  allowfullscreen
></iframe>

The site already had a reasonably strict CSP. Not perfect, but sane. The team had copied a pattern close to what you’d see from modern production sites. For reference, a real CSP header from HeaderTest looks like this:

content-security-policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-MjEwNTc3MmYtYzAyZC00M2Y3LWEyYjEtOTI2ODk1MjhkNTU3' '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 good baseline mindset: explicit sources, object-src 'none', base-uri 'self', no random wildcarding. The problem is obvious though: frame-src only allows self and Cookiebot’s consent frame.

So when the InVision embed shipped, the browser blocked it.

What broke

The console error looked like this:

Refused to frame 'https://invis.io/' because it violates the following
Content Security Policy directive:
"frame-src 'self' https://consentcdn.cookiebot.com".

Pretty standard. The page itself was fine. Scripts were fine. The iframe was not.

This is the first place teams often overreact and start changing default-src, which is usually the wrong move.

The bad fix

One developer proposed this:

Content-Security-Policy:
  default-src 'self' https:;
  script-src 'self' 'unsafe-inline' 'unsafe-eval' https:;
  frame-src *;

Technically, yes, the embed worked after that. So would a lot of things you probably don’t want.

Problems with this “fix”:

  • frame-src * allows framing from basically anywhere
  • script-src https: opens the door to any HTTPS-hosted script
  • unsafe-inline and unsafe-eval are a huge regression
  • broadening default-src can have side effects across multiple fetch types

This is how CSP turns from a meaningful control into security theater.

The actual fix

We stepped back and answered the only question that matters:

What resource type is blocked, and which host is needed?

For a plain InVision embed, that’s usually just the frame source. So the minimal change was to extend frame-src.

Before

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-rAnd0m123';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self';
  frame-src 'self';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

After

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-rAnd0m123';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self';
  frame-src 'self' https://invis.io https://*.invisionapp.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

That was enough for our case.

A couple of things I did intentionally:

  • I changed only frame-src
  • I allowed both https://invis.io and https://*.invisionapp.com because InVision has used multiple host patterns depending on embed style and asset flow
  • I did not touch script-src because the host page wasn’t loading InVision scripts directly

That last point matters. Don’t cargo-cult source expressions into every directive.

If you want a deeper refresher on how directives map to resource types, csp-guide.com is a good reference.

HTML example: before and after

The page markup barely changed.

Before

<section class="prototype">
  <h2>Checkout flow prototype</h2>
  <iframe
    src="https://invis.io/AB12CD34EFG"
    width="100%"
    height="800"
    loading="lazy"
    referrerpolicy="strict-origin-when-cross-origin"
  ></iframe>
</section>

After

<section class="prototype">
  <h2>Checkout flow prototype</h2>
  <iframe
    src="https://invis.io/AB12CD34EFG"
    width="100%"
    height="800"
    loading="lazy"
    referrerpolicy="strict-origin-when-cross-origin"
    sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
    allowfullscreen
  ></iframe>
</section>

The CSP change fixed the blocking. The HTML tweak improved containment.

A quick note on sandbox: it’s useful, but not free. If you make it too restrictive, the embedded prototype can break in weird ways. I usually start with the minimum permissions needed and test the actual interaction paths.

The second issue nobody expected

After fixing frame-src, the embed loaded, but some teams still saw broken interactions or blank areas inside the frame and assumed CSP was still wrong.

Usually, at that point, one of two things is happening:

  1. The embedded app itself is making requests inside its own browsing context, governed by its CSP, not yours.
  2. Your iframe attributes are too restrictive, especially sandbox.

This is a common misunderstanding. Your page’s CSP controls what your document can load. It does not rewrite InVision’s own response headers.

So if your console shows the top-level page blocking frame-src, that’s your CSP. If the frame loads and then the content inside fails, inspect the frame separately.

Express example

Here’s how I’d wire this in a Node/Express app using Helmet.

Before

import express from "express";
import helmet from "helmet";

const app = express();

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", (_req, res) => `'nonce-${res.locals.nonce}'`],
        styleSrc: ["'self'", "'unsafe-inline'"],
        imgSrc: ["'self'", "data:", "https:"],
        fontSrc: ["'self'"],
        connectSrc: ["'self'"],
        frameSrc: ["'self'"],
        frameAncestors: ["'none'"],
        baseUri: ["'self'"],
        formAction: ["'self'"],
        objectSrc: ["'none'"],
      },
    },
  })
);

After

import express from "express";
import helmet from "helmet";
import crypto from "node:crypto";

const app = express();

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString("base64");
  next();
});

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", (_req, res) => `'nonce-${res.locals.nonce}'`],
        styleSrc: ["'self'", "'unsafe-inline'"],
        imgSrc: ["'self'", "data:", "https:"],
        fontSrc: ["'self'"],
        connectSrc: ["'self'"],
        frameSrc: [
          "'self'",
          "https://invis.io",
          "https://*.invisionapp.com",
        ],
        frameAncestors: ["'none'"],
        baseUri: ["'self'"],
        formAction: ["'self'"],
        objectSrc: ["'none'"],
      },
    },
  })
);

That’s the whole change. No drama.

How we verified it safely

I like doing this in two passes.

1. Report-only first

Before enforcing, ship a Content-Security-Policy-Report-Only header:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' 'nonce-rAnd0m123';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self';
  frame-src 'self' https://invis.io https://*.invisionapp.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';
  report-to default-endpoint;

That lets you confirm whether the new sources are sufficient without breaking production traffic.

2. Test the actual embed flow

Not just page load. Click through the prototype. Open overlays. Try fullscreen if you allow it. Some embedded tools behave differently after user interaction.

What I would not add

I would not add these unless you have hard evidence you need them:

script-src https://*.invisionapp.com
connect-src https://*.invisionapp.com
img-src https://*.invisionapp.com

Why? Because the top-level page isn’t necessarily fetching those resources. The iframe is. Adding them “just in case” makes the policy noisier and weaker for no gain.

CSP works best when you treat it like a dependency manifest, not a vibes-based allowlist.

Final policy recommendation

For a site that only needs to embed InVision and nothing more from it, this is the shape I’d keep:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-<dynamic-nonce>';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self';
  frame-src 'self' https://invis.io https://*.invisionapp.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

Simple, narrow, maintainable.

That’s really the lesson from this case: when an embed breaks under CSP, don’t loosen the whole policy. Find the blocked resource type, add the exact host to the exact directive, and stop there. That’s how you keep external embeds working without turning CSP into decorative JSON for your headers.