If you embed Zeplin content and your CSP is even slightly too strict, the embed breaks in annoying ways: blank iframe, blocked scripts, missing fonts, or silent failures that only show up in DevTools.

I’ve had to debug enough third-party embeds to know the pattern: start narrow, inspect what gets blocked, then add only what the embed actually needs. Don’t cargo-cult a giant allowlist.

This guide is a practical reference for getting Zeplin embeds working with CSP, with copy-paste examples you can use as a starting point.

The short version

If you embed Zeplin with an <iframe>, the directive that usually matters most is:

frame-src https://app.zeplin.io;

If your page itself doesn’t load Zeplin scripts directly and only hosts the iframe, that may be enough.

A minimal policy often looks like this:

Content-Security-Policy: default-src 'self'; frame-src https://app.zeplin.io; object-src 'none'; base-uri 'self';

That’s the cleanest setup. Keep Zeplin isolated in an iframe and avoid widening script-src on your own page unless you truly need to.

Basic Zeplin iframe embed

Typical HTML:

<iframe
  src="https://app.zeplin.io/project/PROJECT_ID/screen/SCREEN_ID"
  width="100%"
  height="800"
  loading="lazy"
  referrerpolicy="strict-origin-when-cross-origin"
  allowfullscreen>
</iframe>

For that, start with this CSP:

Content-Security-Policy: default-src 'self'; frame-src 'self' https://app.zeplin.io; object-src 'none'; base-uri 'self';

Why include 'self' in frame-src? Because plenty of apps already use same-origin iframes for internal widgets, and removing them later causes weird regressions.

If Zeplin uses more than one origin

Third-party products sometimes serve embeds, assets, or APIs from more than one hostname. Don’t assume a single domain forever.

A safer production starting point is:

Content-Security-Policy:
  default-src 'self';
  frame-src 'self' https://app.zeplin.io https://*.zeplin.io;
  object-src 'none';
  base-uri 'self';

I would not begin with https: as a blanket fallback. That’s lazy and defeats the point of CSP.

The most common mistake: changing the wrong directive

People often add Zeplin to connect-src or img-src first, because the browser console is noisy and CSP directives blur together after a while.

For an iframe embed:

  • Use frame-src for the embedded page
  • Use script-src only if your page loads Zeplin JavaScript directly
  • Use img-src, font-src, style-src, or connect-src only if blocked resources are being requested by your page context

If Zeplin is inside a cross-origin iframe, its own CSP and resource loading mostly happen inside that iframe context, not yours.

Copy-paste policies

1. Minimal CSP for a page that only embeds Zeplin

Content-Security-Policy: default-src 'self'; frame-src 'self' https://app.zeplin.io; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self';

Good default if the page is otherwise simple.

2. Slightly more tolerant policy for Zeplin subdomains

Content-Security-Policy: default-src 'self'; frame-src 'self' https://app.zeplin.io https://*.zeplin.io; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self';

Use this if you see blocked iframe navigations or supporting embed flows from another Zeplin subdomain.

3. Existing app policy with Zeplin added

Say you already have a CSP similar to this real-world header shape:

content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-NmRjMmRmNWMtMDI3NS00ZDJiLTgzMmYtN2FmNTYxOGU0YzZi' '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'

To allow Zeplin embeds, the smallest change is usually just frame-src:

Content-Security-Policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-NmRjMmRmNWMtMDI3NS00ZDJiLTgzMmYtN2FmNTYxOGU0YzZi' '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 https://app.zeplin.io https://*.zeplin.io;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

That’s the change I’d try first.

Nginx example

add_header Content-Security-Policy "default-src 'self'; frame-src 'self' https://app.zeplin.io https://*.zeplin.io; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'" always;

If you already set CSP elsewhere, don’t accidentally send two conflicting CSP headers. Browsers will enforce both, and that gets messy fast.

Apache example

Header always set Content-Security-Policy "default-src 'self'; frame-src 'self' https://app.zeplin.io https://*.zeplin.io; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'"

Express / Node example

app.use((req, res, next) => {
  res.setHeader(
    "Content-Security-Policy",
    "default-src 'self'; " +
      "frame-src 'self' https://app.zeplin.io https://*.zeplin.io; " +
      "object-src 'none'; " +
      "base-uri 'self'; " +
      "form-action 'self'; " +
      "frame-ancestors 'self'"
  );
  next();
});

If you use Helmet:

import helmet from "helmet";

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        frameSrc: ["'self'", "https://app.zeplin.io", "https://*.zeplin.io"],
        objectSrc: ["'none'"],
        baseUri: ["'self'"],
        formAction: ["'self'"],
        frameAncestors: ["'self'"]
      }
    }
  })
);

When you might need more than frame-src

If you are not using a plain iframe and instead load some Zeplin-hosted script, SDK, or asset directly in your page, you may need entries like these:

script-src 'self' https://app.zeplin.io https://*.zeplin.io;
style-src 'self' 'unsafe-inline' https://app.zeplin.io https://*.zeplin.io;
img-src 'self' data: https://app.zeplin.io https://*.zeplin.io;
connect-src 'self' https://app.zeplin.io https://*.zeplin.io;
font-src 'self' https://app.zeplin.io https://*.zeplin.io;

But I would only add these after confirming actual violations in the browser console or CSP reports.

frame-src vs frame-ancestors

These are easy to mix up.

  • frame-src controls what your page can embed
  • frame-ancestors controls who can embed your page

So if your app embeds Zeplin, you care about frame-src.

If your page itself must not be embedded by others, use:

frame-ancestors 'none';

That’s exactly what the sample real-world header does, and I like that setting for most apps.

If you need internal embedding only:

frame-ancestors 'self';

If you want deeper directive explanations, the CSP reference at https://csp-guide.com is useful, and the canonical spec details live in official documentation like MDN and the W3C spec.

Debugging blocked Zeplin embeds

Open DevTools and look for messages like:

Refused to frame 'https://app.zeplin.io/' because it violates the following Content Security Policy directive: "frame-src 'self'".

That means your policy is missing the Zeplin origin in frame-src.

Another common one:

Refused to connect to 'https://something.zeplin.io/...' because it violates the following Content Security Policy directive: "connect-src 'self'".

That only matters if the request happens in your page context. Don’t blindly add connect-src entries because of noise from unrelated scripts or browser extensions.

Use Report-Only first

For production apps, I usually test CSP changes with Content-Security-Policy-Report-Only before enforcing them.

Example:

Content-Security-Policy-Report-Only: default-src 'self'; frame-src 'self' https://app.zeplin.io https://*.zeplin.io; object-src 'none'; base-uri 'self';

That lets you verify whether Zeplin needs extra origins without breaking the page for real users.

For most teams embedding Zeplin, I’d ship this first:

Content-Security-Policy:
  default-src 'self';
  frame-src 'self' https://app.zeplin.io https://*.zeplin.io;
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'self';

Then tighten or expand only based on actual violations.

That’s the whole game with CSP: be strict on purpose, not strict by accident.