Trovella Wiki

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:

  1. Generates a random nonce: Buffer.from(crypto.randomUUID()).toString("base64")
  2. Passes the nonce to server components via a request header: x-nonce
  3. Sets the Content-Security-Policy-Report-Only response 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

ConditionBehaviorReason
NODE_ENV === "development"Returns NextResponse.next() immediatelyHMR and streaming scripts break strict-dynamic
Path starts with /api/Returns NextResponse.next() immediatelyAPI routes return JSON, not HTML -- no CSP needed
Static assets (_next/static, images, favicon)Excluded via config.matcher regexStatic files don't need CSP headers
/monitoring (Sentry tunnel)Excluded via config.matcher regexSentry 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).

DirectiveValueReason
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.ioSentry tunnel at /monitoring (self), direct ingest as fallback
img-src'self' data: blob: https://*.googleusercontent.comGoogle 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:

HeaderValuePurpose
X-Frame-OptionsDENYLegacy iframe prevention (supplements frame-ancestors)
X-Content-Type-OptionsnosniffPrevents MIME-type sniffing
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadHSTS -- forces HTTPS for 2 years
Referrer-Policystrict-origin-when-cross-originLimits referer information on cross-origin requests
Permissions-Policycamera=(), microphone=(), geolocation=(), browsing-topics=()Disables unused browser features
Cross-Origin-Opener-Policysame-originIsolates browsing context

The X-Powered-By header is removed (poweredByHeader: false in next.config.ts).

Sentry Integration

Two Sentry-related configurations affect routing:

  1. Tunnel route: tunnelRoute: "/monitoring" in the Sentry config routes browser error reports through the app, avoiding ad blockers that block *.ingest.sentry.io
  2. Source maps: hideSourceMaps: true prevents clients from accessing source maps (security)
  3. Global error boundary: global-error.tsx catches unhandled errors and reports them to Sentry via Sentry.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.

On this page