Files
agentic-dev-template/docs/guides/security-headers.md
Danijel Martinek 1fa11fec83 docs(security): add security-headers and rate-limiting cookbooks
Adds two new consumer-facing guides:
- docs/guides/security-headers.md: per-framework middleware wiring
  (Next.js, TanStack Start, Payload CMS), nonce threading for inline
  scripts, CSP allowlist customisation, Sentry nonce integration, and
  securityheaders.com verification workflow.
- docs/guides/rate-limiting.md: manifest rateLimit field declaration,
  canonical key-naming convention (<feature>:<scope>:<key>),
  multi-budget patterns, InMemoryRateLimit / NoopRateLimit for dev/test,
  and production backend wiring via BindContext.rateLimit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 11:39:56 +00:00

14 KiB

Security headers cookbook

Every response from every app emits six security headers and a per-request CSP nonce. This guide walks the wiring for each framework, shows how consumer code threads the nonce into inline scripts, explains CSP allowlist customisation, and covers Sentry nonce integration and verification.


The six headers

buildSecurityHeaders(opts: SecurityHeadersConfig) from @repo/core-shared/security always emits:

Header Value
Strict-Transport-Security max-age=31536000; includeSubDomains; preload
X-Frame-Options DENY
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy camera=(), microphone=(), geolocation=()
Content-Security-Policy mode-dependent (see CSP modes below)

x-nonce is also forwarded as an internal request header so server components can retrieve the nonce without another round trip.


CSP modes

prod — strict-dynamic + nonce

default-src 'self';
script-src 'strict-dynamic' 'nonce-{NONCE}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: {ALLOWED_IMG_ORIGINS};
font-src 'self' {ALLOWED_FONT_ORIGINS};
connect-src 'self' {ALLOWED_CONNECT_ORIGINS};
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';

strict-dynamic allows scripts loaded by a nonce-bearing script to run without listing each origin explicitly.

dev — permissive for local tooling

default-src 'self';
script-src 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: {ALLOWED_IMG_ORIGINS};
font-src 'self' {ALLOWED_FONT_ORIGINS};
connect-src 'self' ws: localhost:* 127.0.0.1:* {ALLOWED_CONNECT_ORIGINS};
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';

unsafe-inline / unsafe-eval permit Vite HMR and React DevTools. The ws: and localhost:* entries allow HMR websockets and local API calls. These never appear in prod.

Both modes are selected automatically from NODE_ENV. No manual config is required.


Per-framework wiring

Next.js (apps/web-next)

Create or update middleware.ts at the app root:

// apps/web-next/middleware.ts
import { withSecurityHeaders } from "@repo/core-shared/security/next";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  return withSecurityHeaders(request);
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

withSecurityHeaders generates a fresh nonce per request, sets all six headers on the response, and forwards the nonce in both the downstream request headers (x-nonce) and the response headers so server components can read it.

TanStack Start (apps/web-tanstack)

Security headers are applied via a Nitro/H3 server hook in app.config.ts:

// apps/web-tanstack/app.config.ts
import { defineConfig } from "@tanstack/start/config";
import { withSecurityHeaders } from "@repo/core-shared/security/tanstack";

interface H3SecurityEvent {
  node: {
    req: { headers: Record<string, string | string[] | undefined> };
    res: { setHeader: (name: string, value: string) => void };
  };
}

function applySecurityHeaders(event: H3SecurityEvent): void {
  const { nonce, headers } = withSecurityHeaders();
  for (const [k, v] of Object.entries(headers)) {
    event.node.res.setHeader(k, v);
  }
  event.node.req.headers["x-nonce"] = nonce;
}

export default defineConfig({
  server: { hooks: { request: applySecurityHeaders } },
});

withSecurityHeaders() returns { nonce, headers }. The hook applies the headers to the response and stores the nonce on the request so getNonce(req) can read it from any server loader.

Payload CMS (apps/cms)

The CMS is server-rendered without client-side JavaScript hydration, so no nonce is needed:

// apps/cms/middleware.ts
import { buildSecurityHeaders } from "@repo/core-shared/security";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

export function middleware(_request: NextRequest): NextResponse {
  const mode = process.env.NODE_ENV === "production" ? "prod" : "dev";
  const secHeaders = buildSecurityHeaders({ mode });

  const response = NextResponse.next();
  for (const [name, value] of Object.entries(secHeaders)) {
    response.headers.set(name, value);
  }
  return response;
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

Nonce threading for inline scripts

Reading the nonce on the server

Next.jsgetNonce() reads the x-nonce header injected by withSecurityHeaders:

import { getNonce } from "@repo/core-shared/security/next";

// Inside a Server Component or route handler:
const nonce = await getNonce();

TanStack StartgetNonce(req) reads from the H3 request:

import { getNonce } from "@repo/core-shared/security/tanstack";
import { getEvent } from "vinxi/http";

// Inside a loader:
const nonce = getNonce(getEvent().node.req);

Exposing the nonce to the browser

Expose the nonce via a <meta> tag so client-side code can read it without re-fetching:

Next.js root layout:

// apps/web-next/src/app/layout.tsx
import { getNonce } from "@repo/core-shared/security/next";

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const nonce = await getNonce();
  return (
    <html lang="en">
      <head>
        <meta name="csp-nonce" content={nonce} />
      </head>
      <body>{children}</body>
    </html>
  );
}

TanStack Start root route:

// apps/web-tanstack/src/routes/__root.tsx
import { getNonce } from "@repo/core-shared/security/tanstack";
import { createRootRoute, Outlet } from "@tanstack/react-router";

