CSP JavaScript Mistakes That Break Apps

Table of Contents

Content Security Policy changes how you write JavaScript. If you’ve spent years sprinkling inline handlers into templates, building DOM with innerHTML, or dropping in third-party snippets, CSP will punish every shortcut.

That’s good. Most CSP pain comes from patterns that were already brittle or unsafe.

I’ve seen teams “enable CSP” by adding 'unsafe-inline' and 'unsafe-eval' just to make the errors go away. That defeats the point. If you want JavaScript that actually works under a strict policy, you need to write code differently from the start.

Here are the most common mistakes I see, and how to fix them.

Mistake 1: Writing inline scripts

This is the classic one.

<script>
  window.appConfig = {
    apiBase: "/api",
    debug: true
  };
</script>

Under a strict CSP, that script is blocked unless you allow it with a nonce or hash. If your policy is trying to avoid inline execution, this pattern becomes a constant source of friction.

A real-world policy like the one from headertest.com uses a nonce and strict-dynamic:

content-security-policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-N2I2ZWRmNTEtZTM3Ny00MDVlLWFhNzYtYWE5YzU2NWRhZmUw' '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 works, but if you’re writing app code from scratch, I’d rather move logic into external files and keep inline script use tiny and deliberate.

Bad:

<script>
  document.getElementById("menu").classList.add("ready");
</script>

Better:

<script type="module" src="/assets/app.js"></script>
// /assets/app.js
document.getElementById("menu")?.classList.add("ready");

If you need server-provided config, pass data through HTML instead of executable code.

<div id="app"
     data-api-base="/api"
     data-debug="true"></div>
const app = document.getElementById("app");
const config = {
  apiBase: app?.dataset.apiBase,
  debug: app?.dataset.debug === "true"
};

Mistake 2: Using inline event handlers

This dies immediately under CSP:

<button onclick="saveDraft()">Save</button>

Inline handlers are effectively inline script. Same problem, same block.

The fix is boring and correct: bind events in JavaScript.

<button id="save-btn">Save</button>
document
  .getElementById("save-btn")
  ?.addEventListener("click", saveDraft);

function saveDraft() {
  console.log("saved");
}

This also scales better. You can use event delegation instead of littering templates with behavior.

<div id="toolbar">
  <button data-action="save">Save</button>
  <button data-action="publish">Publish</button>
</div>
document.getElementById("toolbar")?.addEventListener("click", (event) => {
  const button = event.target.closest("[data-action]");
  if (!button) return;

  switch (button.dataset.action) {
    case "save":
      saveDraft();
      break;
    case "publish":
      publishPost();
      break;
  }
});

That’s more maintainable and CSP-friendly.

Mistake 3: Reaching for eval(), new Function(), or string-based timers

If your codebase uses any of these, CSP is going to complain unless you weaken the policy with 'unsafe-eval'.

Bad:

eval("startApp()");
const fn = new Function("a", "b", "return a + b");
setTimeout("refreshToken()", 1000);

Don’t patch the CSP to allow this. Fix the code.

startApp();

const fn = (a, b) => a + b;

setTimeout(refreshToken, 1000);

A lot of old libraries and template engines still rely on dynamic code generation. If you’re starting fresh, avoid them. If you’re stuck with one, treat it as a dependency problem, not a CSP problem.

Mistake 4: Building UI with innerHTML everywhere

This one is sneaky. CSP doesn’t block innerHTML by default, so people assume it’s fine. It may still create XSS sinks, and it often encourages mixing markup and untrusted data in dangerous ways.

Bad:

list.innerHTML += `<li>${user.name}</li>`;

If user.name is attacker-controlled, you’ve got a problem.

The safer pattern is creating DOM nodes directly.

const li = document.createElement("li");
li.textContent = user.name;
list.appendChild(li);

For more complex structures:

function renderUserCard(user) {
  const card = document.createElement("article");
  card.className = "user-card";

  const heading = document.createElement("h2");
  heading.textContent = user.name;

  const email = document.createElement("p");
  email.textContent = user.email;

  card.append(heading, email);
  return card;
}

I’m not saying innerHTML is always forbidden. I am saying it’s usually the first thing I remove when I want code to behave well under a strict CSP and survive security review.

