security/handler-state-write · Handler writes imported state
A load function or action writes to imported module state, shared across all requests on the server.
Severity: critical · Category: security
What it checks
Flags writes to an imported binding from inside a server-executed handler, meaning load, a form action, a +server HTTP handler, or a hooks.server handler. The writes it looks for are property assignment (state.user = …), increment and delete, and .set(...) / .update(...) calls. Universal +page.ts/+layout.ts load functions are included, since they run on the server during SSR.
Not flagged:
- A universal
+page.ts/+layout.tsfile that itself exportsssr = false. That load never runs on the server, so there is no shared-process instance to leak through.+page.server.tsstill runs server-side regardless ofssr, so server-kind files are unaffected. - Reads, other method calls (
logger.info(…)), and writes to local variables. .set()/.update()on imports from installed packages..set()/.update()on a persistence client resolving tosrc/lib/server: the directory entrypoint (import { db } from '$lib/server') or anything undersrc/lib/server/**, such as Drizzle’sdb.update(...).set(...). Those calls are persistence, not shared module state.
The src/lib/server exemption applies to the resolved path, so it holds however the module is imported, via the $lib/server/ alias or a relative path (../../lib/server/db). A specifier whose .. segments escape the project root is conservatively never treated as repo-local state.
The exemption is not the directory alone. svelte-vitals reads the target module and keeps the call exempt only when the export is not an in-memory container. An export initialized to new Map/Set/WeakMap/WeakSet, or to an object or array literal, is a hand-rolled store, one shared instance overwritten per request, and is reported even under src/lib/server:
export const db = new Map(); // reported when a handler calls db.set(...)
export const client = drizzle(url); // exempt — not a container literal
Anything the read cannot positively identify as a container stays exempt, so a wrapper around a real client, a re-export, or an unreadable module is never a false positive. Only the modules a handler actually writes to are read.
Why it matters
This is the pattern SvelteKit’s state-management docs mark “NEVER DO THIS”. The server is one long-lived process shared by every user, so module state written during one request is still there when the next request arrives. If that state holds per-request or per-user data, one user’s data can leak to another. It works perfectly in single-user dev and corrupts silently in production.
Not every promoted write is a leak, though. A rate limiter or memoization cache keyed by something non-personal (an IP, a URL, a cache key) is the benign shape: it shares data across users by design, and that’s fine. The finding fires on the write regardless, because the same call shape produces both. Verify which one a given write actually is.
How to fix
Return the data instead of storing it:
import { user } from '$lib/user';
export async function load({ fetch }) {
const response = await fetch('/api/user');
user.set(await response.json()); // ❌ shared across ALL requests on the server
return { user: await response.json() }; // ✅ per-request page data
}
Per-user data belongs in cookies/locals plus a database; share loaded data with components via page.data or the context API. If the write is genuinely a rate limiter or memoization cache keyed by non-personal data, add // svelte-vitals-disable-next-line security/handler-state-write above it.
Mode differences
None. This rule reads source, the same .svelte and .ts files, everywhere it runs. The CLI, the Vite plugin’s build pass, and the live dashboard’s static baseline all report it identically, and the rendered-HTML pass never re-evaluates it. Scoping a run with --route skips it: component-scoped rules have no route to attribute a finding to.
Disabling
Silence a single occurrence with <!-- svelte-vitals-disable-next-line security/handler-state-write --> on the line above it, or turn the rule off:
export default {
rules: {
'security/handler-state-write': 'off'
}
};