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:

  1. FullStory silently breaks and nobody notices until analytics goes dark.
  2. 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-src
  • connect-src
  • sometimes img-src
  • sometimes worker-src depending 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-src using nonces and strict-dynamic
  • connect-src listing 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' from style-src. A lot of teams leave that in forever because it’s convenient.
  • If FullStory doesn’t need img-src in 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-src first.

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' and frame-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.