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:
Header | Value | Purpose |
---|---|---|
Content-Security-Policy | Dynamic (see CSP section) | Restricts resource loading to approved origins |
Referrer-Policy | strict-origin-when-cross-origin | Limits referer information on cross-origin requests |
X-Content-Type-Options | nosniff | Prevents MIME type sniffing |
Permissions-Policy | camera=(), microphone=(), ... | Disables unused browser features |
Origin-Agent-Cluster | ?1 | Isolates origin in browser process |
X-DNS-Prefetch-Control | off | Disables DNS prefetching for privacy |
X-Permitted-Cross-Domain-Policies | none | Blocks Flash cross-domain policies |
Strict-Transport-Security | Conditional | Only with ENABLE_HSTS=1 on HTTPS |
Cache-Control | no-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:
ENABLE_HSTS=1
environment variable setVERCEL_ENV=production
(orNODE_ENV=production
)- 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:
- Set
HSTS_PRELOAD=1
- Submit at hstspreload.org
- 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");
});
- Open Chrome DevTools → Network
- Load a page/API
- Click the request
- View "Headers" tab → "Response Headers"
- Verify security headers are present
- Check for CSP violations in Console tab
Common Issues
Related Documentation
- CORS & CSRF - Cross-origin protection
- Rate Limiting - Endpoint throttling
- Overview - Security architecture