If you embed Fillout on a site with a real Content Security Policy, you’ll usually hit one of two problems fast:
- the form iframe gets blocked
- the form loads, but some supporting requests fail silently
This is normal. CSP is doing its job.
The trick is knowing which directives matter for Fillout and which ones don’t. A lot of developers throw https: into half the policy and call it done. That works, but it defeats the point of having CSP in the first place.
Here’s how I’d approach CSP for Fillout in production.
How Fillout is usually embedded
Most teams use Fillout in one of these ways:
- embedded as an
iframe - opened in a popup or modal driven by a script
- linked as a separate hosted form page
- served from a custom domain
Each integration changes which CSP directives you need.
A basic iframe embed looks like this:
<iframe
src="https://form.fillout.com/t/abcd1234"
width="100%"
height="600"
frameborder="0"
title="Contact form">
</iframe>
If your CSP blocks frames from Fillout, this won’t render.
The minimum CSP you usually need
For a plain iframe embed, the key directive is frame-src.
Content-Security-Policy:
default-src 'self';
frame-src 'self' https://form.fillout.com;
object-src 'none';
base-uri 'self';
frame-ancestors 'self';
That allows your page to embed Fillout from form.fillout.com.
If you also load any Fillout JavaScript SDK or embed helper, you’ll need script-src too. Don’t add that unless you actually use it.
Which CSP directives matter for Fillout
Here’s the practical breakdown.
frame-src
This controls whether your page can embed Fillout in an iframe.
frame-src 'self' https://form.fillout.com;
If you use a Fillout custom domain, use that exact domain instead:
frame-src 'self' https://forms.example.com;
script-src
Only needed if you load Fillout JavaScript directly on your page.
For example:
<script src="https://server.fillout.com/embed/v1/"></script>
Then your CSP needs to allow that script origin:
script-src 'self' https://server.fillout.com;
If your site already uses nonces, keep using them. I’d strongly prefer a nonce-based policy over sprinkling hostnames everywhere.
Example:
script-src 'self' 'nonce-rAnd0m123' https://server.fillout.com;
And in HTML:
<script nonce="rAnd0m123" src="https://server.fillout.com/embed/v1/"></script>
connect-src
This one is easy to miss.
If your own page-side JavaScript talks to Fillout APIs, or Fillout’s embed script makes XHR/fetch/WebSocket calls from your page context, those requests are governed by connect-src.
A conservative example:
connect-src 'self' https://*.fillout.com;
I’d start narrow if you know the exact hostnames. If you don’t, use report-only mode first and inspect violations.
img-src, style-src, font-src
For a simple iframe embed, the iframe’s internal resources are governed by the iframe page’s CSP, not yours. That means your top-level CSP usually does not need to allow Fillout images, fonts, or styles just because the iframe uses them.
You only need to touch these directives if you load Fillout assets directly into your own document.
That distinction saves a lot of pointless policy bloat.
A realistic starting policy
If your site embeds Fillout in an iframe and nothing else, I’d start here:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
frame-src 'self' https://form.fillout.com;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'self';
That’s clean and strict.
If you use Fillout’s embed script
Some teams prefer a script-powered embed instead of writing the iframe manually.
Example HTML:
<div data-fillout-id="abcd1234" data-fillout-embed-type="standard"></div>
<script src="https://server.fillout.com/embed/v1/"></script>
Then your policy probably needs at least:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://server.fillout.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://*.fillout.com;
frame-src 'self' https://form.fillout.com;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'self';
Why the 'unsafe-inline' in style-src? Because a lot of embed widgets inject inline styles. I don’t love it, but sometimes it’s the practical answer. If you can verify Fillout’s embed doesn’t need inline styles in your setup, remove it.
Custom domains change everything
If your Fillout form is served from a custom domain like forms.example.com, stop allowing generic Fillout hosts in frame-src unless you still need them.
Use your exact host:
frame-src 'self' https://forms.example.com;
If scripts are also served from that custom domain:
script-src 'self' https://forms.example.com;
connect-src 'self' https://forms.example.com;
This is one of the easiest wins in CSP: prefer exact origins over broad wildcards.
Learning from a real production CSP
Here’s a real CSP header from headertest.com:
content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-MDVmZGM2ZWEtNzczNi00ODU2LTliODUtMWYwNjY3MTY5YTk1' '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'
A few things I like here:
object-src 'none'base-uri 'self'- nonce-based
script-src frame-ancestors 'none'for clickjacking protection
A few things I’d think about before copying this pattern for Fillout:
default-srcincludes third-party origins, which can blur policy intentimg-src https:is broadstyle-src 'unsafe-inline'is a compromise, not a goal
For Fillout, I’d keep the same disciplined structure but add only the exact Fillout origins I need.
For example:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{{ .CSPNonce }}' https://server.fillout.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://*.fillout.com;
frame-src 'self' https://form.fillout.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
That’s much easier to reason about than dumping Fillout into default-src.
Express example
If you’re setting CSP in Node/Express with Helmet:
import express from "express";
import helmet from "helmet";
import crypto from "crypto";
const app = express();
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString("base64");
next();
});
app.use((req, res, next) => {
helmet({
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
(req, res) => `'nonce-${res.locals.cspNonce}'`,
"https://server.fillout.com",
],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
fontSrc: ["'self'"],
connectSrc: ["'self'", "https://*.fillout.com"],
frameSrc: ["'self'", "https://form.fillout.com"],
baseUri: ["'self'"],
formAction: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
},
},
})(req, res, next);
});
app.get("/", (req, res) => {
res.send(`
<!doctype html>
<html>
<body>
<div data-fillout-id="abcd1234" data-fillout-embed-type="standard"></div>
<script nonce="${res.locals.cspNonce}" src="https://server.fillout.com/embed/v1/"></script>
</body>
</html>
`);
});
app.listen(3000);
Nginx example
If you just need an iframe embed:
add_header Content-Security-Policy "
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
frame-src 'self' https://form.fillout.com;
base-uri 'self';
form-action 'self';
object-src 'none';
frame-ancestors 'self';
" always;
Debugging blocked Fillout embeds
The browser console will usually tell you exactly which directive is failing.
Common messages look like:
- refused to frame because it violates
frame-src - refused to load the script because it violates
script-src - refused to connect because it violates
connect-src
My workflow is simple:
- start with
Content-Security-Policy-Report-Only - load the page and interact with the form
- collect violations
- add the minimum required origins
- switch to enforcing mode
Example report-only header:
Content-Security-Policy-Report-Only:
default-src 'self';
frame-src 'self' https://form.fillout.com;
report-to csp-endpoint;
If you want ready-to-use policy patterns, csp-examples.com is useful for comparing approaches before you tighten them for your own stack.
A few hard-earned rules
I stick to these when embedding third-party forms:
- never rely on
default-srcfor third-party embeds - prefer
frame-srcover broad allowlists - keep Fillout origins out of directives that don’t need them
- use exact hosts when you have a custom domain
- treat
'unsafe-inline'as a temporary compromise - use report-only mode before shipping policy changes
If all you need is a Fillout iframe, your CSP can stay pretty tight. That’s the nice part about iframe-based integrations: most of the complexity stays isolated inside the frame.
If you start loading Fillout scripts into your own page, that’s when your policy gets wider and the tradeoffs get real.