Nuxt 3 went stable in November 2022 and now ships server-side rendering through Nitro, a universal server engine that keeps a single Node.js process alive across many concurrent requests rather than spinning up a fresh execution context per page load. That architecture is what makes Nuxt fast, and it's also the source of its most Nuxt-specific security defect class: state declared at module scope — a plain ref() or object created outside setup() — is created once when the server boots, then shared by every request that follows. If that shared state ever holds per-user data, one visitor's session details can render inside another visitor's HTML. Nuxt's own state-management documentation calls this out directly and recommends useState(), a composable that scopes reactive state to the current request and dedupes it by key, specifically to avoid this failure mode. Community reports and issues filed against the nuxt/nuxt GitHub repository describe the same pattern showing up in production as intermittent, hard-to-reproduce data mixups. This post walks through that leakage pattern, how to lock down Content-Security-Policy headers under SSR with per-request nonces, and how to keep Nitro server routes and runtime config from becoming your next incident.
Why does state leak across requests in a Nuxt 3 server?
State leaks across requests because Nitro reuses one Node.js process — and therefore one JavaScript module scope — for the lifetime of the server, not per request. A ref(), reactive(), or plain object declared at the top level of a .vue file, a plugin, or a composable outside of setup()/<script setup> is instantiated exactly once, at boot. Every subsequent request that imports that module reads and writes the same in-memory object. In a client-only SPA this is harmless because each browser tab gets its own JS runtime; under SSR it means request A's write can still be sitting in memory when request B renders. Nuxt's official fix is useState(key, init), which stores the value in the per-request Nuxt payload instead of module scope, so it's automatically isolated and also serialized safely from server to client. The practical rule: any mutable data touched during rendering that isn't purely derived from props or route params should go through useState(), a Pinia store scoped per request, or be fetched fresh — never a bare module-level variable.
How do you deploy a real Content-Security-Policy under SSR?
Nuxt 3 has no built-in CSP support, so most production deployments add the community nuxt-security module, which layers Helmet-style headers and OWASP-aligned protections — CSP, HSTS, X-Frame-Options, rate limiting, and CSRF protection — onto Nitro's request pipeline. The hard part of CSP under SSR has always been inline <script> and <style> tags Vue injects for hydration, which normally force teams into unsafe-inline and defeat the policy's purpose. nuxt-security addresses this by generating a cryptographically random nonce on the server for every request and attaching it both to the Content-Security-Policy header and to the inline tags Nuxt renders, so the browser only executes inline content carrying that request's nonce. Because the nonce is minted per request rather than baked into a build artifact, it can't be reused across users or cached at a CDN edge without care — teams fronting Nuxt with a CDN need to verify caching is disabled for HTML responses carrying a nonce, or every cached visitor ends up sharing (and eventually leaking) the same value.
Can CSP policy differ by route, and should it?
Yes, and for most real applications it should: Nuxt's routeRules config lets you set different security headers per path pattern rather than one global policy for the whole app. A marketing site's blog pages might tolerate a looser policy for embedded third-party widgets, while /checkout or /account routes handling payment or session data warrant a strict policy with no third-party script origins at all. nuxt-security reads these overrides from routeRules the same way Nuxt itself uses them for caching and rendering mode, so a route can be forced to swr caching and a locked-down CSP in the same config block. The security payoff of splitting policy by route is concrete: a single global CSP loose enough to satisfy every third-party embed on a marketing page effectively becomes the policy protecting your login form too, unless you deliberately narrow it. Auditing routeRules for which paths inherit the default (often looser) policy versus which explicitly override it is a five-minute check worth doing before every release that touches routing.
What goes wrong in Nitro server routes and middleware?
Files under server/api and server/middleware run as ordinary Nitro (and therefore Node.js) handlers, so they inherit every classic backend risk: missing input validation, path parameters trusted without checks, and middleware registered in an order that lets an unauthenticated route slip past an auth check meant to guard it. Nitro executes server/middleware files in alphabetical filename order by default, which is an easy trap — a file named auth.ts runs before logging.ts alphabetically regardless of whether that's the dependency order you actually need, so teams commonly prefix files (01.auth.ts, 02.session.ts) to force explicit ordering. Because server/api handlers receive the raw H3Event object, none of Vue's template-level escaping applies; any value read from getQuery(), readBody(), or route params should be validated explicitly — Nuxt's own scaffolding and most production Nitro guides recommend a schema validator such as Zod at the top of every handler rather than trusting request shape implicitly.
How do you stop server secrets from reaching the client bundle?
Nuxt's runtimeConfig object is split into two halves specifically to prevent this: keys nested under runtimeConfig.public are bundled into the client-side JavaScript and readable by anyone who opens dev tools, while every other top-level key stays server-only and is stripped from the client build entirely. The mistake happens when a developer adds an API key or database URL directly under public for convenience — often to unblock a client-side fetch call during development — and it ships to production still under public. Because Nuxt performs this split at build time rather than at runtime, there's no runtime warning when it happens; the only way to catch it is to grep your nuxt.config for runtimeConfig.public and confirm every key there is genuinely safe to expose, or add it to code review checklists for any PR that touches server config.
How does Safeguard fit into this?
None of the patterns above are dependency vulnerabilities — they're application-code and configuration decisions, which is exactly the gap between static analysis and software composition analysis (SCA) that a hardening checklist like this one is meant to close. Where Safeguard's software supply chain platform does add direct value is the layer underneath: SCA scanning across a Nuxt project's package.json and lockfile catches known-vulnerable versions of nuxt-security, Nitro, or any of the dozens of transitive packages a Nuxt app pulls in, and reachability analysis tells you whether a flagged CVE in one of those dependencies sits in a code path your app actually executes. Pairing that dependency-layer coverage with the SSR-specific practices in this post — useState() over module-scoped refs, per-request CSP nonces, explicit routeRules per route, validated Nitro handlers, and a disciplined runtimeConfig split — covers both halves of what a Nuxt 3 threat model actually needs to account for.