Skip to content

Documentation

Enterprise Security Architecture

Comprehensive security system with runtime threat detection, automated response, and hardened Content Security Policy headers (SvelteKit-owned page CSP preserved, strict API/GraphQL policies).

9/22/2026
14 min read Edit on GitHub

SveltyCMS implements a multi-layered, enterprise-grade security system that combines build-time validation, runtime threat detection, and automated security response. The architecture provides comprehensive protection against XSS, CSRF, injection attacks, and sophisticated threat patterns.


Security Architecture Overview

Core Security Components

  1. Build-Time Security Validation - Static analysis and dependency security scanning
  2. Runtime Threat Detection - Real-time security event monitoring and correlation
  3. Nonce-Based Content Security Policy - Cryptographically secure XSS prevention
  4. Automated Security Response - Dynamic threat mitigation and blocking
  5. Security Metrics & Analytics - Comprehensive security monitoring dashboard

Runtime Security Services

SecurityResponseService - Automated Threat Detection

Purpose: Real-time threat analysis with automated response capabilities File: src/services/security/response-service.ts β€” singleton export securityResponseService (see the bottom of the file)

// src/services/security/response-service.ts:197
public async analyzeRequest(
  request: Request,
  clientIp: string,
  tenantId?: string,
  payloadSnapshot?: PayloadSnapshot,
): Promise<SecurityStatus>

How a request is judged:

  1. Blocklist short-circuit β€” securityStore.isBlockedSync(clientIp), with a distributed lookup only when securityStore.needsDistributedLookup().
  2. Throttle gate β€” an active securityStore.getThrottleSync(clientIp) entry returns { level: "medium", action: "throttle" } before any scan.
  3. Clean-surface fast path β€” read requests whose pathname and query pass isCleanRequestSurface() (@src/services/security/threat-scan) return allow without touching the rate limiters.
  4. Rate limiting β€” per-endpoint limits from the ENDPOINT_RATE_LIMITS table (/api/auth/login = 5, /api/graphql = 150, …) plus a global limiter, backed by RateLimiterMemory / RateLimiterRedis from rate-limiter-flexible.
  5. Payload analysis (analyzePayload, private) β€” recursive JSON scanning (scanRecursive) and value inspection (checkValue), capped at SCAN_BODY_MAX_SIZE (32 KB) with ReDoS-safe patterns.
  6. Policy mapping β€” the DEFAULT_POLICIES table (Moderate / High / Critical threat) derives the automated response; getActiveIncidents(tenantId) and getSecurityStats(tenantId) expose the result.

The private surface (analyzePayload, executeResponse, restoreStateSync) is internal β€” the public API is analyzeRequest(), getSecurityStats(), getActiveIncidents(), reset() and destroySync().

Key Features:

  • Distributed Rate Limiting: Redis-backed limits for high-availability clusters.
  • ReDoS-Safe Patterns: Bounded regex quantifiers and length-capped payload scanning.
  • Structured Analysis: Recursive JSON scanning and multipart value inspection.
  • Multi-Tenant Isolation: Automated security response scoped by tenantId.

MetricsService - Security Analytics

Purpose: Unified security and performance counters (per tenant) with on-demand reporting File: src/services/observability/metrics-service.ts β€” singleton export metricsService

// src/services/observability/metrics-service.ts:248 β€” security counters
incrementCSPViolations(tenantId?: string | null): void
incrementRateLimitViolations(tenantId?: string | null): void
incrementSecurityViolations(tenantId?: string | null): void

// src/services/observability/metrics-service.ts:291 β€” single report for all domains
metricsService.getReport(tenantId?: string | null): MetricsReport
// β†’ report.security.cspViolations, report.security.rateLimitViolations, …

The composed security view is GET /api/security/stats (src/routes/api/[...path]/handlers/system.ts:1384, admin-only), which merges securityResponseService.getSecurityStats() with metricsService.getReport(tenantId).security.


Content Security Policy (CSP) Implementation

File: src/hooks/handle-security-headers.ts β€” single export applyAllSecurityHeaders(headers, isHttps, origin, pathname).

There is intentionally no standalone security-headers Handle: the header set is always merged onto an already-produced response, because SvelteKit owns the page CSP. applyAllSecurityHeaders() is called inline from handleTurboPipeline (handle-turbo-pipeline.server.ts), handleTurboGet, the rate-limit 429 path (handle-rate-limit.ts), handle-collection-write-lane.ts, and the top-level error guard in hooks.server.ts.

What the Utility Sets

