StarterApp Docs
Security

Security Headers

Complete guide to HTTP security headers, CSP, HSTS, and cache controls

Security headers protect against common web vulnerabilities. This codebase applies headers through middleware (for HTML pages) and security helpers (for API routes).

Pro Tip

The @workspace/security package provides helpers that automatically apply correct headers. Middleware handles page-level headers. API routes use secureUserJson() and securePublicJson().

Middleware Headers

Both middleware files apply consistent security headers to all HTML responses:

HeaderValuePurpose
Content-Security-PolicyDynamic (see CSP section)Restricts resource loading to approved origins
Referrer-Policystrict-origin-when-cross-originLimits referer information on cross-origin requests
X-Content-Type-OptionsnosniffPrevents MIME type sniffing
Permissions-Policycamera=(), microphone=(), ...Disables unused browser features
Origin-Agent-Cluster?1Isolates origin in browser process
X-DNS-Prefetch-ControloffDisables DNS prefetching for privacy
X-Permitted-Cross-Domain-PoliciesnoneBlocks Flash cross-domain policies
Strict-Transport-SecurityConditionalOnly with ENABLE_HSTS=1 on HTTPS
Cache-Controlno-store (dashboard only)Prevents caching of authenticated pages

Implementation

import { buildDynamicCsp, extractReportUri } from "~/lib/csp";

const isProd = process.env.NODE_ENV === "production";
const enableHsts = process.env.ENABLE_HSTS === "1";

export default function middleware(request: NextRequest) {
  const nonce = createNonce();
  const reportUri = extractReportUri(request);
  const csp = buildDynamicCsp({ nonce, reportUri, isProd });

  const response = NextResponse.next();

  // CSP with nonce
  response.headers.set("Content-Security-Policy", csp);
  response.headers.set("x-nonce", nonce);

  // Cache control for authenticated pages
  if (!isStaticAssetPath(request.nextUrl.pathname)) {
    response.headers.set("Cache-Control", "no-store");
  }

  // Security headers
  response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
  response.headers.set("X-Content-Type-Options", "nosniff");
  response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
  response.headers.set("Origin-Agent-Cluster", "?1");
  response.headers.set("X-DNS-Prefetch-Control", "off");

  // HSTS (production HTTPS only)
  if (enableHsts && isProd && request.nextUrl.protocol === "https:") {
    response.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
  }

  return response;
}
import { buildStaticCsp, extractReportUri } from "~/lib/csp";

const isProd = process.env.NODE_ENV === "production";
const enableHsts = process.env.ENABLE_HSTS === "1";

export default function middleware(request: NextRequest) {
  const reportUri = extractReportUri(request);
  const csp = buildStaticCsp({ reportUri, isProd });

  const response = NextResponse.next();

  // Static CSP
  response.headers.set("Content-Security-Policy", csp);

  // Security headers (identical to dashboard)
  response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
  response.headers.set("X-Content-Type-Options", "nosniff");
  response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
  response.headers.set("Origin-Agent-Cluster", "?1");

  // HSTS (production HTTPS only)
  if (enableHsts && isProd && request.nextUrl.protocol === "https:") {
    response.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
  }

  return response;
}

Content Security Policy (CSP)

The codebase uses a hybrid CSP strategy: Marketing app uses static CSP with 'unsafe-inline' for CDN caching compatibility, while Dashboard app uses nonce-based CSP with 'strict-dynamic' for maximum security.

Nonce-based CSP requires per-request nonce generation, which breaks CDN caching. Marketing pages prioritize cacheable static CSP since they're public and unauthenticated.

Dashboard CSP (Nonce-Based)

Every request generates a unique nonce for script and style tags:

export function buildDynamicCsp(opts: {
  nonce: string;
  reportUri: string;
  isProd: boolean;
}): string {
  const { nonce, reportUri, isProd } = opts;

  const nonceSource = `'nonce-${nonce}'`;

  const scriptSrc = [
    "'self'",
    nonceSource,
    "'strict-dynamic'",
    "'report-sample'",
  ];

  if (!isProd) {
    scriptSrc.push("'unsafe-eval'"); // Next.js dev server
  }

  const styleSrc = isProd
    ? ["'self'", nonceSource, "https://fonts.googleapis.com"]
    : ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"];

  return serializeCsp([
    ["default-src", ["'self'"]],
    ["base-uri", ["'none'"]],
    ["frame-ancestors", ["'none'"]],
    ["object-src", ["'none'"]],
    ["script-src", scriptSrc],
    ["style-src", styleSrc],
    ["img-src", ["'self'", "data:", "blob:", "https://lh3.googleusercontent.com"]],
    ["connect-src", ["'self'", "https://*.convex.cloud", "wss://*.convex.cloud"]],
    ["font-src", ["'self'", "https://fonts.gstatic.com", "data:"]],
  ]);
}