export const Route = createRootRoute({
  loader: async () => {
    try {
      const { getEvent } = await import("vinxi/http");
      return { nonce: getNonce(getEvent().node.req) };
    } catch {
      return { nonce: "" }; // client-side navigation — nonce already in DOM
    }
  },
  component: () => {
    const { nonce } = Route.useLoaderData();
    return (
      <>
        <meta name="csp-nonce" content={nonce} />
        <Outlet />
      </>
    );
  },
});

Using the nonce in your inline scripts

Pass the nonce as the nonce attribute on any <script> tag you add. In prod mode, strict-dynamic propagates trust to scripts loaded by a nonce-bearing script, so third-party scripts loaded dynamically at runtime do not need individual nonces.

// In a Server Component (Next.js):
const nonce = await getNonce();
return (
  <script
    nonce={nonce}
    dangerouslySetInnerHTML={{ __html: "/* your inline script */" }}
  />
);

Do not set nonce on scripts that ship as static *.js files — the nonce changes per request and will not match cached assets.


CSP allowlist customisation

buildSecurityHeaders accepts three optional allowlists. Each entry must be a valid URL string (validated by the URL constructor at call time):

Option Default Controls
allowedConnectOrigins [] Appended to connect-src
allowedImgOrigins [] Appended to img-src (after data:)
allowedFontOrigins [] Appended to font-src

Example — connect to a remote API and load images from a CDN:

buildSecurityHeaders({
  mode: "prod",
  nonce,
  allowedConnectOrigins: ["https://api.example.com"],
  allowedImgOrigins: ["https://cdn.example.com"],
});

Pass the same options in the framework-level middleware by calling buildSecurityHeaders directly instead of withSecurityHeaders (the latter calls buildSecurityHeaders with no allowlists):

// apps/web-next/middleware.ts — custom allowlists
import {
  generateNonce,
  buildSecurityHeaders,
} from "@repo/core-shared/security";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  const nonce = generateNonce();
  const mode = process.env.NODE_ENV === "production" ? "prod" : "dev";
  const secHeaders = buildSecurityHeaders({
    mode,
    nonce,
    allowedConnectOrigins: ["https://api.example.com"],
    allowedImgOrigins: ["https://cdn.example.com"],
  });

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);

  const response = NextResponse.next({ request: { headers: requestHeaders } });
  for (const [name, value] of Object.entries(secHeaders)) {
    response.headers.set(name, value);
  }
  response.headers.set("x-nonce", nonce);
  return response;
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

Sentry nonce integration

Sentry's browser SDK injects a small inline script during initialisation. In prod mode that script is blocked by the nonce-based CSP unless you pass the nonce to initSentryClientReact (or the equivalent init function).

Reading the nonce on the client

After the root layout writes <meta name="csp-nonce">, client-side code reads it from the DOM:

function getNonce(): string {
  if (typeof document === "undefined") return "";
  return (
    document.querySelector('meta[name="csp-nonce"]')?.getAttribute("content") ??
    ""
  );
}

Passing the nonce to Sentry

// apps/web-tanstack/src/instrumentation-client.ts
import { initSentryClientReact } from "@repo/core-shared/instrumentation/sentry/init-client-react";

function getNonce(): string {
  if (typeof document === "undefined") return "";
  return (
    document.querySelector('meta[name="csp-nonce"]')?.getAttribute("content") ??
    ""
  );
}

initSentryClientReact({
  dsn: import.meta.env["VITE_WEB_TANSTACK_SENTRY_DSN"],
  app: "web-tanstack",
  release: import.meta.env["VITE_GIT_COMMIT_SHA"],
  nonce: getNonce(),
});

The nonce is forwarded to Sentry's BrowserTracing integration so Sentry's injected <script> elements carry the same nonce as the page and are allowed by the CSP.

If Sentry scripts are blocked in prod, open DevTools → Console. The error message will mention a nonce or CSP violation. Check that the <meta name="csp-nonce"> tag is present in the HTML and that getNonce() returns a non-empty string before Sentry initialises.


securityheaders.com verification

  1. Deploy to a staging or production URLsecurityheaders.com requires a publicly reachable HTTPS endpoint. Localhost is not supported.

  2. Run the scan — Enter your URL at https://securityheaders.com and click "Scan".

  3. Expected grade — An A or A+ grade with all six headers present and no warnings.

  4. Common issues and fixes:

    Warning Cause Fix
    Content-Security-Policy missing Middleware matcher excluded the scanned path Verify config.matcher in middleware.ts covers the target path
    unsafe-inline in prod CSP NODE_ENV not set to "production" on the server Confirm NODE_ENV=production in the deployment environment
    Nonce appears as literal {NONCE} buildSecurityHeaders called without nonce in prod Ensure the middleware generates a nonce and passes it to the builder
    connect-src missing an external origin API origin not in allowedConnectOrigins Add the origin URL to allowedConnectOrigins
    Permissions-Policy flagged Browser support varies; not a blocking issue No action required — the header is correct
  5. Recheck after CSP changes — each buildSecurityHeaders call change should be followed by a re-scan.


API surface quick-reference

Export Package path Purpose
buildSecurityHeaders(opts) @repo/core-shared/security Low-level builder; returns Record<string, string>
generateNonce() @repo/core-shared/security 16-byte crypto-random base64 string
withSecurityHeaders(request) @repo/core-shared/security/next Next.js middleware helper (generates nonce + sets headers)
getNonce() @repo/core-shared/security/next Read x-nonce from Next.js headers()
withSecurityHeaders() @repo/core-shared/security/tanstack TanStack helper; returns { nonce, headers }
getNonce(req) @repo/core-shared/security/tanstack Read x-nonce from an H3 NodeRequest
SecurityHeadersConfig @repo/core-shared/security Config type (mode, nonce?, allowed*Origins[])
InvalidSecurityHeadersConfig @repo/core-shared/security Thrown when an origin URL fails URL validation