FullStory is one of those tools that security teams side-eye and product teams love. Session replay, event capture, rage clicks, funnels — great for debugging real user behavior, but it also means you’re injecting a third-party script that phones home constantly.
That makes Content Security Policy a real concern, not a checkbox.
If you add FullStory without thinking through CSP, you usually get one of two outcomes:
- FullStory silently breaks and nobody notices until analytics goes dark.
- Someone opens the policy way too far with
https:and'unsafe-inline', and now your CSP is mostly decorative.
I’d rather avoid both.
What FullStory needs from CSP
FullStory’s browser snippet typically requires a few things:
- loading its JavaScript from FullStory-controlled origins
- sending telemetry and replay data back over HTTPS
- sometimes opening WebSocket connections
- loading images or other assets used by the SDK
The exact hostnames can vary by account region and FullStory’s current infrastructure, so always validate against their latest docs and your own network traffic. Don’t cargo-cult a CSP from a random blog post and assume it’s right six months later.
At a minimum, you’ll usually be dealing with:
script-srcconnect-src- sometimes
img-src - sometimes
worker-srcdepending on how the vendor SDK evolves
Start with a sane baseline
Before adding FullStory, I like to start from a reasonably locked-down policy. For example, here’s a real CSP header from headertest.com, which is a good example of a modern production policy that’s doing more than the bare minimum:
content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-MmQ4YTczNDQtYjQxOC00NzY3LWFhNDgtM2Q0MGNjMDkyOGNm' '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 not a FullStory policy, but it shows the shape I want:
- explicit directives
- tight
object-src - locked
frame-ancestors - a modern
script-srcusing nonces andstrict-dynamic connect-srclisting only what the app actually talks to
That’s the mindset to keep when adding analytics.
A practical CSP for FullStory
Here’s a starting point for a site that loads FullStory directly. You must verify the exact domains FullStory uses for your deployment.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://edge.fullstory.com;
connect-src 'self' https://rs.fullstory.com https://edge.fullstory.com wss://rs.fullstory.com;
img-src 'self' data: https://*.fullstory.com;
style-src 'self' 'unsafe-inline';
font-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
report-to csp-endpoint;
report-uri https://csp-report.example.com/report;
A few opinions here:
- I prefer putting FullStory hosts in the specific directives they need instead of bloating
default-src. - If you can use a nonce, use one.
- If your app doesn’t need inline styles, remove
'unsafe-inline'fromstyle-src. A lot of teams leave that in forever because it’s convenient. - If FullStory doesn’t need
img-srcin your setup, don’t add it.
If you install FullStory via Google Tag Manager
This is where things usually get messy.
When FullStory is injected by GTM, your CSP has to allow GTM first, then whatever GTM loads. If your policy uses nonces with 'strict-dynamic', that can help a lot, but only if your initial GTM script tag is nonce-protected.
Example:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://www.googletagmanager.com https://edge.fullstory.com;
connect-src 'self' https://www.googletagmanager.com https://rs.fullstory.com https://edge.fullstory.com wss://rs.fullstory.com;
img-src 'self' data: https://*.fullstory.com https://www.googletagmanager.com;
style-src 'self' 'unsafe-inline';
frame-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
And the script tag:
<script nonce="{{ .CSPNonce }}">
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXXX');
</script>
If your GTM container injects custom HTML tags, your CSP story gets worse fast. I’ve cleaned up enough GTM-heavy setups to say this plainly: custom HTML in GTM is where security discipline goes to die. If you can load FullStory directly in application code, that’s usually cleaner.
Express example with a nonce
If you’re serving a Node app, here’s a basic Express setup using Helmet.
import express from "express";
import crypto from "crypto";
import helmet from "helmet";
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'",
`'nonce-${res.locals.cspNonce}'`,
"'strict-dynamic'",
"https://edge.fullstory.com"
],
connectSrc: [
"'self'",
"https://rs.fullstory.com",
"https://edge.fullstory.com",
"wss://rs.fullstory.com"
],
imgSrc: [
"'self'",
"data:",
"https://*.fullstory.com"
],
styleSrc: ["'self'", "'unsafe-inline'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"],
reportUri: ["https://csp-report.example.com/report"]
}
}
})(req, res, next);
});
app.get("/", (req, res) => {
res.send(`
<!doctype html>
<html>
<head>
<script nonce="${res.locals.cspNonce}">
window['_fs_host'] = 'fullstory.com';
window['_fs_script'] = 'edge.fullstory.com/s/fs.js';
window['_fs_org'] = 'YOUR_ORG_ID';
window['_fs_namespace'] = 'FS';
(function(m,n,e,t,l,o,g,y){
if (e in m) return;
g = m[e] = function(a,b,s){
g.q ? g.q.push([a,b,s]) : g._api(a,b,s);
};
g.q = [];
o = n.createElement(t);
o.async = 1;
o.crossOrigin = 'anonymous';
o.src = 'https://' + _fs_script;
y = n.getElementsByTagName(t)[0];
y.parentNode.insertBefore(o, y);
})(window, document, window['_fs_namespace'], 'script');
</script>
</head>
<body>
<h1>FullStory CSP test</h1>
</body>
</html>
`);
});
app.listen(3000);
A couple of practical notes:
- Don’t hardcode a static nonce.
- Don’t use both a solid nonce strategy and then throw in
'unsafe-inline'for scripts. That defeats the point. - Watch the browser console and network tab after rollout. FullStory failures usually show up in
connect-srcfirst.
Nginx example
If you terminate at Nginx and your app already renders a nonce, you can set the header there.
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' 'nonce-$csp_nonce' 'strict-dynamic' https://edge.fullstory.com;
connect-src 'self' https://rs.fullstory.com https://edge.fullstory.com wss://rs.fullstory.com;
img-src 'self' data: https://*.fullstory.com;
style-src 'self' 'unsafe-inline';
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
" always;
The hard part is not writing the header. The hard part is making sure $csp_nonce is generated per response and matches the nonce in your script tags.
Roll out in Report-Only first
For analytics vendors, I nearly always start with Content-Security-Policy-Report-Only for a few days.
Example:
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://edge.fullstory.com;
connect-src 'self' https://rs.fullstory.com https://edge.fullstory.com wss://rs.fullstory.com;
report-uri https://csp-report.example.com/report;
Then:
- browse critical app flows
- log in, log out, hit dashboards, forms, SPA routes
- test with consent banners if FullStory is gated by consent
- compare blocked URLs against actual vendor docs
If you need ready-made policy patterns for common setups, csp-examples.com is useful as a starting point. I’d still treat every example as a draft, not gospel.
Common mistakes
1. Allowing all HTTPS scripts
script-src 'self' https:
That’s lazy and dangerous. It turns CSP into “please load malware over TLS.”
2. Forgetting WebSockets
A lot of analytics and replay tools use wss:// endpoints. If you only allow https://, you may still break data collection.
connect-src 'self' https://rs.fullstory.com wss://rs.fullstory.com;
3. Hiding everything under default-src
Technically valid, operationally annoying. Be explicit. When something breaks, directive-specific policies are much easier to debug.
4. Not revisiting the policy
Vendors change infrastructure. Marketing adds GTM tags. Product adds another analytics SDK. Six months later your CSP is stale and nobody knows why.
A tighter variant if FullStory is your only third-party script
If the page is otherwise clean, you can keep things pretty strict:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://edge.fullstory.com;
connect-src 'self' https://rs.fullstory.com wss://rs.fullstory.com;
img-src 'self' data:;
style-src 'self';
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
That’s much closer to what I want to see on a modern app: a narrow policy with obvious intent.
Final checklist
Before calling it done, I’d verify all of this:
- FullStory script loads successfully
- replay/session data is sent without CSP violations
- WebSocket connections work if used
- consent flow still blocks or enables FullStory correctly
- no extra wildcard domains were added “just to make it work”
object-src 'none'andframe-ancestors 'none'are still present- policy is tested in both enforced and report-only modes
FullStory can coexist with a strong CSP just fine. The trick is resisting the usual temptation to loosen the whole policy for one vendor. Give it exactly the sources it needs, keep the rest tight, and check the browser like you don’t trust anyone — because for third-party analytics, you probably shouldn’t.