Emitted policy:

script-src 'self' 'nonce-abc123' 'strict-dynamic';
style-src 'self' 'nonce-abc123' https://fonts.googleapis.com;
default-src 'self';
frame-ancestors 'none';

Marketing CSP (Static)

Simple static policy that allows inline scripts:

export function buildStaticCsp(opts: {
  reportUri: string;
  isProd: boolean;
}): string {
  const { reportUri, isProd } = opts;

  const scriptSrc = [
    "'self'",
    "'unsafe-inline'",
    ...(isProd ? [] : ["'unsafe-eval'"]),
  ];

  return serializeCsp([
    ["default-src", ["'self'"]],
    ["script-src", scriptSrc],
    ["style-src", ["'self'", "'unsafe-inline'"]],
    // ... other directives
  ]);
}

Emitted policy:

script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
default-src 'self';

HSTS Configuration

Strict-Transport-Security (HSTS) tells browsers to always use HTTPS. Once enabled, browsers refuse HTTP connections for the specified duration.

Environment Setup

ENABLE_HSTS=1
VERCEL_ENV=production

Generated header:

Strict-Transport-Security: max-age=31536000; includeSubDomains
ENABLE_HSTS=1
HSTS_PRELOAD=1
VERCEL_ENV=production

Generated header:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
# HSTS disabled for preview/staging
# ENABLE_HSTS=0 (or omit)

No HSTS header emitted.

Activation Requirements

HSTS only activates when all three conditions are met:

  1. ENABLE_HSTS=1 environment variable set
  2. VERCEL_ENV=production (or NODE_ENV=production)
  3. Request protocol is https:
const enableHsts = process.env.ENABLE_HSTS === "1";
const vercelEnv = process.env.VERCEL_ENV;
const isProductionEnv = vercelEnv ? vercelEnv === "production" : isProd;

if (enableHsts && isProductionEnv && request.nextUrl.protocol === "https:") {
  response.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
}

Heads up

HSTS is cached by browsers for max-age seconds (1 year). Setting it locks users into HTTPS even if you later disable it. Test thoroughly before enabling.

Deployment Strategy

Verify HTTPS Coverage

Ensure all domains and subdomains serve HTTPS:

curl -I https://example.com
curl -I https://www.example.com
curl -I https://api.example.com

All must return 200 with valid certificates.

Enable in Production

Set environment variables:

ENABLE_HSTS=1
VERCEL_ENV=production

Deploy and verify the header appears on HTTPS requests.

Submit for Preload (Optional)

After 3+ months of stable HSTS:

  1. Set HSTS_PRELOAD=1
  2. Submit at hstspreload.org
  3. Wait 3-6 months for browser inclusion

Cache Controls

Cache headers prevent user data leaks and optimize CDN performance.

User Data (Never Cache)

Use secureUserJson() for user-specific responses:

import { secureUserJson } from "@workspace/security";

export async function GET() {
  const user = await getCurrentUser();

  return secureUserJson({
    id: user.id,
    email: user.email,
    settings: user.settings,
  });
}

Headers applied:

Cache-Control: private, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
Vary: Cookie, Authorization
Content-Type: application/json; charset=utf-8
X-Content-Type-Options: nosniff

Public Data (CDN-Friendly)

Use securePublicJson() with configurable TTL:

import { securePublicJson } from "@workspace/security";

export async function GET() {
  const stats = await getPublicStats();

  return securePublicJson(stats, { maxAge: 300 }); // 5 minutes
}

Headers applied:

Cache-Control: public, max-age=300, s-maxage=300, stale-while-revalidate=60
Vary: Accept, Accept-Encoding, Origin
Content-Type: application/json; charset=utf-8
X-Content-Type-Options: nosniff

Cache duration options:

  • maxAge: 60 - Frequently changing data (1 minute)
  • maxAge: 300 - Standard content (5 minutes)
  • maxAge: 3600 - Stable resources (1 hour)

Page-Level Cache Control

Protected pages require explicit cache disabling:

// Disable all caching for authenticated pages
export const revalidate = 0;
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";

export default async function DashboardPage() {
  const session = await getCurrentSession();
  // Render user-specific content
}

CSP Implementation

Dashboard Policy (Nonce-Based)

Maximum security for authenticated routes using per-request nonces:

function createNonce(): string {
  const bytes = new Uint8Array(16);
  crypto.getRandomValues(bytes);
  const binary = String.fromCharCode(...bytes);
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

export default function middleware(request: NextRequest) {
  const nonce = createNonce();
  const csp = buildDynamicCsp({ nonce, reportUri: extractReportUri(request), isProd });

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

  const response = NextResponse.next({ request: { headers: reqHeaders } });
  response.headers.set("Content-Security-Policy", csp);
  response.headers.set("x-nonce", nonce);

  return response;
}

Key directives:

script-src 'self' 'nonce-abc123' 'strict-dynamic';
style-src 'self' 'nonce-abc123';
style-src-attr 'unsafe-inline';  // For motion/animation styles
default-src 'self';
frame-ancestors 'none';
base-uri 'none';

Pro Tip

Use the nonce in React components:

const headersList = await headers();
const nonce = headersList.get("x-nonce");

<script src="/analytics.js" nonce={nonce} />

Marketing Policy (Static)

Simpler policy for public pages:

export default function middleware(request: NextRequest) {
  const reportUri = extractReportUri(request);
  const csp = buildStaticCsp({ reportUri, isProd });

  const response = NextResponse.next();
  response.headers.set("Content-Security-Policy", csp);

  return response;
}

Key directives:

script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
default-src 'self';
frame-ancestors 'none';

Extending CSP

Add new domains to CSP builders in packages/app-shell/src/security/csp.ts:

// Image sources
const IMG_SRC = [
  "'self'",
  "data:",
  "blob:",
  "https://lh3.googleusercontent.com",
  "https://avatars.githubusercontent.com",
  "https://your-cdn.example.com", // Add here
];

// API/WebSocket sources
const CONNECT_SRC_BASE = [
  "'self'",
  "https://*.convex.cloud",
  "wss://*.convex.cloud",
  "https://api.example.com", // Add here
];

Heads up

After modifying CSP sources, test thoroughly in development. CSP violations appear in browser console and /api/csp-report endpoint.

API Response Headers

API routes don't receive middleware headers. Use security helpers from @workspace/security:

secureUserJson

For user-specific data that must never cache:

import { secureUserJson } from "@workspace/security";

export async function GET() {
  const settings = await getUserSettings();

  return secureUserJson({ settings });
}

Headers:

Content-Type: application/json; charset=utf-8
Cache-Control: private, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
Vary: Cookie, Authorization
X-Content-Type-Options: nosniff

securePublicJson

For truly public data that can be cached:

import { securePublicJson } from "@workspace/security";

export async function GET() {
  const config = await getPublicConfig();

  return securePublicJson(config, { maxAge: 3600 });
}

Headers:

Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=3600, s-maxage=3600, stale-while-revalidate=600
Vary: Accept, Accept-Encoding, Origin
X-Content-Type-Options: nosniff

secureErrorJson

For error responses:

import { secureErrorJson } from "@workspace/security";

export async function GET() {
  try {
    const data = await fetchData();
    return secureUserJson({ data });
  } catch (error) {
    return secureErrorJson(
      { message: "Failed to fetch data", code: "FETCH_ERROR" },
      { status: 500, userScoped: true }
    );
  }
}

Headers:

Content-Type: application/json; charset=utf-8
Cache-Control: private, no-store, max-age=0, must-revalidate
Vary: Cookie, Authorization  // Only when userScoped: true
X-Content-Type-Options: nosniff

Header Explanations

Testing Headers

# Test page headers
curl -I https://localhost:3001/dashboard

# Test API headers
curl -I https://localhost:3001/api/user

# Check HSTS (production only)
curl -I https://example.com | grep Strict-Transport

# Verify CSP
curl -I https://localhost:3001/dashboard | grep Content-Security-Policy
import { expect, test } from "vitest";

test("dashboard pages have CSP with nonce", async () => {
  const response = await fetch("http://localhost:3001/dashboard");
  const csp = response.headers.get("Content-Security-Policy");

  expect(csp).toContain("nonce-");
  expect(csp).toContain("strict-dynamic");
});

test("user API never caches", async () => {
  const response = await fetch("/api/user", {
    headers: { Authorization: "Bearer token" },
  });

  expect(response.headers.get("Cache-Control")).toContain("private, no-store");
  expect(response.headers.get("Vary")).toContain("Cookie");
});

test("public API allows caching", async () => {
  const response = await fetch("/api/public/stats");

  expect(response.headers.get("Cache-Control")).toContain("public");
  expect(response.headers.get("Cache-Control")).toContain("max-age");
});
  1. Open Chrome DevTools → Network
  2. Load a page/API
  3. Click the request
  4. View "Headers" tab → "Response Headers"
  5. Verify security headers are present
  6. Check for CSP violations in Console tab

Common Issues