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-srcfor the embedded page - Use
script-srconly if your page loads Zeplin JavaScript directly - Use
img-src,font-src,style-src, orconnect-srconly 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-srccontrols what your page can embedframe-ancestorscontrols 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.
My recommended default
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.