Files
agentic-dev/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

379 lines
14 KiB
Markdown

# 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](#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:
```ts
// 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`:
```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:
```ts
// 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`:
```ts
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:
```ts
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:**
```tsx
// 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:**
```tsx
// 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.
```tsx
// 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:
```ts
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):
```ts
// 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:
```ts
function getNonce(): string {
if (typeof document === "undefined") return "";
return (
document.querySelector('meta[name="csp-nonce"]')?.getAttribute("content") ??
""
);
}
```
### Passing the nonce to Sentry
```ts
// 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 URL**`securityheaders.com` requires a publicly reachable HTTPS endpoint. Localhost is not supported.
2. **Run the scan** — Enter your URL at [https://securityheaders.com](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 |