Teams love dropping Loom videos into internal portals, onboarding hubs, help centers, and customer dashboards. Security teams usually hate how fast those embeds spread.

I’ve seen this pattern a lot: a portal starts with one harmless embedded video, then picks up analytics, a consent banner, a chat widget, and a couple of “temporary” inline scripts that never go away. The CSP ends up either too loose to matter or so strict that Loom breaks in production.

Here’s a practical case study for getting CSP under control on a Loom-powered portal, with a real-world mindset and before/after examples.

The setup

The app was a video portal for onboarding and support content. Main features:

  • server-rendered app pages
  • embedded Loom videos
  • analytics
  • cookie consent
  • a few inline bootstrapping scripts
  • no reason to allow plugins, random framing, or broad third-party script execution

The original policy looked a lot like the kind of “works for now” CSP I see in the wild.

Before: a CSP that technically exists, but doesn’t help much

This real header from headertest.com is a decent reference point for the shape many production CSPs take:

Content-Security-Policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-NWMyZjc1M2MtYTc3MC00ZDY0LWJkNjEtMGM1ZDQ0Y2UxNTgx' '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'

This isn’t terrible. It has some strong ideas:

  • object-src 'none'
  • base-uri 'self'
  • form-action 'self'
  • frame-ancestors 'none'
  • nonce-based scripts
  • strict-dynamic

But for a Loom portal, it still has a few issues.

Problem 1: no explicit Loom allowlist

If your portal embeds Loom, you need to allow Loom in the right places. Usually that means at least:

  • frame-src for the embed iframe
  • img-src for thumbnails and preview assets
  • connect-src if the embedded player or surrounding app fetches Loom-related resources
  • sometimes media-src depending on playback behavior

Without those, your app breaks the second someone pastes in a Loom embed.

Problem 2: style-src 'unsafe-inline'

This is the classic compromise. Sometimes you need it for a consent tool or legacy UI library. But if your app can move to nonce- or hash-based styles, do it. unsafe-inline is one of the most common places CSP gets watered down.

Problem 3: broad defaults can hide intent

Using default-src with several third parties is convenient, but it makes policy review harder. I prefer being explicit for the directives that matter.

If I’m reviewing a video portal, I want to immediately see:

  • what can execute scripts
  • what can frame content
  • what can receive network connections
  • what can load media

That’s easier when those directives are spelled out.

What broke when Loom was added

The portal team first shipped a Loom embed like this:

<iframe
  src="https://www.loom.com/embed/abc123xyz"
  frameborder="0"
  allowfullscreen
></iframe>

And the CSP blocked it because frame-src only allowed self and Cookiebot:

frame-src 'self' https://consentcdn.cookiebot.com;

The browser console showed a CSP violation for the iframe source. Then came the usual panic fix: someone proposed frame-src https:.

Don’t do that. That’s how you turn a targeted policy into decorative security.

After: a CSP shaped around the Loom portal

Here’s the tightened version I’d actually ship after testing the real app behavior.

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  style-src 'self' 'unsafe-inline' https://*.cookiebot.com https://consent.cookiebot.com;
  img-src 'self' data: https://www.loom.com https://cdn.loom.com https:;
  font-src 'self';
  connect-src 'self' https://api.example.com https://www.loom.com https://cdn.loom.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
  frame-src 'self' https://www.loom.com https://consentcdn.cookiebot.com;
  media-src 'self' https://www.loom.com https://cdn.loom.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';
  report-to default-endpoint;
  report-uri /csp-report

A few opinions here:

  • I reset default-src back to 'self'. Cleaner and easier to reason about.
  • I explicitly allow Loom only where needed.
  • I keep strict-dynamic because nonce-based script trust is the right direction for modern apps.
  • I leave style-src 'unsafe-inline' only if the app genuinely still needs it. I’d treat that as technical debt, not a final state.
  • I add reporting from day one.

If you want a refresher on directive behavior, the official CSP docs are still the source of truth, and https://csp-guide.com is useful for quick explanations.

The actual embed code

The embed itself is simple. The security work is in the policy, not the HTML.

