Remix is a nice fit for a strict Content Security Policy because it already gives you a clean server-rendered document boundary. But I keep seeing Remix apps ship with a CSP that looks strict on paper and falls apart the second analytics, hydration, or a UI library enters the room.

The usual pattern is familiar:

  • start with default-src 'self'
  • app breaks
  • add 'unsafe-inline'
  • app still breaks
  • add a few domains
  • stop checking reports

That’s not a CSP. That’s a list of regrets.

Here are the most common CSP mistakes I see in Remix apps, why they happen, and how I’d fix them.

Mistake #1: Setting CSP in Remix but forgetting nonces

If you want a serious CSP in Remix, you usually need a nonce-based policy. Remix renders the HTML document on the server, and your app often includes framework-generated scripts for hydration, route data, and deferred behavior.

If your policy says:

Content-Security-Policy: default-src 'self'; script-src 'self'

your page will often break because Remix emits inline scripts or script tags that need explicit authorization.

Fix

Generate a nonce per request, add it to the CSP header, and pass it into the rendered document so every allowed script gets the same nonce.

A simplified server example for Remix on Node:

import { createRequestHandler } from "@remix-run/express";
import express from "express";
import crypto from "node:crypto";

const app = express();

app.all(
  "*",
  (req, res, next) => {
    res.locals.nonce = crypto.randomUUID();
    next();
  },
  createRequestHandler({
    build: require("./build"),
    getLoadContext(req, res) {
      return {
        cspNonce: res.locals.nonce,
      };
    },
  })
);

