CSP for Multi-Tenant SaaS: A Real-World Case Study

Table of Contents

Multi-tenant SaaS apps make CSP harder than most docs admit.

A brochure site can get away with a tidy, static policy. A SaaS product with tenant branding, embeddable widgets, analytics, support chat, feature flags, and “paste your custom tracking code here” absolutely cannot. I’ve seen teams ship a CSP that looked clean in staging and then explode the moment a large customer enabled SSO, a consent manager, and a dashboard plugin from three different vendors.

The hard part is not writing a CSP. The hard part is writing one that survives tenant-specific behavior without turning into this:

Content-Security-Policy: default-src * data: blob: 'unsafe-inline' 'unsafe-eval';

That’s not a CSP. That’s a decorative string.

The setup

Here’s a realistic SaaS scenario:

  • One app, many tenants
  • Each tenant gets:
    • custom logo and theme
    • optional custom domain
    • analytics integrations
    • support widget
    • embedded reports
  • The platform team also runs:
    • Google Tag Manager
    • Google Analytics
    • Cookie consent tooling
    • a real-time app channel over WebSocket
    • an API on a separate subdomain

That combination creates the usual pressure:

  • security wants a strict CSP
  • product wants “tenant flexibility”
  • customer success wants to paste third-party snippets
  • engineering wants one policy for everyone

Usually, one of those groups loses. Often it’s security.

The “before” state

The team I’m describing had a policy that grew by exception. Every new integration added another host. Every breakage got fixed with a wildcard. Custom scripts from tenants pushed them toward unsafe-inline. A legacy chart library demanded unsafe-eval.

This was the policy they were effectively running:

Content-Security-Policy:
  default-src 'self' https: data: blob:;
  script-src 'self' 'unsafe-inline' 'unsafe-eval' https: blob:;
  style-src 'self' 'unsafe-inline' https:;
  img-src * data: blob:;
  font-src 'self' data: https:;
  connect-src 'self' https: wss:;
  frame-src https:;
  object-src 'none';

This “worked,” but only in the sense that unplugging your smoke detector also stops the beeping.

What was wrong with it

1. Every tenant expanded the blast radius

If Tenant A needed a script from cdn.vendor-a.com, the team often added a broad allowlist entry that every tenant inherited. Tenant B, who never used that vendor, still got exposed.

In multi-tenant SaaS, global CSP exceptions are a tax on every customer.

2. Inline scripts became the escape hatch

Branded pages and marketing-driven customizations had little snippets injected into templates:

<script>
  window.tenantTheme = {
    primary: "#5b6cff",
    name: "Acme Co"
  };
</script>

One inline config block turned into five. Then someone added an inline event handler. Then CSP needed 'unsafe-inline'.

3. Custom domains complicated source control

Tenants using app.customer.com still loaded assets from the platform domain, analytics from vendors, and API calls from a central backend. The team started using wide https: allowances because maintaining explicit hosts across custom domains felt painful.

4. Third-party tags were unmanaged

Support chat, session replay, analytics, affiliate scripts — all loaded through a mix of direct embeds and tag manager rules. Nobody had a clean inventory.

That last one matters. You can’t secure what you can’t enumerate.

The turning point

The team finally changed approach after a security review found that a stored XSS in one tenant-facing configuration field could execute in the app shell because the CSP still allowed inline script and broad third-party execution.

No breach, but enough of a scare.

They moved to three principles:

  1. One strict baseline policy for the app shell
  2. Per-tenant capabilities, not per-tenant arbitrary script
  3. Nonces and strict-dynamic for first-party script loading

If you want a quick sanity check on what your current headers look like from the outside, HeaderTest is handy for validating what actually ships, not what you think ships.

The “after” state

Their revised policy looked much closer to what strong production apps use today. A good real-world reference is the CSP header currently served by headertest.com:

content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-YTA0MjVkOWItNmMxZC00Yjk1LWIxOTgtMWNlNTExNzk3Zjc3' '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 header shows the shape I like for SaaS:

  • default-src 'self'
  • explicit third-party hosts
  • nonce-based script-src
  • strict-dynamic
  • locked-down object-src, base-uri, form-action
  • explicit connect-src for APIs and WebSockets
  • explicit frame-ancestors

Not perfect — style-src 'unsafe-inline' is still a compromise — but a lot better than the usual wildcard soup.

What changed technically

1. They stopped allowing tenant-provided JavaScript

This was the biggest product decision.

Before:

<div class="custom-snippet">
  {{ tenant.custom_tracking_code | safe }}