<section class="video">
  <h2>Welcome walkthrough</h2>
  <iframe
    src="https://www.loom.com/embed/abc123xyz"
    title="Welcome walkthrough"
    allowfullscreen
    loading="lazy"
    referrerpolicy="strict-origin-when-cross-origin"
  ></iframe>
</section>

I like setting referrerpolicy deliberately on third-party embeds. It’s not CSP, but it’s part of the same discipline: be intentional about what leaves your app.

Nonces done correctly

If you’re using a nonce in script-src, generate it per response and inject it into every allowed inline script.

Node/Express example:

import crypto from "node:crypto";
import express from "express";

const app = express();

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://*.cookiebot.com https://*.google-analytics.com`,
      "style-src 'self' 'unsafe-inline' https://*.cookiebot.com https://consent.cookiebot.com",
      "img-src 'self' data: https://www.loom.com https://cdn.loom.com https:",
      "font-src 'self'",
      "connect-src 'self' https://api.example.com https://www.loom.com https://cdn.loom.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com",
      "frame-src 'self' https://www.loom.com https://consentcdn.cookiebot.com",
      "media-src 'self' https://www.loom.com https://cdn.loom.com",
      "frame-ancestors 'none'",
      "base-uri 'self'",
      "form-action 'self'",
      "object-src 'none'",
    ].join("; ")
  );

  next();
});

app.get("/", (req, res) => {
  res.send(`
    <!doctype html>
    <html>
      <body>
        <script nonce="${res.locals.nonce}">
          window.APP_CONFIG = { env: "prod" };
        </script>

        <iframe
          src="https://www.loom.com/embed/abc123xyz"
          title="Loom video"
          allowfullscreen
        ></iframe>
      </body>
    </html>
  `);
});

Two rules I’m strict about:

  1. Don’t reuse nonces across responses.
  2. Don’t mix nonce-based policies with random unsafe exceptions “just to get it working”.

That second one is how CSP rots.

The rollout strategy that saved time

The team did not switch straight to enforcement. Good move.

They first shipped a Content-Security-Policy-Report-Only header to learn what the portal was actually loading.

Example:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  style-src 'self' 'unsafe-inline' https://*.cookiebot.com https://consent.cookiebot.com;
  img-src 'self' data: https://www.loom.com https://cdn.loom.com https:;
  connect-src 'self' https://api.example.com https://www.loom.com https://cdn.loom.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
  frame-src 'self' https://www.loom.com https://consentcdn.cookiebot.com;
  media-src 'self' https://www.loom.com https://cdn.loom.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';
  report-uri /csp-report

That surfaced two things fast:

  • a marketing snippet loading an extra analytics endpoint no one documented
  • one old inline UI helper script missing a nonce

That’s exactly why report-only mode exists. It catches the stuff your architecture diagram forgot.

What changed after the new CSP

After moving to the explicit Loom-aware policy:

  • Loom embeds worked reliably
  • random third-party framing stayed blocked
  • inline script execution was limited to nonce-approved code
  • plugin/object abuse stayed dead with object-src 'none'
  • future reviews got easier because the policy showed intent instead of historical accidents

The biggest win wasn’t just “CSP passes.” The biggest win was that adding new third parties now required a deliberate change. That friction is healthy.

A few gotchas specific to video portals

1. Don’t assume iframe support is enough

Video platforms often load assets from domains other than the iframe host. Test thumbnails, playback, fullscreen, and analytics events.

2. Watch media-src

A lot of teams forget media-src until video playback fails in weird ways.

3. Keep frame-ancestors intentional

For many internal or customer portals, frame-ancestors 'none' is exactly right. If your app must be embedded elsewhere, define that narrowly. Don’t remove it casually.

4. Don’t solve CSP breakage with wildcards everywhere

https: in half your directives is not a policy. It’s an apology.

The final take

For Loom video portals, the right CSP is usually boring and explicit:

  • self by default
  • nonce-based scripts
  • narrow third-party allowances
  • explicit Loom entries for frame, media, image, and maybe connect sources
  • no object embeds
  • no surprise framing
  • report-only first, enforcement second

That’s the version that survives real production use.

And if your current CSP grew one emergency exception at a time, I’d rebuild it around actual app behavior instead of patching it forever. That almost always ends up smaller, clearer, and safer.