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).
On this page
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
- Build-Time Security Validation - Static analysis and dependency security scanning
- Runtime Threat Detection - Real-time security event monitoring and correlation
- Nonce-Based Content Security Policy - Cryptographically secure XSS prevention
- Automated Security Response - Dynamic threat mitigation and blocking
- 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:
- Blocklist short-circuit β
securityStore.isBlockedSync(clientIp), with a distributed lookup only whensecurityStore.needsDistributedLookup(). - Throttle gate β an active
securityStore.getThrottleSync(clientIp)entry returns{ level: "medium", action: "throttle" }before any scan. - Clean-surface fast path β read requests whose pathname and query pass
isCleanRequestSurface()(@src/services/security/threat-scan) returnallowwithout touching the rate limiters. - Rate limiting β per-endpoint limits from the
ENDPOINT_RATE_LIMITStable (/api/auth/login= 5,/api/graphql= 150, β¦) plus a global limiter, backed byRateLimiterMemory/RateLimiterRedisfromrate-limiter-flexible. - Payload analysis (
analyzePayload, private) β recursive JSON scanning (scanRecursive) and value inspection (checkValue), capped atSCAN_BODY_MAX_SIZE(32 KB) with ReDoS-safe patterns. - Policy mapping β the
DEFAULT_POLICIEStable (Moderate / High / Critical threat) derives the automated response;getActiveIncidents(tenantId)andgetSecurityStats(tenantId)expose the result.
The private surface (
analyzePayload,executeResponse,restoreStateSync) is internal β the public API isanalyzeRequest(),getSecurityStats(),getActiveIncidents(),reset()anddestroySync().
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-originCross-Origin-Opener-Policy: same-originplusCross-Origin-Embedder-Policyβcredentiallessfor/api/media/*and/mediagallery, otherwiserequire-corpPermissions-Policylockdown (geolocation/microphone/camera/display-capture denied)Strict-Transport-Securitywhen the request is HTTPS- CORS headers from the
getCorsHeaders()allowlist (theOriginheader is never reflected) and a de-duplicatedVarytoken (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:
privateEnvimported from the legacy settings store path (the first pattern is the only specifier-aware rule).- Any
getPrivateSetting(call in a scanned client file β e.g.import { getPrivateSetting } from "@src/services/core/settings-service"used inside a.sveltefile. - 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
-
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 -
Private settings in server-only files β
privateEnvis the bootstrap export ofconfig/private.ts(typed insrc/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"); -
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:
.tsfiles under/stores/that are not.svelte.ts - Any
.ts/.jsfile 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
privateEnvimports in.sveltefiles - β
No
getPrivateSetting()calls in client code - β
All sensitive operations in
+page.server.tsor+server.ts - β
page.dataonly 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
- Authentication System - 3-Layer Security Architecture
- Settings System - Managing system settings
- Environment Variables - Static vs dynamic settings
- Server Hooks Architecture - Enterprise middleware pipeline
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/eventsSSE stream and no dedicatedsecurity-widget.svelte: live updates come from the dashboard widgetβspollIntervalon/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:
- β
Build-Time Validation: Static analysis prevents vulnerabilities before deployment (
src/utils/vite-plugin-security-check.ts) - β
Runtime Threat Detection: Real-time analysis with automated response (
src/services/security/response-service.ts) - β
CSP & Hardened Headers: SvelteKit-owned page CSP preserved, strict API/GraphQL policies, violation counting (
src/hooks/handle-security-headers.ts) - β
Automated Response: Dynamic threat mitigation without manual intervention (
DEFAULT_POLICIES, rate limiters, blocklists) - β
Enterprise Monitoring: Security Monitor dashboard widget plus
GET /api/security/statsandmetricsService - β Credentials Protection: Build-time enforcement prevents sensitive data exposure