</div>

After:

  • tenants could choose from approved integrations
  • tenants could configure IDs, not raw code
  • platform rendered vetted templates for each integration

Before:

<script>
  !function(){ /* random vendor snippet copied from docs */ }()
</script>

After:

<script nonce="{{.CSPNonce}}" src="/static/js/integrations/ga.js"></script>
<script nonce="{{.CSPNonce}}">
  window.appConfig = {
    gaMeasurementId: "{{ .Tenant.GAID }}"
  };
</script>

That one decision eliminated most of the “CSP is impossible in SaaS” complaints.

2. They used nonces everywhere server-rendered script existed

A per-request nonce was generated and attached both to the header and the allowed script tags.

Example in Express:

import crypto from "node:crypto";

app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString("base64");
  res.locals.nonce = nonce;

  res.setHeader(
    "Content-Security-Policy",
    [
      "default-src 'self'",
      `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com https://*.google-analytics.com https://*.cookiebot.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.example.com wss://realtime.example.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'"
    ].join("; ")
  );

  next();
});

Template:

<script nonce="{{nonce}}" src="/assets/app.js"></script>
<script nonce="{{nonce}}">
  window.__BOOTSTRAP__ = {{ bootstrapJson }};
</script>

3. They split the app shell from tenant content

This is where multi-tenant architecture really matters.

The app shell stayed on a strict policy. Tenant-generated rich content was isolated, either by:

  • rendering it as sanitized HTML only, or
  • placing it in a sandboxed iframe on a separate origin

Before:

<div class="tenant-html">
  {{{ tenant.customLandingPageHtml }}}
</div>

After:

<iframe
  src="https://tenant-content.example-cdn.com/t/acme/page/123"
  sandbox="allow-same-origin"
  referrerpolicy="no-referrer">
</iframe>

That separation is boring, but it works. If tenant content needs more freedom, don’t grant it inside your primary app origin.

4. They made CSP tenant-aware, but only in narrow ways

A lot of teams hear “multi-tenant” and assume every tenant needs a custom CSP. Usually that’s a trap.

What you actually want is:

  • one baseline policy
  • a tiny set of controlled per-tenant additions
  • preferably no script-src additions at all

For example, allowing a tenant-specific image CDN can be fine:

img-src 'self' data: https://cdn.tenant-assets.com;

Allowing a tenant to add arbitrary script origins is usually a mistake.

If you really need policy variants, generate them from capability flags, not freeform customer input.

function buildCsp({ allowSupportWidget }) {
  const connectSrc = ["'self'", "https://api.example.com"];
  const scriptSrc = ["'self'", "'strict-dynamic'"];

  if (allowSupportWidget) {
    scriptSrc.push("https://widget.intercom.io");
    connectSrc.push("https://api-iam.intercom.io");
  }

  return [
    "default-src 'self'",
    `script-src ${scriptSrc.join(" ")}`,
    `connect-src ${connectSrc.join(" ")}`,
    "object-src 'none'"
  ].join("; ");
}

What they got out of it

After the rollout:

  • stored XSS paths got much harder to exploit
  • tenant integrations became inventory-driven instead of ad hoc
  • support stopped asking engineering to “just whitelist one more domain”
  • custom domains no longer forced broad https: allowances
  • CSP reports became useful because noise dropped

That last point is underrated. A noisy CSP is ignored. A tight CSP gives you signal.

Practical advice for SaaS teams

Don’t start with every directive

Start with script-src, connect-src, frame-src, img-src, object-src, base-uri, and frame-ancestors. That gets you most of the value.

Ban raw tenant script injection

If your product has a “custom JS” box, your CSP project is already compromised.

Prefer capabilities over snippets

“Enable HubSpot” is manageable. “Paste whatever HubSpot gave you” is how policies rot.

Isolate untrusted tenant content

Different origin beats endless sanitizer debates.

Use examples, but adapt them

If you need a baseline to work from, csp-examples.com is useful for ready-to-use policy patterns. Just don’t cargo-cult a static site policy into a multi-tenant app.

The opinionated takeaway

CSP in multi-tenant SaaS is not mainly a header-writing problem. It’s a product boundary problem.

If tenants can run arbitrary code in your main origin, your CSP will either be weak or constantly broken. Once you accept that, the path gets clearer:

  • lock down the shell
  • isolate tenant-controlled content
  • use nonces
  • keep third parties on a short leash
  • make exceptions capability-based, not customer-defined

That’s the difference between a CSP that looks good in a slide deck and one that actually survives production.