Branch CSP applied
Page routes Preserved β€” the existing SvelteKit Content-Security-Policy header is read back and re-set, never overwritten
/api/* (except GraphQL) API_CONTENT_SECURITY_POLICY from @utils/security/constants (default-src 'self'; frame-ancestors 'none'; …)
/api/graphql GRAPHQL_PLAYGROUND_CSP in dev/playground builds, GRAPHQL_PROD_CSP when NODE_ENV === "production"

The remaining headers are pre-compiled arrays (PRECOMPILED_API_STATIC_HEADERS, PRECOMPILED_PAGE_STATIC_HEADERS), so a request only pays for headers.set() calls:

  • X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin
  • Cross-Origin-Opener-Policy: same-origin plus Cross-Origin-Embedder-Policy β€” credentialless for /api/media/* and /mediagallery, otherwise require-corp
  • Permissions-Policy lockdown (geolocation/microphone/camera/display-capture denied)
  • Strict-Transport-Security when the request is HTTPS
  • CORS headers from the getCorsHeaders() allowlist (the Origin header is never reflected) and a de-duplicated Vary token (case-insensitive merge, addVaryHeader())

Key Features:

  • Framework-owned nonces: page CSP (and any nonce SvelteKit emits) is left untouched β€” middleware must not clobber it
  • Violation Reporting: browsers post to /api/security/csp-report (see below)
  • Defense in Depth: API vs page vs GraphQL header sets, plus isolation headers on every response
  • Production Hardening: HSTS enforcement for HTTPS-only environments

CSP Violation Handling

Purpose: Real-time CSP violation counting Handler: POST /api/security/csp-report in src/routes/api/[...path]/handlers/system.ts:1401 (routed through handleSecurityRoutes)

// src/routes/api/[...path]/handlers/system.ts:1401
if (action === "csp-report" && request.method === "POST") {
  const report = await request.json();
  const blockedUri = report["csp-report"]?.["blocked-uri"];
  const isFalsePositive =
    blockedUri?.startsWith("chrome-extension://") || blockedUri?.startsWith("moz-extension://");

  if (!isFalsePositive) {
    metricsService.incrementCSPViolations(tenantId as string);
    return rawResponse(event, { status: "received" });
  }

  return rawResponse(event, { status: "ignored" });
}

Extensions are filtered out before counting so that browser-extension injections do not skew report.security.cspViolations (read via GET /api/security/stats).


Build-Time Security Plugin

Overview

The Vite Security Check Plugin provides build-time protection against accidental exposure of private settings in client-side code. Unlike static environment variables (e.g., PRIVATE_* in SvelteKit), our dynamic settings loaded from the database don’t have built-in Vite protection. This plugin fills that gap.

The Problem

Static Environment Variables (Built-in Protection βœ…)

// .env file
PRIVATE_DB_PASSWORD=secret123  // βœ… Vite warns if used in client code
PUBLIC_API_URL=https://api.com  // βœ… Safe for client

SvelteKit’s PRIVATE_ prefix triggers build-time warnings if accidentally imported in .svelte files.

Dynamic Settings (No Built-in Protection ❌)

// Loaded from database at runtime
const settings = {
  DB_PASSWORD: "secret123", // ❌ No compile-time protection!
  JWT_SECRET_KEY: "abc...", // ❌ Could leak to browser
  ENCRYPTION_KEY: "xyz...", // ❌ Dangerous if imported in .svelte
};

Our database-driven settings lack this protection because they’re loaded at runtime, not compile-time.

The Solution

The plugin lives in src/utils/vite-plugin-security-check.ts and exports securityCheckPlugin(options). It scans code during Vite’s transform phase and fails the build (buildEnd) when it detects dangerous patterns.

🚨 Blocked Patterns

The plugin holds three error patterns (src/utils/vite-plugin-security-check.ts:40):

const DANGEROUS_PATTERNS = [
  {
    pattern: /import\s+{[^}]*privateEnv[^}]*}\s+from\s+['"]@src\/stores\/global-settings['"]/g,
    severity: "error",
    message:
      "SECURITY: Importing privateEnv in client-side code exposes database passwords, JWT secrets, and other sensitive data!",
  },
  {
    pattern: /getPrivateSetting\s*\(/g,
    severity: "error",
    message:
      "SECURITY: Using getPrivateSetting() in client-side code will attempt to access private settings!",
  },
  {
    pattern: /getAllSettings\s*\(/g,
    severity: "error",
    message:
      "SECURITY: getAllSettings() exposes ALL private settings including passwords and secrets!",
  },
];

In practice that means:

  1. privateEnv imported from the legacy settings store path (the first pattern is the only specifier-aware rule).
  2. Any getPrivateSetting( call in a scanned client file β€” e.g. import { getPrivateSetting } from "@src/services/core/settings-service" used inside a .svelte file.
  3. Any getAllSettings( call in a scanned client file β€” it returns private and public settings, so it is never safe on the client.

A fourth pattern is reported as a warning, not an error:

// src/utils/vite-plugin-security-check.ts:61
{ pattern: /getUntypedSetting\s*\([^)]*,\s*['"]private['"]\s*\)/g,
  message: 'WARNING: getUntypedSetting() with "private" scope may expose sensitive data' }

βœ… Safe Patterns

  1. Public settings in any file β€” the public store only ever holds non-sensitive values, typed against publicConfigSchema (src/stores/global-settings.svelte.ts):

    // βœ… SAFE - Public settings can be used anywhere
    import { publicEnv, getPublicSetting } from "@src/stores/global-settings.svelte";
    console.log(publicEnv.SITE_NAME); // Safe for browser
  2. Private settings in server-only files β€” privateEnv is the bootstrap export of config/private.ts (typed in src/types/config.d.ts), while DB-driven private settings come from @src/services/core/settings-service:

    // βœ… SAFE - Server-only files can use private settings
    // Files: +page.server.ts, +layout.server.ts, +server.ts, /hooks/, /auth/, /databases/
    import { privateEnv } from "@config/private";
    import { getPrivateSettingSync } from "@src/services/core/settings-service";
    
    const dbPassword = privateEnv.DB_PASSWORD; // Never sent to client
    const smtpHost = getPrivateSettingSync("SMTP_HOST");
  3. Passing data via page.data

    <!-- +page.server.ts (server-only) -->
    export async function load() {
      const dbStatus = await checkDatabase(getPrivateSettingSync("DB_HOST"));
      return {
        isConnected: dbStatus.connected, // Only pass safe data
        // Never return private settings directly!
      };
    }
    <!-- +page.svelte (client) -->
    <script lang="ts">
      let { data } = $props();
    </script>
    
    <p>Connected: {data.isConnected}</p>

Configuration

The plugin is already wired into vite.config.ts with its defaults:

// vite.config.ts:29
import { securityCheckPlugin } from "./src/utils/vite-plugin-security-check.ts";

// vite.config.ts:1169
plugins: [securityCheckPlugin()];

The optional arguments (defaults shown) are:

securityCheckPlugin({
  failOnError: true, // Fail build on violations (recommended)
  showWarnings: true, // Report suspicious patterns
  extensions: [".svelte", ".ts", ".js"], // Files to check
});

How It Works

1. File Scanning

The plugin is declared with enforce: "pre" and checks files inside Vite’s transform hook β€” the report itself is emitted once in buildEnd.

2. Smart Filtering

Not all .ts files are client-side! The checker skips (in this order, src/utils/vite-plugin-security-check.ts:68):

  • Build + vendor output: /.svelte-kit/, /node_modules/
  • Server-only route files: +page.server., +layout.server., +server.
  • Server-only hooks: /hooks.server., /hooks/
  • API routes and services: /routes/api/, /services/, /databases/
  • Store modules: .ts files under /stores/ that are not .svelte.ts
  • Any .ts/.js file outside the client directories /src/components/, /src/routes/(app), /src/routes/(unauthenticated)

Before running any regex it also performs a keyword pre-scan (privateEnv, getPrivateSetting, getAllSettings, getUntypedSetting) so files without a keyword are rejected in O(1).

3. Pattern Detection

Patterns are applied only to the remaining client-side files; each match collects the relative file path, the line number and the offending source line.

4. Build Failure

If violations were collected, the build fails with a detailed error report:

🚨 SECURITY VIOLATIONS DETECTED!

Found 2 critical security issue(s):

  βœ— src/components/Settings.svelte:5
    SECURITY: Using getPrivateSetting() in client-side code will attempt to access private settings!
    β†’ const secret = getPrivateSetting('JWT_SECRET_KEY');

  βœ— src/routes/(app)/admin/+page.svelte:12
    SECURITY: getAllSettings() exposes ALL private settings including passwords and secrets!
    β†’ const all = await getAllSettings();

These violations expose sensitive data to the client!
Fix these issues before deploying to production.

How to fix:
  1. Remove privateEnv imports from .svelte files
  2. Use page.data from +page.server.ts load functions instead
  3. Only access private settings in +page.server.ts, +layout.server.ts, or +server.ts files

With failOnError: true the plugin then throws Build failed due to N security violation(s); with no findings it logs βœ“ Security check passed: No private settings exposed to client.

Real-World Example

❌ Vulnerable Code

<!-- src/routes/(app)/settings/+page.svelte -->
<script lang="ts">
  import { getPrivateSettingSync } from '@src/services/core/settings-service';

  // 🚨 CRITICAL VULNERABILITY: private settings reach the browser bundle!
  // Browser DevTools can access:
  // - DB_PASSWORD
  // - JWT_SECRET_KEY
  // - ENCRYPTION_KEY
  // - SMTP_PASSWORD
  // - GOOGLE_CLIENT_SECRET
  // etc.

  const dbInfo = {
    host: getPrivateSettingSync('DB_HOST'),
    password: getPrivateSettingSync('DB_PASSWORD') // Visible in browser source!
  };
</script>

<div>Database: {dbInfo.host}</div>

Impact: Complete credential exposure, allows full system compromise. The getPrivateSetting( rule above fails this build.

βœ… Secure Code

// src/routes/(app)/settings/+page.server.ts
import { privateEnv } from "@config/private";
import { getPrivateSettingSync } from "@src/services/core/settings-service";
import type { PageServerLoad } from "./$types";

export const load: PageServerLoad = async () => {
  // βœ… Server-only: private settings never sent to client
  const isConnected = await testConnection(privateEnv.DB_HOST, privateEnv.DB_PASSWORD);

  return {
    // Only return safe, non-sensitive data
    dbStatus: isConnected ? "connected" : "disconnected",
    dbHost: privateEnv.DB_HOST, // Host is not sensitive
    sessionTtlHours: getPrivateSettingSync("SESSION_TTL_HOURS"),
    // Never return: DB_PASSWORD, JWT_SECRET_KEY, etc.
  };
};
<!-- src/routes/(app)/settings/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types';
  let { data }: { data: PageData } = $props();
</script>

<div>Database Status: {data.dbStatus}</div>
<div>Host: {data.dbHost}</div>

Best Practices

1. Server-Side Data Loading

Always load private settings in +page.server.ts and pass only necessary, safe data to the client via page.data.

2. Minimal Data Exposure

Only return the absolute minimum data needed. Never pass entire settings objects.

// ❌ BAD
export const load = async () => {
  return { settings: privateEnv }; // ALL secrets exposed!
};

// βœ… GOOD
export const load = async () => {
  return {
    siteName: publicEnv.SITE_NAME,
    sessionTtlHours: getPrivateSettingSync("SESSION_TTL_HOURS"),
  };
};

3. Type Safety

Use TypeScript to enforce safe data contracts:

// src/routes/(app)/admin/types.ts
export interface SafeAdminData {
  dbConnected: boolean;
  sessionTtlHours: number;
  // No password fields allowed!
}

// +page.server.ts
import { getPrivateSettingSync } from "@src/services/core/settings-service";

export const load = async (): Promise<SafeAdminData> => {
  return {
    dbConnected: await isDbConnected(),
    sessionTtlHours: getPrivateSettingSync("SESSION_TTL_HOURS") ?? 0,
  };
};

4. Code Review Checklist

Before committing, verify:

  • βœ… No privateEnv imports in .svelte files
  • βœ… No getPrivateSetting() calls in client code
  • βœ… All sensitive operations in +page.server.ts or +server.ts
  • βœ… page.data only contains safe, non-sensitive information
  • βœ… Build passes without security violations

Integration with CI/CD

The plugin automatically runs during your build process:

# Development
bun run dev      # No check during dev (for speed)

# Production build
bun run build    # βœ… Security check runs and fails on violations

# CI/CD (GitHub Actions, etc.)
- run: bun run build
  # Build will fail if security violations detected

Disabling (Not Recommended)

If you need to temporarily disable for debugging:

// vite.config.ts
securityCheckPlugin({
  failOnError: false, // Show warnings but don't fail build
  showWarnings: true,
});

⚠️ Warning: Never deploy to production with failOnError: false!

Related Documentation


Security Dashboard Integration

Real-Time Security Monitoring

Security Monitor widget β€” dashboard widget with id security (widget.json), component src/routes/(app)/dashboard/widgets/security/index.svelte.

The widget does not poll raw metrics itself: it hands its endpoint to BaseWidget, which owns the fetch, the pollInterval (8000 ms) and the loading/error states of the card.

<!-- src/routes/(app)/dashboard/widgets/security/index.svelte:123 -->
<BaseWidget
  {label}
  {theme}
  endpoint="/api/dashboard/security"
  pollInterval={8000}
  {icon}
  {widgetId}
  {size}
  {onSizeChange}
  onCloseRequest={onRemove}
  onDataLoaded={handleDataLoaded}
>
  {#snippet children({ data })}
    {const statsData = data?.stats as SecurityStats | null}
    {const activeIncidents = data?.incidents || []}
  {/snippet}
</BaseWidget>

The payload comes from the security branch of handleDashboardRoutes (src/routes/api/[...path]/handlers/dashboard.ts:618), which composes two services rather than reading storage directly:

// src/routes/api/[...path]/handlers/dashboard.ts:618
const { securityResponseService } = await import("@src/services/security/response-service");
const [statsRes, activeIncidents] = await Promise.all([
  securityResponseService.getSecurityStats(tenantId || undefined),
  securityResponseService.getActiveIncidents(tenantId || undefined),
]);
const report = metricsService.getReport(tenantId as string);

return rawResponse(event, {
  stats: {
    activeIncidents: statsRes.activeIncidents,
    blockedIPs,
    throttledIPs,
    cspViolations: report.security.cspViolations,
    rateLimitHits: report.security.rateLimitViolations,
    totalIncidents: statsRes.totalIncidentsLast24h,
    threatLevelDistribution: statsRes.threatDistribution,
  },
  incidents: activeIncidents.map(/* id, clientIp, threatLevel, timestamp, responseActions */),
});

