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>
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.js — getNonce() 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 Start — getNonce(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 thatgetNonce()returns a non-empty string before Sentry initialises.
securityheaders.com verification
-
Deploy to a staging or production URL —
securityheaders.comrequires a publicly reachable HTTPS endpoint. Localhost is not supported. -
Run the scan — Enter your URL at https://securityheaders.com and click "Scan".
-
Expected grade — An A or A+ grade with all six headers present and no warnings.
-
Common issues and fixes:
Warning Cause Fix Content-Security-PolicymissingMiddleware matcher excluded the scanned path Verify config.matcherinmiddleware.tscovers the target pathunsafe-inlinein prod CSPNODE_ENVnot set to"production"on the serverConfirm NODE_ENV=productionin the deployment environmentNonce appears as literal {NONCE}buildSecurityHeaderscalled withoutnoncein prodEnsure the middleware generates a nonce and passes it to the builder connect-srcmissing an external originAPI origin not in allowedConnectOriginsAdd the origin URL to allowedConnectOriginsPermissions-PolicyflaggedBrowser support varies; not a blocking issue No action required — the header is correct -
Recheck after CSP changes — each
buildSecurityHeaderscall 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 |