Middleware & CSP
How the Next.js middleware generates CSP nonces, what it skips, and the security headers applied by next.config.ts.
Trovella's middleware (apps/web/src/middleware.ts) has a single responsibility: Content Security Policy. Auth is handled per-page, not in middleware. Security headers beyond CSP are set in next.config.ts.
What the Middleware Does
On each non-API page request in production:
- Generates a random nonce:
Buffer.from(crypto.randomUUID()).toString("base64") - Passes the nonce to server components via a request header:
x-nonce - Sets the
Content-Security-Policy-Report-Onlyresponse header with the nonce embedded
The root layout reads the nonce and passes it to the ThemeProvider (which uses next-themes):
// apps/web/src/app/layout.tsx
const nonce = (await headers()).get("x-nonce") ?? undefined;
// ...
<ThemeProvider nonce={nonce}>
What the Middleware Skips
| Condition | Behavior | Reason |
|---|---|---|
NODE_ENV === "development" | Returns NextResponse.next() immediately | HMR and streaming scripts break strict-dynamic |
Path starts with /api/ | Returns NextResponse.next() immediately | API routes return JSON, not HTML -- no CSP needed |
Static assets (_next/static, images, favicon) | Excluded via config.matcher regex | Static files don't need CSP headers |
/monitoring (Sentry tunnel) | Excluded via config.matcher regex | Sentry tunnel is a proxy, not an HTML page |
The matcher regex that excludes static assets:
export const config = {
matcher: [
"/((?!_next/static|_next/image|monitoring|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)",
],
};
CSP Directives
The CSP is in report-only mode (Content-Security-Policy-Report-Only) -- violations are logged but not blocked. This allows monitoring before enforcement. The policy was added for TRO-120 (CASA Tier 2 / ASVS V14 compliance).
| Directive | Value | Reason |
|---|---|---|
default-src | 'self' | Baseline: only same-origin resources |
script-src | 'self' 'nonce-{nonce}' 'strict-dynamic' 'unsafe-inline' 'wasm-unsafe-eval' | Nonce for inline scripts; strict-dynamic allows Next.js code-split chunks loaded by nonced bootstrap; unsafe-inline is a CSP3 fallback ignored when strict-dynamic is present; wasm-unsafe-eval for Sentry replay |
style-src | 'self' 'unsafe-inline' | Next.js and Tailwind inject inline styles |
font-src | 'self' | next/font/google self-hosts fonts at build time |
connect-src | 'self' https://*.ingest.sentry.io | Sentry tunnel at /monitoring (self), direct ingest as fallback |
img-src | 'self' data: blob: https://*.googleusercontent.com | Google profile avatars via OAuth |
frame-src | 'none' | No iframes allowed |
frame-ancestors | 'none' | App cannot be embedded in iframes |
object-src | 'none' | No plugins (Flash, Java, etc.) |
base-uri | 'self' | Prevents <base> tag injection |
form-action | 'self' | Forms can only submit to same origin |
worker-src | 'self' blob: | Service workers and web workers (Sentry) |
Security Headers from next.config.ts
In addition to CSP (set by middleware), next.config.ts applies these headers to all routes:
| Header | Value | Purpose |
|---|---|---|
X-Frame-Options | DENY | Legacy iframe prevention (supplements frame-ancestors) |
X-Content-Type-Options | nosniff | Prevents MIME-type sniffing |
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | HSTS -- forces HTTPS for 2 years |
Referrer-Policy | strict-origin-when-cross-origin | Limits referer information on cross-origin requests |
Permissions-Policy | camera=(), microphone=(), geolocation=(), browsing-topics=() | Disables unused browser features |
Cross-Origin-Opener-Policy | same-origin | Isolates browsing context |
The X-Powered-By header is removed (poweredByHeader: false in next.config.ts).
Sentry Integration
Two Sentry-related configurations affect routing:
- Tunnel route:
tunnelRoute: "/monitoring"in the Sentry config routes browser error reports through the app, avoiding ad blockers that block*.ingest.sentry.io - Source maps:
hideSourceMaps: trueprevents clients from accessing source maps (security) - Global error boundary:
global-error.tsxcatches unhandled errors and reports them to Sentry viaSentry.captureException(error)
For detailed Sentry setup, see Error Tracking.
Why Auth is Not in Middleware
A deliberate design choice: auth checks happen in each page's server component, not in middleware. Reasons:
- Co-location: Auth logic lives next to the page that needs it, making it easier to understand and modify
- Flexibility: Different pages can handle missing sessions differently (redirect vs. render public content)
- Simplicity: The middleware stays tiny (single responsibility: CSP) and easy to reason about
- Next.js patterns: Aligns with the App Router recommendation of using server components for auth
The home page (/) demonstrates the flexibility: it renders a landing page for visitors and a dashboard for authenticated users, without any middleware involvement.