Skip to content

Enalmada/start-secure

@enalmada/start-secure

Security header management for TanStack Start applications with native nonce support.

npm version License: MIT

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-secure

Quick Start

💡 Complete Example: See @enalmada/tanstarter for a production-ready implementation with authentication, database, and full CSP integration.

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.

Working Example

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

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 defaults
  • config.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 merge
  • nonce: string - Nonce for this request
  • isDev: 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' to script-src only (for source maps and dev tools)
  • 'unsafe-eval' NOT added to script-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):

  1. User rules can target base directives (script-src, style-src)
  2. Sources are automatically copied to granular directives
  3. CSP Level 3 browsers check granular directives first
  4. Exception: 'unsafe-eval' is NOT copied from script-src to script-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 warnings

Examples

For a complete, production-ready implementation, see @enalmada/tanstarter.

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.

Related Projects

About

No description, website, or topics provided.

Resources

License

Code of conduct

Contributing

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •