Security header management for TanStack Start applications with native nonce support.
- 🔒 Secure defaults (strict CSP, security headers)
- 🎯 Type-safe CSP rule definitions
- 🔄 Automatic CSP rule merging and deduplication
- 🛠️ Development mode support (HMR, eval, WebSocket)
- 📝 Rule descriptions for documentation
- 🔐 Native per-request nonce generation
- ⚡ Middleware pattern for TanStack Start
- 🎯 Official TanStack pattern (direct context access)
- 🚀 Minimal setup (~20 lines)
TanStack Start has native nonce support via router.options.ssr.nonce. This package provides:
- Per-request nonce generation - Unique cryptographic nonce for each request
- Middleware pattern - Integrates with TanStack Start's global middleware system
- No
'unsafe-inline'for scripts - Strict CSP in production (scripts only, styles remain pragmatic) - Automatic nonce application - TanStack router applies nonces to all framework scripts
- Direct context access - Official TanStack pattern (no broken wrappers)
Reference: TanStack Router Discussion #3028
bun add @enalmada/start-secure💡 Complete Example: See @enalmada/tanstarter for a production-ready implementation with authentication, database, and full CSP integration.
File: src/config/cspRules.ts
import type { CspRule } from '@enalmada/start-secure';
export const cspRules: CspRule[] = [
{
description: 'google-auth',
'form-action': "'self' https://accounts.google.com",
'img-src': "https://*.googleusercontent.com",
'connect-src': "https://*.googleusercontent.com",
},
{
description: 'posthog-analytics',
'script-src': "https://*.posthog.com",
'connect-src': "https://*.posthog.com",
},
];File: src/start.ts
import { createStart } from '@tanstack/react-start';
import { createCspMiddleware } from '@enalmada/start-secure';
import { cspRules } from './config/cspRules';
export const startInstance = createStart(() => ({
requestMiddleware: [
createCspMiddleware({
rules: cspRules,
options: { isDev: process.env.NODE_ENV !== 'production' }
})
]
}));File: src/router.tsx
import { createRouter } from '@tanstack/react-router';
export async function getRouter() {
// Get nonce on server (client uses meta tag automatically)
let nonce: string | undefined;
if (typeof window === 'undefined') {
// Dynamic import for server-only code
const { getStartContext } = await import('@tanstack/start-storage-context');
const context = getStartContext();
nonce = context.contextAfterGlobalMiddlewares?.nonce;
}
const router = createRouter({
routeTree,
// ... other options
ssr: { nonce } // Applies nonce to all framework scripts
});
return router;
}Why this pattern?
- Direct context access (official TanStack pattern)
- No wrapper to break AsyncLocalStorage
- Works on both server and client
That's it! Total setup: ~20 lines of code.
See this pattern in action in @enalmada/tanstarter - a full-featured TanStack Start application demonstrating:
- ✅ Multi-service CSP rules (Google Auth, Sentry, PostHog)
- ✅ Nonce-based script security in production
- ✅ Development mode with HMR support
- ✅ Integration with authentication and database
- ✅ Complete type safety throughout
Creates CSP middleware for TanStack Start with per-request nonce generation.
Parameters:
config.rules?: CspRule[]- Array of CSP rules to merge with defaultsconfig.options.isDev?: boolean- Enable development mode (WebSocket, unsafe-eval, HTTPS/HTTP sources)config.nonceGenerator?: () => string- Custom nonce generator (optional, defaults to crypto-random)config.additionalHeaders?: Record<string, string>- Additional response headers to set
Returns: TanStack Start middleware
Example:
import { createCspMiddleware } from '@enalmada/start-secure';
const middleware = createCspMiddleware({
rules: [
{ description: 'google-fonts', 'font-src': 'https://fonts.gstatic.com' }
],
options: { isDev: process.env.NODE_ENV !== 'production' }
});This function has been removed due to a critical AsyncLocalStorage bug.
The isomorphic wrapper broke AsyncLocalStorage context chain, preventing nonce access. Use direct context access instead (see Quick Start above).
Migration: See MIGRATION-1.0-to-1.0.1.md
Correct pattern:
export async function getRouter() {
let nonce: string | undefined;
if (typeof window === 'undefined') {
const { getStartContext } = await import('@tanstack/start-storage-context');
nonce = getStartContext().contextAfterGlobalMiddlewares?.nonce;
}
return createRouter({ ssr: { nonce } });
}Generates a cryptographically secure random nonce for CSP.
Returns: Base64-encoded random nonce (128-bit from UUID)
Example:
import { generateNonce } from '@enalmada/start-secure';
const nonce = generateNonce();
// "Y2QxMjM0NTY3ODkwMTIzNDU2Nzg="Low-level utility to build CSP header string from rules and nonce.
Parameters:
rules: CspRule[]- CSP rules to mergenonce: string- Nonce for this requestisDev: boolean- Whether in development mode
Returns: CSP header string
Example:
import { buildCspHeader } from '@enalmada/start-secure';
const csp = buildCspHeader(rules, generateNonce(), false);
// "default-src 'self'; script-src 'self' 'nonce-...' ..."interface CspRule {
description?: string; // Document why this rule exists
source?: string; // Reserved for future use
// CSP directives - all optional, support both string and string[]
'base-uri'?: string | string[];
'child-src'?: string | string[];
'connect-src'?: string | string[];
'default-src'?: string | string[];
'font-src'?: string | string[];
'form-action'?: string | string[];
'frame-ancestors'?: string | string[];
'frame-src'?: string | string[];
'img-src'?: string | string[];
'manifest-src'?: string | string[];
'media-src'?: string | string[];
'object-src'?: string | string[];
'script-src'?: string | string[];
'script-src-attr'?: string | string[];
'script-src-elem'?: string | string[];
'style-src'?: string | string[];
'style-src-attr'?: string | string[];
'style-src-elem'?: string | string[];
'worker-src'?: string | string[];
}interface CspMiddlewareConfig {
rules?: CspRule[];
options?: SecurityOptions;
nonceGenerator?: () => string;
additionalHeaders?: Record<string, string>;
}Production:
script-src 'nonce-XXX' 'strict-dynamic'
script-src-elem 'nonce-XXX' 'strict-dynamic'
- ✅ Unique nonce per request
- ✅
'strict-dynamic'allows nonce-verified scripts to load other scripts - ✅ No
'self','unsafe-inline', or URL whitelists (ignored by'strict-dynamic') - ✅ No inline scripts without nonce
Development:
script-src 'nonce-XXX' 'strict-dynamic' 'unsafe-eval'
script-src-elem 'nonce-XXX' 'strict-dynamic'
- Adds
'unsafe-eval'toscript-srconly (for source maps and dev tools) 'unsafe-eval'NOT added toscript-src-elem(causes browser warning)
style-src 'self' 'unsafe-inline'
style-src-elem 'self' 'unsafe-inline'
style-src-attr 'unsafe-inline'
Why 'unsafe-inline' for styles:
- React hydration injects styles before nonce available
- Vite HMR injects styles dynamically
- CSS-in-JS libraries generate runtime styles
- Tailwind and other frameworks inject dynamic styles
- Trade-off: Styles cannot execute code (low XSS risk)
This is the industry-standard approach used by GitHub, Google, and other major sites.
The package properly handles granular directives (-elem, -attr):
- User rules can target base directives (
script-src,style-src) - Sources are automatically copied to granular directives
- CSP Level 3 browsers check granular directives first
- Exception:
'unsafe-eval'is NOT copied fromscript-srctoscript-src-elem(prevents browser warning)
How it works:
// Base directives (user or default)
script-src 'nonce-XXX' 'strict-dynamic' 'unsafe-eval' // (dev mode)
// Automatically copied to granular directive (minus unsafe-eval)
script-src-elem 'nonce-XXX' 'strict-dynamic' // No unsafe-eval here
// Result: Zero browser warningsFor a complete, production-ready implementation, see @enalmada/tanstarter.
import { createCspMiddleware } from '@enalmada/start-secure';
const middleware = createCspMiddleware({
rules: [
{
description: 'google-auth',
'form-action': "'self' https://accounts.google.com",
'img-src': "https://*.googleusercontent.com",
'connect-src': "https://*.googleusercontent.com",
},
{
description: 'sentry-monitoring',
'worker-src': "blob:",
'connect-src': "https://*.ingest.sentry.io",
},
{
description: 'posthog-analytics',
'script-src': "https://*.posthog.com",
'connect-src': "https://*.posthog.com",
},
],
options: {
isDev: process.env.NODE_ENV !== 'production',
},
});import { createCspMiddleware } from '@enalmada/start-secure';
const middleware = createCspMiddleware({
rules: [...],
nonceGenerator: () => {
// Custom nonce generation logic
return customCryptoFunction();
},
});import { createCspMiddleware } from '@enalmada/start-secure';
const middleware = createCspMiddleware({
rules: [...],
additionalHeaders: {
'X-Custom-Header': 'value',
'X-Powered-By': 'My App',
},
});The middleware automatically sets these security headers:
Content-Security-Policy: (built from rules + nonce)
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-XSS-Protection: 1; mode=block
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload (production only)
Permissions-Policy: camera=(), microphone=(), geolocation=(), ...
If you're using the old createSecureHandler API, here's how to migrate:
// src/server.ts
import { createSecureHandler } from '@enalmada/start-secure';
const secureHandler = createSecureHandler({
rules: cspRules,
options: { isDev: process.env.NODE_ENV !== 'production' }
});
export default {
fetch: secureHandler(createStartHandler(defaultStreamHandler))
};// src/start.ts (NEW FILE)
import { createStart } from '@tanstack/react-start';
import { createCspMiddleware } from '@enalmada/start-secure';
export const startInstance = createStart(() => ({
requestMiddleware: [
createCspMiddleware({ rules: cspRules, options: { isDev: process.env.NODE_ENV !== 'production' } })
]
}));
// src/router.tsx (UPDATED)
export async function getRouter() {
let nonce: string | undefined;
if (typeof window === 'undefined') {
const { getStartContext } = await import('@tanstack/start-storage-context');
nonce = getStartContext().contextAfterGlobalMiddlewares?.nonce;
}
return createRouter({ ssr: { nonce } });
}
// src/server.ts (SIMPLIFIED)
const fetch = createStartHandler(defaultStreamHandler);- ✅ Per-request nonce generation (not static)
- ✅ No
'unsafe-inline'for scripts in production - ✅ Integrates with TanStack router nonce support
- ✅ Automatic nonce in all framework scripts
- ✅ Cleaner, more maintainable code
The old handler wrapper API is still available for backward compatibility but is deprecated. Please migrate to the middleware pattern for better security.
import { createSecureHandler } from '@enalmada/start-secure';
const secureHandler = createSecureHandler({
rules: [
{ 'connect-src': 'https://api.example.com' }
],
options: {
isDev: process.env.NODE_ENV !== 'production'
}
});
export default {
fetch: secureHandler(createStartHandler(defaultStreamHandler))
};Limitations:
- ❌ Headers generated once at startup (no per-request nonces)
- ❌ Falls back to
'unsafe-inline'for scripts - ❌ Doesn't integrate with TanStack router
Contributions are welcome! Please open an issue or PR.
MIT © Adam Lane
Inspired by @enalmada/next-secure.
- @enalmada/tanstarter - Production-ready TanStack Start template with start-secure integration
- @enalmada/next-secure - Security headers for Next.js applications