Configure CSP

Configure Content Security Policy headers, nonce for inline script, and overall suggestions.


Content Security Policy (CSP) is important to guard your Waku application against various security threats such as cross-site scripting (XSS), clickjacking, and other code injection attacks.

By using CSP, developers can specify which origins are permissible for content sources, scripts, stylesheets, images, fonts, objects, media (audio, video), iframes, and more.

Inline Script and nonce

Waku relies on inline scripts for basic functionality: loading the client entry module, streaming the initial RSC workload, and prefetching assets on navigation.

Inline script is also a source of Cross-site scripting (XSS). To mitigate this, a common practice is to set nonce attribute on <script>, together with proper Content-Security-Policy response header.

Since Waku v1.0.0-alpha.3, introduced in PR#1922, Waku has built-in support with nonce.

Waku has 2 ways to set nonce.

Hono Middleware

Hono provides a middleware which simplifies the setup of security headers. Set up a middleware in src/middleware/nonce.ts that enables Hono's context storage and generates the nonce:

import type { MiddlewareHandler } from 'hono';
import { every } from 'hono/combine';
import { contextStorage } from 'hono/context-storage';
import { NONCE, secureHeaders } from 'hono/secure-headers';

const nonceMiddleware = (): MiddlewareHandler =>
  every(
    contextStorage(),
    secureHeaders({
      contentSecurityPolicy: {
        scriptSrc: ["'self'", NONCE],
      },
    }),
  );

export default nonceMiddleware;

Behind the scenes, hono generates the nonce, passes it to Content-Security-Policy headers (you can add more here) and stores it in the secureHeadersNonce context. To apply that nonce to Waku's inline scripts, bridge it with a handler interceptor that reads the nonce from Hono's context and calls unstable_setNonce. Use tryGetContext rather than getContext, since interceptors also run at build time where there is no Hono request context.

In managed mode (no waku.server.tsx), drop the interceptor in src/pages/_interceptors/nonce.ts:

import { tryGetContext } from 'hono/context-storage';
import type { HandlerInterceptor } from 'waku/router/server';
import { unstable_setNonce as setNonce } from 'waku/router/server';

const nonceInterceptor: HandlerInterceptor = (next) => {
  const nonce = tryGetContext()?.get('secureHeadersNonce');
  if (typeof nonce === 'string') {
    setNonce(nonce);
  }
  return next();
};

export default nonceInterceptor;

With a custom waku.server.tsx using createPages, register the same logic through createInterceptor:

import { tryGetContext } from 'hono/context-storage';
import { unstable_setNonce as setNonce } from 'waku/router/server';

createPages(async ({ createPage, createInterceptor }) => {
  createInterceptor((next) => {
    const nonce = tryGetContext()?.get('secureHeadersNonce');
    if (typeof nonce === 'string') {
      setNonce(nonce);
    }
    return next();
  });
  return [
    // ...pages...
  ];
});

An interceptor wraps each render in both the request and build phases, so reading the nonce and calling setNonce runs inside the render scope where Waku picks it up.

For more on request context and handler interceptors, see Request Context.

Pass nonce in waku.server.entry

If your adapter allows you to customize handleRequest, you can pass it manually:

import adapter from 'waku/adapters/default';
import { Slot } from 'waku/minimal/client';
import App from './components/App.js';
import { encodeBase64 } from 'hono/utils/encode';

const generateNonce = () => {
  const arrayBuffer = new Uint8Array(16);
  crypto.getRandomValues(arrayBuffer);
  return 'toBase64' in arrayBuffer
    ? arrayBuffer.toBase64()
    : encodeBase64(arrayBuffer.buffer);
};

const NONCE = generateNonce();

export default adapter({
  handleRequest: async (input, { renderRsc, renderHtml }) => {
    if (input.type === 'component') {
      return renderRsc({ App: <App /> });
    }
    if (input.type === 'custom' && input.pathname === '/') {
      const response = await renderHtml(
        await renderRsc({ App: <App /> }),
        <Slot id="App" />,
        {
          rscPath: '',
          nonce: NONCE,
        },
      );

      response.headers.set(
        'Content-Security-Policy',
        `script-src 'self' 'nonce-${NONCE}';`,
      );

      return response;
    }
    return null;
  },
  handleBuild: async () => {},
});

We set a minimal CSP header here. In practice, CSP should be as minimal or strict as possible. We recommend hono middleware approach as it provides a good default value.

Limitation

In the case of SSG, a nonce is not secure because it must be randomly generated for every response. Since SSG produces static HTML, this is not possible. If your security requirements prioritize a nonce over static HTML benefits, consider disabling HTML pre-rendering, or use SSR Stream Interception if your host platform provides a similar middleware mechanism (e.g. Netlify's Edge Function).

For this reason unstable_setNonce applies to dynamic (request-time) rendering only and has no effect on statically generated HTML. It must be called from within a render scope such as a handler interceptor; called elsewhere it is a no-op.

designed bycandycode alternative graphic design web development agency San Diego