Security API Endpoints

Comprehensive Security Management API

All security actions live in one place β€” handleSecurityRoutes() in src/routes/api/[...path]/handlers/system.ts β€” reached through the fail-closed dispatcher entry security: { handler: "system", fn: "handleSystemRoutes" }.

Security Stats Endpoint (admin-only)

// src/routes/api/[...path]/handlers/system.ts:1384
if (action === "stats" && request.method === "GET") {
  if (!locals.user || locals.user.role !== "admin") {
    throw new AppError("Forbidden", 403, "FORBIDDEN");
  }
  const { securityResponseService } = await import("@src/services/security/response-service");
  const { metricsService } = await import("@src/services/observability/metrics-service");

  const stats = await securityResponseService.getSecurityStats();
  const report = metricsService.getReport(tenantId as string);

  return rawResponse(event, {
    ...stats,
    overallStatus: stats.activeIncidents > 0 ? "warning" : "healthy",
    metrics: report.security,
  });
}

CSP Report Endpoint (public, counted per tenant)

POST /api/security/csp-report β€” see CSP Violation Handling above; the handler rejects unknown actions with Security action <action> not implemented (404).

There is no /api/security/events SSE stream and no dedicated security-widget.svelte: live updates come from the dashboard widget’s pollInterval on /api/dashboard/security.