Mistake 5: Injecting styles from JavaScript

A lot of front-end code does this:

const style = document.createElement("style");
style.textContent = ".toast { background: red; }";
document.head.appendChild(style);

Whether this works depends on your style-src. The headertest.com policy allows 'unsafe-inline' in styles, which makes this easier, but that’s not where I’d aim if I were tightening a policy over time.

A cleaner pattern is to ship CSS in static files and toggle classes from JavaScript.

.toast {
  background: red;
}
.toast.is-visible {
  opacity: 1;
}
toast.classList.add("is-visible");

If your app relies heavily on CSS-in-JS that injects <style> tags at runtime, test it early against your target CSP. Many teams discover too late that their styling system quietly depends on unsafe style execution.

Mistake 6: Dynamically loading scripts without understanding nonce and strict-dynamic

Third-party loaders are where CSP gets messy.

With a policy like:

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

a nonce-bearing root script can load additional scripts, and those child scripts are trusted because of strict-dynamic.

That’s useful, but developers often misunderstand it and either over-whitelist hosts or load scripts in unsafe ways.

A safe loader pattern looks like this:

<script nonce="{{ .CSPNonce }}" src="/assets/bootstrap.js"></script>
// bootstrap.js
function loadScript(src) {
  const script = document.createElement("script");
  script.src = src;
  script.async = true;
  document.head.appendChild(script);
}

loadScript("https://www.googletagmanager.com/gtm.js?id=GTM-XXXX");

If your bootstrap script has a valid nonce and your policy includes 'strict-dynamic', modern browsers will allow the dynamically added script.

What I would not do is blindly allow half the internet in script-src because one vendor’s snippet told me to.

Keep the trusted entry points small. Review every external script like it has production database access, because in practice it often does.

If you want policy patterns to compare against, https://csp-examples.com is handy for ready-to-use examples.

Mistake 7: Forgetting that fetch() and WebSockets need connect-src

A lot of JavaScript is “CSP-compliant” until it makes a network call.

const response = await fetch("https://api.example.com/data");

If connect-src doesn’t allow that origin, the browser blocks it even though your script itself is allowed.

The headertest.com policy explicitly lists API and telemetry endpoints in connect-src, including WebSocket endpoints:

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;

That’s the right mindset. Write down every destination your app actually talks to:

  • API hosts
  • analytics endpoints
  • error reporting
  • feature flag services
  • WebSocket URLs
  • background polling endpoints

Then make your code predictable. Don’t scatter hardcoded URLs across random files.

const ENDPOINTS = {
  api: "/api",
  metrics: "https://or.headertest.com/events",
  socket: "wss://or.headertest.com/realtime"
};

Mistake 8: Embedding JSON the wrong way

Developers often replace inline scripts with this:

<script type="application/json" id="config">
  {"theme":"dark","apiBase":"/api"}
</script>

This can be fine, because non-executable script types are handled differently. But people then mix in unsafe templating or malformed escaping and create parsing bugs.

If you do this, keep it strict and parse it explicitly.

const configEl = document.getElementById("config");
const config = configEl ? JSON.parse(configEl.textContent) : {};

Personally, for small bits of state, I prefer data-* attributes. For larger structured payloads, JSON blobs are reasonable.

Just don’t drift back into “script tag as a dumping ground for executable setup code.”

Mistake 9: Treating CSP as a header-only problem

This is the big one. CSP-compliant JavaScript is mostly about coding style, not header syntax.

Good CSP-friendly habits:

  • external scripts by default
  • event listeners instead of inline handlers
  • no eval patterns
  • DOM APIs over HTML string concatenation
  • CSS classes over runtime style injection
  • centralized network endpoints
  • minimal, intentional third-party script loading

A header can reinforce these choices, but it can’t rescue bad JavaScript architecture.

If you need the exact behavior of directives like script-src, nonces, hashes, and strict-dynamic, the official reference is the MDN CSP docs and the CSP spec documentation from browser vendors. Start there when browser behavior seems weird.

A simple starting point

If I were building a server-rendered app from scratch, I’d aim for JavaScript that works with a policy shape like this:

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

Then I’d write code that never needs exceptions.

That’s the real trick: don’t “make CSP allow your JavaScript.” Write JavaScript that deserves a strict CSP.