app.use((req, res, next) => {
  const nonce = res.locals.nonce;
  res.setHeader(
    "Content-Security-Policy",
    [
      "default-src 'self'",
      `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
      "style-src 'self'",
      "img-src 'self' data: https:",
      "font-src 'self'",
      "connect-src 'self'",
      "base-uri 'self'",
      "form-action 'self'",
      "frame-ancestors 'none'",
      "object-src 'none'",
    ].join("; ")
  );
  next();
});

Then use that nonce in your Remix document:

import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from "@remix-run/react";
import type { LinksFunction } from "@remix-run/node";

export function loader({ context }: any) {
  return Response.json({
    cspNonce: context.cspNonce,
  });
}

export default function App() {
  const { cspNonce } = useLoaderData<typeof loader>();

  return (
    <html lang="en">
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration nonce={cspNonce} />
        <Scripts nonce={cspNonce} />
      </body>
    </html>
  );
}

If you skip the nonce wiring, you’ll end up weakening the policy to make Remix boot again.

Mistake #2: Using 'unsafe-inline' for scripts because hydration breaks

This is the fastest way to turn CSP into theater.

I still see Remix apps with policies like:

script-src 'self' 'unsafe-inline' https://www.googletagmanager.com

That fixes hydration errors, sure. It also allows injected inline scripts. If an attacker lands HTML injection anywhere, your CSP won’t save you.

Fix

Use nonces for inline and framework-managed scripts. If you also use third-party script loaders, consider 'strict-dynamic' with a nonce.

A real-world header from headertest.com shows this pattern:

content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-NjU0MmU1MDYtYjcyNy00NzE0LThlNDctNTNiNWI3ODUyMzk5' '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'

The good part here is the nonce plus 'strict-dynamic' in script-src. That’s the right direction for modern apps. The weak spot is style-src 'unsafe-inline', which I’ll get to next.

If you need ready-made policy patterns, csp-examples.com is useful.

Mistake #3: Leaving 'unsafe-inline' in style-src forever

This one is common in Remix apps using CSS-in-JS libraries, component libraries, or consent banners that inject styles.

Developers often accept:

style-src 'self' 'unsafe-inline'

because “it’s only styles.” I don’t love that argument. Inline styles can still become part of exploit chains, and once a weak directive gets normalized, teams stop tightening anything.

Fix

First, figure out who is injecting styles.

Common causes in Remix:

  • CSS-in-JS runtime injection
  • third-party consent tools
  • UI component libraries
  • inline <style> blocks in the root document

Then choose the least bad fix:

  • move styles to external stylesheets under 'self'
  • use a nonce on style tags if your setup supports it
  • isolate the specific third-party source instead of allowing all inline styles

If you truly cannot avoid it, keep 'unsafe-inline' only in style-src, never in script-src, and document why.

A tighter version might look like:

style-src 'self' https://consent.cookiebot.com;

or, if supported by your rendering path:

style-src 'self' 'nonce-<request-nonce>' https://consent.cookiebot.com;

Mistake #4: Forgetting connect-src for Remix loaders and browser fetches

Remix blurs the line between server and client, which is great until CSP breaks your client-side data fetching.

People lock down script-src and img-src, then wonder why fetch() calls fail in the browser.

Typical misses:

  • API calls from the browser to a separate backend
  • WebSocket endpoints
  • analytics beacons
  • feature flag services
  • error reporting endpoints

Fix

Audit every browser-initiated network request and add them to connect-src.

Example:

connect-src 'self' https://api.example.com wss://ws.example.com https://analytics.example.com;

Don’t confuse server-side Remix loaders with browser fetches. CSP only governs what the browser does. Your server can call other services without connect-src caring.

That distinction matters. I’ve seen teams add internal backend hosts to CSP because they were thinking about server loaders. Totally unnecessary.

Mistake #5: Whitelisting broad domains instead of understanding script trust

This usually starts with tag managers.

A team adds Google Tag Manager, analytics, a consent manager, and maybe a support widget. The policy grows into this:

script-src 'self' https: *.vendor1.com *.vendor2.com *.vendor3.com

At that point, you’ve basically told the browser, “Run whatever script shows up from the internet, but with branding.”

Fix

Use a nonce-based script-src and add 'strict-dynamic' where it makes sense. That shifts trust toward scripts you explicitly nonce rather than every host on a giant allowlist.

Something like this is much cleaner:

script-src 'self' 'nonce-{{nonce}}' 'strict-dynamic' https://www.googletagmanager.com;

With that model, the nonce is the primary trust signal.

Be careful, though: once you bring in a tag manager, you’re delegating script loading decisions. That may be a business requirement, but don’t pretend CSP is still doing all the heavy lifting. It’s doing less, by design.

Mistake #6: Forgetting Remix document pieces that need the nonce

I’ve seen teams nonce <Scripts /> and forget <ScrollRestoration />, or add a nonce in one document path and not another.

If you have custom error boundaries or alternate root rendering paths, they need the same treatment.

Fix

Pass the same nonce consistently to every Remix component that emits inline script content.

Typical root document setup:

export default function App() {
  const { cspNonce } = useLoaderData<typeof loader>();

  return (
    <html lang="en">
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration nonce={cspNonce} />
        <Scripts nonce={cspNonce} />
      </body>
    </html>
  );
}

And do the same in your error boundary document if it renders a full page shell.

Mistake #7: Shipping one CSP for local dev and never revisiting production

Development often needs looser settings:

  • Vite dev server
  • hot reload WebSockets
  • localhost origins
  • inline styles from tooling

That’s fine. Production is where teams get lazy and keep the dev policy.

Fix

Maintain separate CSP variants for development and production.

Example:

function buildCsp({ nonce, isDev }: { nonce: string; isDev: boolean }) {
  const directives = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self'${isDev ? " 'unsafe-inline'" : ""}`,
    `connect-src 'self'${isDev ? " ws://localhost:* http://localhost:*" : " https://api.example.com"}`,
    "img-src 'self' data: https:",
    "font-src 'self'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
    "object-src 'none'",
  ];

  return directives.join("; ");
}

I’m fine with a pragmatic dev CSP. I’m not fine with shipping it.

Mistake #8: Ignoring frame-ancestors, base-uri, and form-action

A lot of Remix CSPs focus only on scripts and styles. That misses easy wins.

These directives are low-friction and worth adding:

frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';

Why they matter:

  • frame-ancestors 'none' blocks clickjacking
  • base-uri 'self' stops malicious <base> tag abuse
  • form-action 'self' limits form submissions
  • object-src 'none' kills off old plugin-related attack surface

The headertest.com policy gets this part right. Most apps should too.

Mistake #9: Rolling out CSP without report-only mode

If you switch on an aggressive CSP in Remix without testing, you’ll break production for real users. Usually because of one weird route, one A/B testing script, or one vendor callback nobody documented.

Fix

Start with Content-Security-Policy-Report-Only, collect violations, then enforce.

For example:

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-abc123' 'strict-dynamic'; report-to csp-endpoint

Use reporting to find:

  • missing nonces
  • blocked browser fetches
  • hidden third-party dependencies
  • dev-only code leaking into prod

Then switch to enforcement when the noise drops and the remaining violations are understood.

A solid Remix CSP baseline

If I were starting fresh on a Remix app, I’d begin around here:

Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{{nonce}}' 'strict-dynamic';
style-src 'self';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';

Then I’d add only what the app actually needs.

That’s the real fix for most CSP problems in Remix: stop treating the policy as a static copy-paste header. It needs to match how your document is rendered, how your scripts are authorized, and which browser-side requests your app really makes.

For Remix specifically, the biggest upgrade is simple: generate a nonce per request, wire it through the document correctly, and resist the urge to “just add unsafe-inline” when something breaks.

If you want the canonical framework APIs for document rendering and script components, check the official Remix docs at https://remix.run/docs.