Content-Security-Policy in Next.js: Environment-Aware Headers, connect-src Rules, and Realtime Pitfalls

Content-Security-Policy in Next.js: Environment-Aware Headers, connect-src Rules, and Realtime Pitfalls

Content-Security-Policy gets treated as a box to check: paste a template from OWASP, sprinkle a few nonces, call it secure. But in modern application stacks, especially ones built on the Next.js App Router, React Server Components, and real-time GraphQL connections, a static CSP isn't just insufficient, it's dangerous. It can silently block hydration, kill WebSocket upgrades, or collapse real-time telemetry flows without leaving meaningful traces.

This post walks through the architecture, reasoning, and real-world pitfalls of implementing environment-aware CSP rules with headers() in the App Router, focusing on the one directive that causes disproportionate pain: connect-src.

I'll also share how we use environment-specific CSP at Hoomanely, where our fleet of SoM-based devices, Tracker, EverBowl, and EverHub, relies on real-time telemetry ingestion. The frontends powering our internal dashboards are cloud-based rather than device-hosted, but the same CSP considerations apply whenever you need streaming logs, live device health, or gated admin access to real-time channels.

Why CSP breaks modern Next.js apps

A CSP restricts where scripts, images, fonts, and connections can originate. But the modern web isn't static HTML plus JS anymore. A Next.js app includes React Server Components interleaving server and client execution, streaming responses relying on hydration scripts, edge-rendered layouts that preload data, GraphQL clients batching requests and retrying over multiple transports, real-time connections over WebSockets, SSE, or AppSync subscriptions, and analytics scripts spinning up background connect events.

A static CSP template is usually unaware of all this. A single missing directive causes failures like hydration never starting because inline bootstrap scripts are blocked, WebSocket connections silently downgrading or failing, GraphQL subscriptions disconnecting immediately, AppSync real-time endpoints getting blocked, inline nonce mismatches between RSC and the client shell, and dev tools breaking since the Next dev server depends heavily on ws://localhost connections.

The worst part: Next.js renders the page without errors, leaving you debugging a suspected race condition when the real culprit is a too-strict CSP. That's why CSP has to be environment-aware, not copy-pasted once and deployed forever.

CSP is not one policy, it's three

A correct CSP is tied to its environment. Development needs localhost:* connections, a WebSocket dev channel, eval-like constructs internally, inline scripts for fast refresh, and a permissive connect-src. Staging needs real backend domains, AppSync or GraphQL endpoints, debugging and observability tools, and something stricter than dev but not as locked down as production. Production needs to eliminate unsafe code, use nonces or hashes for bootstrap scripts, explicitly allow only real domains, and support real-time connections without wildcard fallbacks.

Trying to use one CSP across all three produces fragile, brittle apps. A well-designed policy is flexible where it must be and strict where it can be.

Designing an environment-aware CSP model

A resilient CSP for the App Router uses a layered approach. Layer one is the core structure: default-src 'self', script-src 'self' 'nonce-{nonce}', style-src 'self' 'unsafe-inline', img-src 'self' data: blob:, font-src 'self', and a dynamic connect-src. Everything here is stable except connect-src, which is where DX, real-time communication, and production lockdown all collide.

Layer two is the connect-src expansion pack. To support real-time systems, it has to anticipate REST fetches, GraphQL queries and mutations, GraphQL subscription endpoints, the AppSync real-time endpoint, WebSocket fallback transports, SSE event streams, analytics ingestion, and CDN-backed prefetching. These vary a lot across environments.

Layer three is per-environment merge rules. For development:

connect-src 'self' http://localhost:* ws://localhost:* https://*.ngrok.io data:;

That's permissive because Fast Refresh uses WebSockets, dev tunnels need wildcard domains, and errors should appear instantly instead of failing silently. For staging:

connect-src 'self' https://staging-api.example.com wss://staging-rt.example.com https://*.amazonaws.com;

This mirrors production, allows real-time debugging, restricts localhost, and supports CDN plus AppSync. For production:

connect-src 'self' https://api.example.com wss://rt.example.com https://<region>.amazonaws.com;

This is a clear, tight allow-list supporting production AppSync and subscription URLs while eliminating wildcard domains, localhost, and eval-based tools.

Implementation with headers() and dynamic CSP

The App Router's headers() hook lets you generate CSP per request:

export function headers() {
  const env = process.env.APP_ENV;

  return [
    {
      source: "/(.*)",
      headers: [
        {
          key: "Content-Security-Policy",
          value: generateCsp(env),
        },
      ],
    },
  ];
}

Where generateCsp() returns environment-specific policies. You can also insert a per-request nonce with something like const nonce = crypto.randomUUID();, used to unlock Next.js' RSC bootstrap script.

Where CSP intersects IoT dashboards

At Hoomanely, our internal tools visualize telemetry from Tracker devices (motion, altitude, external events), EverBowl (body temperature, audio events, weight curves), and EverHub (edge inference and pipeline decisions). Much of this streams via GraphQL subscriptions or WebSocket-backed SSE.

Because real-time data matters for validating device behavior, we run dashboards across dev, staging, and production, each with different transports and endpoints. A strict but environment-unaware CSP would block EverHub's real-time decision logs, block Tracker's live positional traces, block EverBowl's audio event streaming, and prevent dev tools from connecting entirely. By designing a configurable, environment-aware connect-src, real-time flows stay fluid in development, staging accurately mirrors production, and production stays secure without sacrificing developer experience. That balance matters a lot in multi-device ecosystems where telemetry is time-sensitive.

Common pitfalls: why real-time breaks silently

WebSockets get blocked by missing wss:// domains. The Next.js dev server leans heavily on WS, and AppSync subscriptions use a different connection URL than GraphQL queries, so forgetting the wss:// endpoint silently kills real-time features.

SSE fails from missing EventStream MIME allowances. Not every real-time solution uses WebSockets, and some just need connect-src plus making sure no other directive blocks them.

Hydration fails because inline scripts are restricted. Next.js injects a minimal inline script to bootstrap the RSC boundary, and that script needs a nonce. Without it, the JS bundle loads but the client never hydrates.

The AppSync real-time endpoint doesn't match the GraphQL URL. AWS AppSync uses a secondary endpoint for subscriptions, https://xxxxxxxx.appsync-realtime-api.<region>.amazonaws.com, and many engineers only allow the query endpoint, https://xxxxxxxx.appsync-api.<region>.amazonaws.com, causing subscriptions to fail instantly.

Preload and prefetch requests get blocked under connect-src. Next.js issues background requests for RSC preloads, pre-rendered route segments, and optimistic client navigations, and if connect-src doesn't include your CDN and data source domains, prefetching dies and TTFB rises.

Takeaways

CSP isn't static, it's a living specification tied to environments. Development needs freedom, production demands precision. connect-src is the hardest part of CSP for modern real-time apps, since WebSockets, AppSync, SSE, analytics, and RSC preloads all need explicit allowances. The Next.js App Router relies on inline scripts that require nonce-based unlocking, and missing that breaks hydration silently. Environment-aware CSP design prevents common debugging rabbit holes, since it's better to generate CSP per environment than maintain one template. And in multi-device IoT ecosystems, real-time data is non-negotiable, so CSP has to secure production without strangling live telemetry.