Nuxt SSR State Leaks: When One User Sees Another User's Data
A support ticket came in on a Tuesday: "I logged in and the account menu says Daniel. I am not Daniel."
My first guess was a CDN cache serving someone else's HTML. I hit origin with curl instead, no CDN, fresh session, and Daniel's name was in the response. So it was ours: a cross-request state leak, what the Nuxt docs call state pollution, in an app that had been live for a month.
Somewhere in app/composables a teammate had written the obvious thing:
// app/composables/useCurrentUser.ts
import { ref } from 'vue'
export const currentUser = ref<User | null>(null)
export function useCurrentUser() {
return currentUser
}
Anyone who has written Vue recognizes that shape. One ref, exported, imported wherever the header needs a name. It works in a SPA. It worked in npm run dev for six weeks. It even worked in production, as long as one person was logged in at a time. Not the condition you test under.
The module is a process, not a request
ES modules are evaluated once. The body of that file runs the first time anything imports it, and the ref it creates belongs to the Node process from then on. Nuxt builds a fresh Vue app and a fresh Nuxt app context for every request, so anything created inside setup() or a plugin body gets the right lifetime. Module scope sits outside that boundary, which makes the ref a singleton shared by every visitor until the process restarts.
Request A logs in and sets currentUser.value. Request B arrives a second later, the header renders, the ref still holds A's user. B's HTML now contains A's name, and Nuxt serializes that value into B's payload so the browser hydrates with it. Nobody has to be logged in as anyone. They just have to arrive second. The docs say never define const state = ref() outside of setup() for precisely this reason, and I had read that line once and filed it under things that do not apply to my code.
Reproducing it in two curl calls
You do not need concurrency to prove this. Sequential requests are enough:
# log in as two accounts, keep both cookie jars
curl -s -c /tmp/a.txt -X POST localhost:3000/api/login \
-H 'content-type: application/json' \
-d '{"email":"[email protected]","password":"..."}'
curl -s -c /tmp/b.txt -X POST localhost:3000/api/login \
-H 'content-type: application/json' \
-d '{"email":"[email protected]","password":"..."}'
# now request the page as B only
curl -s -b /tmp/b.txt localhost:3000/ | grep -o '[email protected]'
If the last line prints anything, it is a data leak and not a rendering bug. Grep for the other account's email, tenant ID, or whatever identifies a user in your app, and run it against the built server (node .output/server/index.mjs) rather than dev. Dev restarts on every file save, which hides the problem on its own.
Grep for the shape
Variable names tell you nothing here. Search for the shape instead:
grep -rnE '^(export )?(const|let) [A-Za-z0-9_]+ = (ref|reactive|shallowRef|computed|new Map|new Set)\(' \
app/composables app/utils app/stores app/plugins
Two kinds of hits are harmless: frozen config objects, and constants that never change after import. The dangerous ones are mutable, hold per-user data, and live in a module every request touches.
The fix, and the key you will leave out
useState exists for exactly this. Its value lives on the current request's Nuxt instance and gets serialized into the payload, so the client hydrates without refetching:
// app/composables/useCurrentUser.ts
export function useCurrentUser() {
return useState<User | null>('current-user', () => null)
}
Pass a key. I know it is optional. Without one, Nuxt generates a key from the file and line number of the useState call, which works right up until someone moves that call up four lines during a refactor and the key silently changes. Now the code reading it and the code writing it are not looking at the same state, and nothing warns you. The reverse mistake is a wrapper that hides the key, collapsing several logical states onto one.
Inside components, plain ref() is still correct. This is about ownership, not about which API you call. State belonging to one component instance can be a ref. State that outlives one request cannot be a module. When you need to drop the value, clearNuxtState('current-user') deletes the cached state for that key.
What useState will not save you from
The value goes into the payload, and anything that does not survive that trip dies quietly. Class instances throw Cannot stringify arbitrary non-POJOs. A Date comes back on the client as a string. Functions and symbols vanish. Keep plain data in there and rebuild the instance on the client, or register a custom type with definePayloadPlugin.
The payload also ships inside the HTML. Put an internal token or an unpublished database field into useState so it will be "available on the client", and you have published it. Nothing in the composable filters what leaves the server.
One relative worth knowing: VueUse's createGlobalState and createSharedComposable both keep a single instance for the whole module, which is the same lifetime problem in a friendlier name.
The memory half of the same warning
Our repo had a second file doing this:
const cache = new Map<string, Profile>()
export function rememberProfile(id: string, profile: Profile) {
cache.set(id, profile) // keyed by user id, never evicted
}
Every request that touched a profile added an entry to a map that lived as long as the process did. RSS climbed for three weeks and we blamed the ORM. If you need a server-side cache in Nuxt, Nitro's useStorage at least makes the lifetime and backing driver explicit, and user data in it needs a TTL. A bare module-level Map never has one.
So I run that grep and read every line it prints before shipping. The two bugs it would have caught cost us a week of confused tickets and one awkward email to a customer who had screenshotted another person's order history. The grep and the curl check live in Snippet Ark, next to every other never-again command I collect. The curl part is also the quickest way to inspect payload trouble like the useFetch vs $fetch trap, since both come down to what ends up in that payload.