@enalmada/start-secure
v1.0.2
Published
Security header management for TanStack Start
Maintainers
Readme
@enalmada/start-secure
Security header management for TanStack Start applications with native nonce support.
Features
- 🔒 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)
Overview
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
Installation
bun add @enalmada/start-secureQuick Start
Step 1: Create CSP rules configuration
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",
},
];Step 2: Register CSP middleware
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' }
})
]
}));Step 3: Configure router with nonce
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.
API Reference
Middleware API (Recommended)
createCspMiddleware(config)
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' }
});createNonceGetter() ⚠️ REMOVED
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 } });
}generateNonce()
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="buildCspHeader(rules, nonce, isDev)
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-...' ..."Types
CspRule
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[];
}CspMiddlewareConfig
interface CspMiddlewareConfig {
rules?: CspRule[];
options?: SecurityOptions;
nonceGenerator?: () => string;
additionalHeaders?: Record<string, string>;
}Security Model
Scripts: Strict Nonce-based CSP
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)
Styles: Pragmatic Approach
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.
CSP Level 3 Support
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 warningsExamples
Multiple Service Rules
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',
},
});Custom Nonce Generator
import { createCspMiddleware } from '@enalmada/start-secure';
const middleware = createCspMiddleware({
rules: [...],
nonceGenerator: () => {
// Custom nonce generation logic
return customCryptoFunction();
},
});Additional Headers
import { createCspMiddleware } from '@enalmada/start-secure';
const middleware = createCspMiddleware({
rules: [...],
additionalHeaders: {
'X-Custom-Header': 'value',
'X-Powered-By': 'My App',
},
});Default Security Headers
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=(), ...Migration from Handler Wrapper Pattern
If you're using the old createSecureHandler API, here's how to migrate:
Before (Handler Wrapper - Deprecated)
// 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))
};After (Middleware Pattern - Recommended)
// 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);Benefits of Middleware Pattern
- ✅ 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
Legacy API (Handler Wrapper)
The old handler wrapper API is still available for backward compatibility but is deprecated. Please migrate to the middleware pattern for better security.
createSecureHandler(config) (Deprecated)
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
Contributing
Contributions are welcome! Please open an issue or PR.
License
MIT © Adam Lane
Credits
Inspired by @enalmada/next-secure.
