Twind is great when you want Tailwind-style utilities without a build step. The CSP story is where things get annoying.

The problem is simple: Twind often injects CSS at runtime using a <style> tag. CSP hates that unless you explicitly allow it. If you try to run Twind under a strict policy without planning for style injection, your app looks broken fast.

This guide is the practical version: what breaks, what policy you need, and the least-painful ways to make Twind work.

Why Twind collides with CSP

Twind generates CSS in the browser and writes it into a style element. CSP checks that against style-src.

If your policy is strict, this usually fails unless you allow one of these:

  • 'unsafe-inline' in style-src
  • a valid nonce-... on the injected <style>
  • a matching sha256-... hash for the exact inline style block

For Twind, hashes are usually a bad fit because the generated CSS changes at runtime. So the real choices are:

  1. allow 'unsafe-inline' for styles
  2. use nonces with Twind’s injected style tag
  3. avoid runtime injection entirely and move style generation to build/server time where possible

If you need a refresher on CSP directives, csp-guide.com is a solid reference.

The shortest working CSP for Twind

If you just need Twind to work and you accept the tradeoff, use this:

Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'

That works because style-src 'unsafe-inline' allows Twind’s runtime <style> injection.

When I’d use this

  • internal tools
  • prototypes
  • apps where script injection is tightly controlled and style injection is not your biggest risk
  • teams that need a fix now, not a security architecture project

Downside

'unsafe-inline' for styles weakens CSP. It does not make inline scripts legal, but it does allow injected inline CSS. That can still matter for UI redressing, data exfil tricks with CSS in edge cases, and generally reducing the value of your policy.

A better option: nonce the Twind style tag

If your Twind setup lets you attach a nonce to the generated <style> element, that’s the sweet spot.

Your CSP becomes:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{{NONCE}}'; style-src 'self' 'nonce-{{NONCE}}'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'

And your HTML includes the same nonce on scripts and the Twind style element.

Plain HTML example with Twind and a nonce

Here’s the shape you want:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self' 'nonce-r4nd0m123'; style-src 'self' 'nonce-r4nd0m123'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
    />
    <script type="module" nonce="r4nd0m123">
      import { setup, tw } from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm'

      const style = document.createElement('style')
      style.setAttribute('nonce', 'r4nd0m123')
      document.head.appendChild(style)

      setup({ target: style })

      document.body.innerHTML = `
        <main class="${tw`min-h-screen bg-slate-950 text-white p-8`}">
          <h1 class="${tw`text-3xl font-bold`}">Twind with CSP nonce</h1>
        </main>
      `
    </script>
  </head>
  <body></body>
</html>

The key detail is setup({ target: style }) using a <style> element that already has the nonce.

If you let Twind create the style tag on its own, you may not get a nonce attached, and CSP will block it.

Node/Express example

This is the pattern I’ve used most often: generate one nonce per request, set it in the header, and inject it into the page.

import express from 'express'
import crypto from 'node:crypto'

const app = express()

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString('base64')
  next()
})

app.get('/', (req, res) => {
  const nonce = res.locals.nonce

  res.setHeader(
    'Content-Security-Policy',
    [
      "default-src 'self'",
      `script-src 'self' 'nonce-${nonce}'`,
      `style-src 'self' 'nonce-${nonce}'`,
      "img-src 'self' data:",
      "font-src 'self'",
      "object-src 'none'",
      "base-uri 'self'",
      "frame-ancestors 'none'",
    ].join('; ')
  )

  res.send(`
    <!doctype html>
    <html>
      <head>
        <script type="module" nonce="${nonce}">
          import { setup, tw } from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm'

          const style = document.createElement('style')
          style.setAttribute('nonce', '${nonce}')
          document.head.appendChild(style)

          setup({ target: style })

          window.render = () => {
            document.getElementById('app').className =
              tw('min-h-screen bg-zinc-900 text-white p-10')
          }
        </script>
      </head>
      <body onload="render()">
        <div id="app">Hello Twind</div>
      </body>
    </html>
  `)
})

app.listen(3000)

One warning: the inline onload handler above would be blocked by that CSP. So in a real app, don’t do that. Here’s the corrected body script pattern:

<body>
  <div id="app">Hello Twind</div>
  <script type="module" nonce="{{NONCE}}">
    import { setup, tw } from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm'

    const style = document.createElement('style')
    style.setAttribute('nonce', '{{NONCE}}')
    document.head.appendChild(style)

    setup({ target: style })

    document.getElementById('app').className =
      tw('min-h-screen bg-zinc-900 text-white p-10')
  </script>
</body>

Next.js or SSR apps

If you’re rendering HTML on the server, the same rule applies:

  • generate nonce per request
  • include it in CSP response header
  • pass it into the rendered page
  • ensure Twind writes into a style tag with that nonce

Pseudo-example:

export default function Document({ nonce }: { nonce: string }) {
  return (
    <html>
      <head>
        <script nonce={nonce} type="module" dangerouslySetInnerHTML={{
          __html: `
            import { setup } from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm';
            const style = document.createElement('style');
            style.setAttribute('nonce', '${nonce}');
            document.head.appendChild(style);
            setup({ target: style });
          `
        }} />
      </head>
      <body>
        <div id="app" />
      </body>
    </html>
  )
}

I’d still prefer avoiding inline module code where the framework gives you a cleaner nonce-aware asset pipeline. But if you need a minimal working pattern, this is it.

If you must use external scripts too

Real apps rarely have only Twind. Analytics, consent banners, and widgets show up fast.

Here’s a real-world CSP from Headertest:

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

For a Twind app, the relevant bit is still style-src. If Twind injects CSS without a nonce, you’ll usually end up needing 'unsafe-inline' there unless you explicitly control the target style element.

A practical version for Twind plus common third parties might look like this:

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

If Cookiebot or another tool injects inline styles without nonce support, you may still be forced back to:

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

That’s not ideal, but sometimes vendor reality wins.

Common failure cases

1. Twind works locally but not in production

Usually production adds CSP and local dev doesn’t. Check the console for errors like:

Refused to apply inline style because it violates the following Content Security Policy directive: "style-src 'self'"

That’s your signal that Twind’s style element isn’t covered.

2. You added a script nonce but styles still fail

script-src and style-src are separate. A script nonce does not automatically bless inline styles.

You need the style tag itself to carry a valid nonce and style-src must include that nonce.

3. You used hashes

For Twind, hashes are fragile because the generated CSS changes with runtime class usage. Great for static inline CSS. Bad fit for Twind.

4. You rely on strict-dynamic

strict-dynamic affects scripts, not styles. It won’t help Twind’s injected CSS.

Copy-paste policies

Easiest working policy

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'

Better policy with nonced Twind style tag

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{{NONCE}}'; style-src 'self' 'nonce-{{NONCE}}'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'

Twind plus API calls

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

My recommendation

If you can attach a nonce to Twind’s target style element, do that. It’s the cleanest balance between security and sanity.

If your Twind setup or framework makes that painful, use 'unsafe-inline' for style-src and keep the rest of the policy strict. I’d much rather see a team ship a solid CSP with one deliberate compromise than pretend they have a strict policy while the UI is silently broken.

For Twind, that’s really the game: either control the style tag, or accept inline styles.