Enterprise Security Features

Advanced Threat Detection

  • Behavioral Analysis: Pattern recognition across user sessions
  • Anomaly Detection: Statistical analysis of request patterns
  • Geographic Filtering: IP geolocation-based threat assessment
  • Device Fingerprinting: Hardware-based threat correlation

Automated Response Capabilities

  • Dynamic Rate Limiting: Adaptive throttling based on threat levels
  • IP Blocking: Temporary and permanent IP blacklisting
  • Session Termination: Automatic logout for compromised accounts
  • Alert Escalation: Real-time notifications to security teams

Compliance & Audit

  • Security Event Logging: Comprehensive audit trail
  • Compliance Reporting: GDPR, SOC2, ISO 27001 compliance support
  • Forensic Analysis: Detailed attack pattern analysis
  • Performance Impact: Minimal overhead security operations

Summary

The enterprise security architecture provides comprehensive protection through:

  1. βœ… Build-Time Validation: Static analysis prevents vulnerabilities before deployment (src/utils/vite-plugin-security-check.ts)
  2. βœ… Runtime Threat Detection: Real-time analysis with automated response (src/services/security/response-service.ts)
  3. βœ… CSP & Hardened Headers: SvelteKit-owned page CSP preserved, strict API/GraphQL policies, violation counting (src/hooks/handle-security-headers.ts)
  4. βœ… Automated Response: Dynamic threat mitigation without manual intervention (DEFAULT_POLICIES, rate limiters, blocklists)
  5. βœ… Enterprise Monitoring: Security Monitor dashboard widget plus GET /api/security/stats and metricsService
  6. βœ… Credentials Protection: Build-time enforcement prevents sensitive data exposure

securitycspthreat-detectionxss-preventionenterpriseautomated-responsereal-time-monitoring
Was this page helpful?